diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index b1303e28..d83fbe01 100644 --- a/cmd/creinit/creinit_test.go +++ b/cmd/creinit/creinit_test.go @@ -3,6 +3,7 @@ package creinit import ( "fmt" "os" + "os/exec" "path/filepath" "testing" @@ -75,6 +76,82 @@ func requireNoDirExists(t *testing.T, dirPath string) { require.Falsef(t, fi.IsDir(), "directory %s should NOT exist", dirPath) } +// runLanguageSpecificTests runs the appropriate test suite based on the language field. +// For TypeScript: runs bun install and bun test in the workflow directory. +// For Go: runs go test ./... in the workflow directory. +func runLanguageSpecificTests(t *testing.T, workflowDir, language string) { + t.Helper() + + switch language { + case "typescript": + runTypescriptTests(t, workflowDir) + case "go": + runGoTests(t, workflowDir) + default: + t.Logf("Unknown language %q, skipping tests", language) + } +} + +// runTypescriptTests executes TypeScript tests using bun. +// Follows the cre init instructions: bun install --cwd then bun test in that directory. +func runTypescriptTests(t *testing.T, workflowDir string) { + t.Helper() + + testFile := filepath.Join(workflowDir, "main.test.ts") + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Logf("Skipping TS tests: no main.test.ts in %s", workflowDir) + return + } + + t.Logf("Running TypeScript tests in %s", workflowDir) + + // Install dependencies using bun install --cwd (as instructed by cre init) + installCmd := exec.Command("bun", "install", "--cwd", workflowDir, "--ignore-scripts") + installOutput, err := installCmd.CombinedOutput() + require.NoError(t, err, "bun install failed in %s:\n%s", workflowDir, string(installOutput)) + t.Logf("bun install succeeded") + + // Run tests + testCmd := exec.Command("bun", "test") + testCmd.Dir = workflowDir + testOutput, err := testCmd.CombinedOutput() + require.NoError(t, err, "bun test failed in %s:\n%s", workflowDir, string(testOutput)) + t.Logf("bun test passed:\n%s", string(testOutput)) +} + +// runGoTests executes Go tests in the workflow directory. +func runGoTests(t *testing.T, workflowDir string) { + t.Helper() + + // Check if there's a go.mod or any .go test files + hasGoTests := false + entries, err := os.ReadDir(workflowDir) + if err != nil { + t.Logf("Skipping Go tests: cannot read %s", workflowDir) + return + } + + for _, entry := range entries { + if filepath.Ext(entry.Name()) == "_test.go" { + hasGoTests = true + break + } + } + + if !hasGoTests { + t.Logf("Skipping Go tests: no *_test.go files in %s", workflowDir) + return + } + + t.Logf("Running Go tests in %s", workflowDir) + + testCmd := exec.Command("go", "test", "./...") + testCmd.Dir = workflowDir + testOutput, err := testCmd.CombinedOutput() + require.NoError(t, err, "go test failed in %s:\n%s", workflowDir, string(testOutput)) + t.Logf("go test passed:\n%s", string(testOutput)) +} + func TestInitExecuteFlows(t *testing.T) { cases := []struct { name string @@ -86,6 +163,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel string expectWorkflowName string expectTemplateFiles []string + language string // "go" or "typescript" }{ { name: "explicit project, default template via prompt, custom workflow via prompt", @@ -98,6 +176,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "myproj", expectWorkflowName: "myworkflow", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "only project, default template+workflow via prompt", @@ -110,6 +189,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "alpha", expectWorkflowName: "default-wf", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "no flags: prompt project, blank template, prompt workflow", @@ -123,6 +203,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "projX", expectWorkflowName: "workflow-X", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "workflow-name flag only, default template, no workflow prompt", @@ -135,6 +216,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "projFlag", expectWorkflowName: "flagged-wf", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "template-id flag only, no template prompt", @@ -146,6 +228,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "tplProj", expectWorkflowName: "workflow-Tpl", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "PoR template via flag with rpc-url provided (skips RPC prompt)", @@ -158,6 +241,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "porWithFlag", expectWorkflowName: "por-wf-01", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "TS template with rpc-url provided (flag ignored; no RPC prompt needed)", @@ -170,6 +254,31 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "tsWithRpcFlag", expectWorkflowName: "ts-wf-flag", expectTemplateFiles: GetTemplateFileListTS(), + language: "typescript", + }, + { + name: "TS PoR template", + projectNameFlag: "tsPorProj", + templateIDFlag: 4, // TypeScript PoR + workflowNameFlag: "ts-por-wf", + rpcURLFlag: "https://sepolia.example/rpc", + mockResponses: []string{}, + expectProjectDirRel: "tsPorProj", + expectWorkflowName: "ts-por-wf", + expectTemplateFiles: GetTemplateFileListTS(), + language: "typescript", + }, + { + name: "TS Confidential HTTP template", + projectNameFlag: "tsConfHTTP", + templateIDFlag: 5, // TypeScript Confidential HTTP + workflowNameFlag: "ts-confhttp-wf", + rpcURLFlag: "", + mockResponses: []string{}, + expectProjectDirRel: "tsConfHTTP", + expectWorkflowName: "ts-confhttp-wf", + expectTemplateFiles: GetTemplateFileListTS(), + language: "typescript", }, } @@ -199,8 +308,8 @@ func TestInitExecuteFlows(t *testing.T) { projectRoot := filepath.Join(tempDir, tc.expectProjectDirRel) validateInitProjectStructure(t, projectRoot, tc.expectWorkflowName, tc.expectTemplateFiles) - // NOTE: We deliberately don't assert Go/TS scaffolding here because the - // template chosen by prompt could vary; dedicated tests below cover both paths. + + runLanguageSpecificTests(t, filepath.Join(projectRoot, tc.expectWorkflowName), tc.language) }) } } diff --git a/cmd/creinit/template/workflow/typescriptConfHTTP/main.ts.tpl b/cmd/creinit/template/workflow/typescriptConfHTTP/main.ts.tpl index c12bc427..737c2b1e 100644 --- a/cmd/creinit/template/workflow/typescriptConfHTTP/main.ts.tpl +++ b/cmd/creinit/template/workflow/typescriptConfHTTP/main.ts.tpl @@ -56,7 +56,7 @@ const fetchResult = (sendRequester: ConfidentialHTTPSendRequester, config: Confi return json(response) as ResponseValues } -const onCronTrigger = (runtime: Runtime) => { +export const onCronTrigger = (runtime: Runtime) => { runtime.log('Confidential HTTP workflow triggered.') const confHTTPClient = new ConfidentialHTTPClient() @@ -75,7 +75,7 @@ const onCronTrigger = (runtime: Runtime) => { } } -const initWorkflow = (config: Config) => { +export const initWorkflow = (config: Config) => { const cron = new CronCapability() return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] diff --git a/cmd/creinit/template/workflow/typescriptConfHTTP/package.json.tpl b/cmd/creinit/template/workflow/typescriptConfHTTP/package.json.tpl index 1cc95d48..97946ee3 100644 --- a/cmd/creinit/template/workflow/typescriptConfHTTP/package.json.tpl +++ b/cmd/creinit/template/workflow/typescriptConfHTTP/package.json.tpl @@ -8,7 +8,7 @@ }, "license": "UNLICENSED", "dependencies": { - "@chainlink/cre-sdk": "^1.0.9", + "@chainlink/cre-sdk": "file:/Users/ryantinianov/ts/cre-sdk-typescript/packages/cre-sdk", "zod": "3.25.76" }, "devDependencies": { diff --git a/cmd/creinit/template/workflow/typescriptPorExampleDev/main.test.ts.tpl b/cmd/creinit/template/workflow/typescriptPorExampleDev/main.test.ts.tpl new file mode 100644 index 00000000..b28ff48f --- /dev/null +++ b/cmd/creinit/template/workflow/typescriptPorExampleDev/main.test.ts.tpl @@ -0,0 +1,286 @@ +import { HTTPClient, consensusIdenticalAggregation, getNetwork, TxStatus } from "@chainlink/cre-sdk"; +import { describe, expect } from "bun:test"; +import { + newTestRuntime, + test, + CapabilitiesNetworkingHttpV1alphaClientMock, + ClientCapabilityMock as EVMClientCapabilityMock, +} from "@chainlink/cre-sdk/test"; +import { initWorkflow, onCronTrigger, onLogTrigger, fetchReserveInfo } from "./main"; +import type { Config } from "./main"; +import { type Address, decodeFunctionData, encodeFunctionData, encodeFunctionResult } from "viem"; +import { BalanceReader, IERC20, MessageEmitter } from "../contracts/abi"; + +const mockConfig: Config = { + schedule: "0 0 * * *", + url: "https://example.com/api/por", + evms: [ + { + tokenAddress: "0x1234567890123456789012345678901234567890", + porAddress: "0x2234567890123456789012345678901234567890", + proxyAddress: "0x3234567890123456789012345678901234567890", + balanceReaderAddress: "0x4234567890123456789012345678901234567890", + messageEmitterAddress: "0x5234567890123456789012345678901234567890", + chainSelectorName: "ethereum-testnet-sepolia", + gasLimit: "1000000", + }, + ], +}; + +/** + * Helper to set up all EVM mocks for the PoR workflow. + * Mocks three contract call paths: + * 1. BalanceReader.getNativeBalances - returns mock native token balances + * 2. IERC20.totalSupply - returns mock total supply + * 3. MessageEmitter.getLastMessage - returns mock message (for log trigger) + * 4. WriteReport - returns success for reserve updates + */ +const setupEVMMocks = (config: Config) => { + const network = getNetwork({ + chainFamily: "evm", + chainSelectorName: config.evms[0].chainSelectorName, + isTestnet: true, + }); + + if (!network) { + throw new Error(`Network not found for chain selector: ${config.evms[0].chainSelectorName}`); + } + + const evmMock = EVMClientCapabilityMock.testInstance(network.chainSelector.selector); + + // Mock contract calls - route based on target address and function signature + evmMock.callContract = (req) => { + const toAddress = Buffer.from(req.call?.to || new Uint8Array()).toString("hex").toLowerCase(); + const callData = Buffer.from(req.call?.data || new Uint8Array()); + + // BalanceReader.getNativeBalances + if (toAddress === config.evms[0].balanceReaderAddress.slice(2).toLowerCase()) { + const decoded = decodeFunctionData({ + abi: BalanceReader, + data: `0x${callData.toString("hex")}` as Address, + }); + + if (decoded.functionName === "getNativeBalances") { + const addresses = decoded.args[0] as Address[]; + expect(addresses.length).toBeGreaterThan(0); + + // Return mock balance for each address (0.5 ETH in wei) + const mockBalances = addresses.map(() => 500000000000000000n); + const resultData = encodeFunctionResult({ + abi: BalanceReader, + functionName: "getNativeBalances", + result: mockBalances, + }); + + return { + data: Buffer.from(resultData.slice(2), "hex"), + }; + } + } + + // IERC20.totalSupply + if (toAddress === config.evms[0].tokenAddress.slice(2).toLowerCase()) { + const decoded = decodeFunctionData({ + abi: IERC20, + data: `0x${callData.toString("hex")}` as Address, + }); + + if (decoded.functionName === "totalSupply") { + // Return mock total supply (1 token with 18 decimals) + const mockSupply = 1000000000000000000n; + const resultData = encodeFunctionResult({ + abi: IERC20, + functionName: "totalSupply", + result: mockSupply, + }); + + return { + data: Buffer.from(resultData.slice(2), "hex"), + }; + } + } + + // MessageEmitter.getLastMessage + if (toAddress === config.evms[0].messageEmitterAddress.slice(2).toLowerCase()) { + const decoded = decodeFunctionData({ + abi: MessageEmitter, + data: `0x${callData.toString("hex")}` as Address, + }); + + if (decoded.functionName === "getLastMessage") { + // Verify the emitter address parameter is passed correctly + const emitterArg = decoded.args[0] as string; + expect(emitterArg).toBeDefined(); + + const mockMessage = "Test message from contract"; + const resultData = encodeFunctionResult({ + abi: MessageEmitter, + functionName: "getLastMessage", + result: mockMessage, + }); + + return { + data: Buffer.from(resultData.slice(2), "hex"), + }; + } + } + + throw new Error(`Unmocked contract call to ${toAddress} with data ${callData.toString("hex")}`); + }; + + // Mock writeReport for updateReserves + evmMock.writeReport = (req) => { + // Convert Uint8Array receiver to hex string for comparison + const receiverHex = `0x${Buffer.from(req.receiver || new Uint8Array()).toString("hex")}`; + expect(receiverHex.toLowerCase()).toBe(config.evms[0].proxyAddress.toLowerCase()); + expect(req.report).toBeDefined(); + // gasLimit is bigint, config has string - compare the values + expect(req.gasConfig?.gasLimit?.toString()).toBe(config.evms[0].gasLimit); + + return { + txStatus: TxStatus.SUCCESS, + txHash: new Uint8Array(Buffer.from("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "hex")), + errorMessage: "", + }; + }; + + return evmMock; +}; + +describe("fetchReserveInfo", () => { + test("fetches and parses reserve info using HTTP capability", async () => { + const runtime = newTestRuntime(); + runtime.config = mockConfig; + + const httpMock = CapabilitiesNetworkingHttpV1alphaClientMock.testInstance(); + + const mockPORResponse = { + accountName: "test-account", + totalTrust: 1500000, + totalToken: 1500000, + ripcord: false, + updatedAt: "2024-01-15T12:00:00Z", + }; + + httpMock.sendRequest = (req) => { + expect(req.method).toBe("GET"); + expect(req.url).toBe(mockConfig.url); + return { + statusCode: 200, + body: new TextEncoder().encode(JSON.stringify(mockPORResponse)), + headers: {}, + }; + }; + + const httpClient = new HTTPClient(); + const result = httpClient + .sendRequest(runtime, fetchReserveInfo, consensusIdenticalAggregation())(mockConfig) + .result(); + + expect(result.totalReserve).toBe(mockPORResponse.totalToken); + expect(result.lastUpdated).toBeInstanceOf(Date); + }); +}); + +describe("onCronTrigger", () => { + test("executes full PoR workflow with all EVM calls", () => { + const runtime = newTestRuntime(); + runtime.config = mockConfig; + + // Setup HTTP mock for reserve info + const httpMock = CapabilitiesNetworkingHttpV1alphaClientMock.testInstance(); + const mockPORResponse = { + accountName: "TrueUSD", + totalTrust: 1000000, + totalToken: 1000000, + ripcord: false, + updatedAt: "2023-01-01T00:00:00Z", + }; + + httpMock.sendRequest = (req) => { + expect(req.method).toBe("GET"); + expect(req.url).toBe(mockConfig.url); + return { + statusCode: 200, + body: new TextEncoder().encode(JSON.stringify(mockPORResponse)), + headers: {}, + }; + }; + + // Setup all EVM mocks + setupEVMMocks(mockConfig); + + // Execute trigger with mock payload + const result = onCronTrigger(runtime, { + scheduledExecutionTime: { + seconds: 1752514917n, + nanos: 0, + }, + }); + + // Result should be the totalToken from mock response + expect(result).toBeDefined(); + expect(typeof result).toBe("string"); + + // Verify expected log messages were produced + const logs = runtime.getLogs().map((log) => Buffer.from(log).toString("utf-8")); + expect(logs.some((log) => log.includes("fetching por"))).toBe(true); + expect(logs.some((log) => log.includes("ReserveInfo"))).toBe(true); + expect(logs.some((log) => log.includes("TotalSupply"))).toBe(true); + expect(logs.some((log) => log.includes("TotalReserveScaled"))).toBe(true); + expect(logs.some((log) => log.includes("NativeTokenBalance"))).toBe(true); + }); + + test("validates scheduledExecutionTime is present", () => { + const runtime = newTestRuntime(); + runtime.config = mockConfig; + + expect(() => onCronTrigger(runtime, {})).toThrow("Scheduled execution time is required"); + }); +}); + +describe("onLogTrigger", () => { + test("retrieves and returns message from contract", () => { + const runtime = newTestRuntime(); + runtime.config = mockConfig; + + // Setup EVM mock for MessageEmitter + setupEVMMocks(mockConfig); + + // Create mock EVMLog payload matching the expected structure + // topics[1] should contain the emitter address (padded to 32 bytes) + const mockLog = { + topics: [ + Buffer.from("1234567890123456789012345678901234567890123456789012345678901234", "hex"), + Buffer.from("000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcdefabcd", "hex"), + Buffer.from("000000000000000000000000000000000000000000000000000000006716eb80", "hex"), + ], + data: Buffer.from("", "hex"), + blockNumber: { value: 100n }, + }; + + const result = onLogTrigger(runtime, mockLog); + + expect(result).toBe("Test message from contract"); + + // Verify log message + const logs = runtime.getLogs().map((log) => Buffer.from(log).toString("utf-8")); + expect(logs.some((log) => log.includes("Message retrieved from the contract"))).toBe(true); + }); +}); + +describe("initWorkflow", () => { + test("returns two handlers with correct configuration", () => { + const testSchedule = "*/10 * * * *"; + const config = { ...mockConfig, schedule: testSchedule }; + + const handlers = initWorkflow(config); + + expect(handlers).toBeArray(); + expect(handlers).toHaveLength(2); + expect(handlers[0].trigger.config.schedule).toBe(testSchedule); + expect(handlers[0].fn.name).toBe("onCronTrigger"); + expect(handlers[1].trigger.config).toHaveProperty("addresses"); + expect(handlers[1].fn.name).toBe("onLogTrigger"); + }); +}); diff --git a/cmd/creinit/template/workflow/typescriptPorExampleDev/main.ts.tpl b/cmd/creinit/template/workflow/typescriptPorExampleDev/main.ts.tpl index 85271301..9f14c7f2 100644 --- a/cmd/creinit/template/workflow/typescriptPorExampleDev/main.ts.tpl +++ b/cmd/creinit/template/workflow/typescriptPorExampleDev/main.ts.tpl @@ -37,7 +37,7 @@ const configSchema = z.object({ ), }) -type Config = z.infer +export type Config = z.infer interface PORResponse { accountName: string @@ -56,7 +56,7 @@ interface ReserveInfo { const safeJsonStringify = (obj: any): string => JSON.stringify(obj, (_, value) => (typeof value === 'bigint' ? value.toString() : value), 2) -const fetchReserveInfo = (sendRequester: HTTPSendRequester, config: Config): ReserveInfo => { +export const fetchReserveInfo = (sendRequester: HTTPSendRequester, config: Config): ReserveInfo => { const response = sendRequester.sendRequest({ method: 'GET', url: config.url }).result() if (response.statusCode !== 200) { @@ -319,7 +319,7 @@ const getLastMessage = ( return message } -const onCronTrigger = (runtime: Runtime, payload: CronPayload): string => { +export const onCronTrigger = (runtime: Runtime, payload: CronPayload): string => { if (!payload.scheduledExecutionTime) { throw new Error('Scheduled execution time is required') } @@ -329,7 +329,7 @@ const onCronTrigger = (runtime: Runtime, payload: CronPayload): string = return doPOR(runtime) } -const onLogTrigger = (runtime: Runtime, payload: EVMLog): string => { +export const onLogTrigger = (runtime: Runtime, payload: EVMLog): string => { runtime.log('Running LogTrigger') const topics = payload.topics @@ -350,7 +350,7 @@ const onLogTrigger = (runtime: Runtime, payload: EVMLog): string => { return message } -const initWorkflow = (config: Config) => { +export const initWorkflow = (config: Config) => { const cronTrigger = new CronCapability() const network = getNetwork({ chainFamily: 'evm', diff --git a/cmd/creinit/template/workflow/typescriptPorExampleDev/package.json.tpl b/cmd/creinit/template/workflow/typescriptPorExampleDev/package.json.tpl index 38cc533b..605984c4 100644 --- a/cmd/creinit/template/workflow/typescriptPorExampleDev/package.json.tpl +++ b/cmd/creinit/template/workflow/typescriptPorExampleDev/package.json.tpl @@ -8,7 +8,7 @@ }, "license": "UNLICENSED", "dependencies": { - "@chainlink/cre-sdk": "^1.0.9", + "@chainlink/cre-sdk": "file:/Users/ryantinianov/ts/cre-sdk-typescript/packages/cre-sdk", "viem": "2.34.0", "zod": "3.25.76" }, diff --git a/cmd/creinit/template/workflow/typescriptSimpleExample/main.test.ts.tpl b/cmd/creinit/template/workflow/typescriptSimpleExample/main.test.ts.tpl new file mode 100644 index 00000000..d108aadb --- /dev/null +++ b/cmd/creinit/template/workflow/typescriptSimpleExample/main.test.ts.tpl @@ -0,0 +1,42 @@ +import { describe, expect } from "bun:test"; +import { newTestRuntime, test } from "@chainlink/cre-sdk/test"; +import { onCronTrigger, initWorkflow } from "./main"; +import type { Config } from "./main"; + +describe("onCronTrigger", () => { + test("logs message and returns greeting", async () => { + const config: Config = { schedule: "*/5 * * * *" }; + const runtime = newTestRuntime(); + runtime.config = config; + + const result = onCronTrigger(runtime); + + expect(result).toBe("Hello world!"); + const logs = runtime.getLogs(); + expect(logs).toContain("Hello world! Workflow triggered."); + }); +}); + +describe("initWorkflow", () => { + test("returns one handler with correct cron schedule", async () => { + const testSchedule = "0 0 * * *"; + const config: Config = { schedule: testSchedule }; + + const handlers = initWorkflow(config); + + expect(handlers).toBeArray(); + expect(handlers).toHaveLength(1); + expect(handlers[0].trigger.config.schedule).toBe(testSchedule); + }); + + test("handler executes onCronTrigger and returns result", async () => { + const config: Config = { schedule: "*/5 * * * *" }; + const runtime = newTestRuntime(); + runtime.config = config; + const handlers = initWorkflow(config); + + const result = handlers[0].fn(runtime, {}); + + expect(result).toBe(onCronTrigger(runtime)); + }); +}); diff --git a/cmd/creinit/template/workflow/typescriptSimpleExample/main.ts.tpl b/cmd/creinit/template/workflow/typescriptSimpleExample/main.ts.tpl index aada0405..36682c22 100644 --- a/cmd/creinit/template/workflow/typescriptSimpleExample/main.ts.tpl +++ b/cmd/creinit/template/workflow/typescriptSimpleExample/main.ts.tpl @@ -1,15 +1,15 @@ import { CronCapability, handler, Runner, type Runtime } from "@chainlink/cre-sdk"; -type Config = { +export type Config = { schedule: string; }; -const onCronTrigger = (runtime: Runtime): string => { +export const onCronTrigger = (runtime: Runtime): string => { runtime.log("Hello world! Workflow triggered."); return "Hello world!"; }; -const initWorkflow = (config: Config) => { +export const initWorkflow = (config: Config) => { const cron = new CronCapability(); return [ diff --git a/cmd/creinit/template/workflow/typescriptSimpleExample/package.json.tpl b/cmd/creinit/template/workflow/typescriptSimpleExample/package.json.tpl index cddfabf3..06ac5b04 100644 --- a/cmd/creinit/template/workflow/typescriptSimpleExample/package.json.tpl +++ b/cmd/creinit/template/workflow/typescriptSimpleExample/package.json.tpl @@ -8,7 +8,7 @@ }, "license": "UNLICENSED", "dependencies": { - "@chainlink/cre-sdk": "^1.0.9" + "@chainlink/cre-sdk": "file:/Users/ryantinianov/ts/cre-sdk-typescript/packages/cre-sdk" }, "devDependencies": { "@types/bun": "1.2.21" diff --git a/secrets.yaml b/secrets.yaml new file mode 100644 index 00000000..63307f2f --- /dev/null +++ b/secrets.yaml @@ -0,0 +1,3 @@ +secretsNames: + SECRET_ADDRESS: + - SECRET_ADDRESS_ALL diff --git a/test-wf/README.md b/test-wf/README.md new file mode 100644 index 00000000..df03f864 --- /dev/null +++ b/test-wf/README.md @@ -0,0 +1,53 @@ +# Typescript Simple Workflow Example + +This template provides a simple Typescript workflow example. It shows how to create a simple "Hello World" workflow using Typescript. + +Steps to run the example + +## 1. Update .env file + +You need to add a private key to env file. This is specifically required if you want to simulate chain writes. For that to work the key should be valid and funded. +If your workflow does not do any chain write then you can just put any dummy key as a private key. e.g. + +``` +CRE_ETH_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000001 +``` + +Note: Make sure your `workflow.yaml` file is pointing to the config.json, example: + +```yaml +staging-settings: + user-workflow: + workflow-name: "hello-world" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.json" +``` + +## 2. Install dependencies + +If `bun` is not already installed, see https://bun.com/docs/installation for installing in your environment. + +```bash +cd && bun install +``` + +Example: For a workflow directory named `hello-world` the command would be: + +```bash +cd hello-world && bun install +``` + +## 3. Simulate the workflow + +Run the command from project root directory + +```bash +cre workflow simulate --target=staging-settings +``` + +Example: For workflow named `hello-world` the command would be: + +```bash +cre workflow simulate ./hello-world --target=staging-settings +``` diff --git a/test-wf/config.production.json b/test-wf/config.production.json new file mode 100644 index 00000000..1a360cb3 --- /dev/null +++ b/test-wf/config.production.json @@ -0,0 +1,3 @@ +{ + "schedule": "*/30 * * * * *" +} diff --git a/test-wf/config.staging.json b/test-wf/config.staging.json new file mode 100644 index 00000000..1a360cb3 --- /dev/null +++ b/test-wf/config.staging.json @@ -0,0 +1,3 @@ +{ + "schedule": "*/30 * * * * *" +} diff --git a/test-wf/main.test.ts b/test-wf/main.test.ts new file mode 100644 index 00000000..40a673d1 --- /dev/null +++ b/test-wf/main.test.ts @@ -0,0 +1,49 @@ +import { describe, expect } from "bun:test"; +import { newTestRuntime, test } from "@chainlink/cre-sdk/test"; +import { onCronTrigger, initWorkflow } from "./main"; +import type { Config } from "./main"; + +describe("onCronTrigger", () => { + test("logs message and returns greeting", async () => { + const config: Config = { schedule: "*/5 * * * *" }; + const runtime = newTestRuntime(); + runtime.config = config; + + const result = onCronTrigger(runtime); + + expect(result).toBe("Hello world!"); + const logs = runtime.getLogs(); + expect(logs).toContain("Hello world! Workflow triggered."); + }); +}); + +describe("initWorkflow", () => { + test("returns array with one handler for cron trigger", async () => { + const config: Config = { schedule: "*/10 * * * *" }; + + const handlers = initWorkflow(config); + + expect(handlers).toBeArray(); + expect(handlers).toHaveLength(1); + }); + + test("handler uses correct schedule from config", async () => { + const testSchedule = "0 0 * * *"; + const config: Config = { schedule: testSchedule }; + + const handlers = initWorkflow(config); + + expect(handlers[0].trigger.config.schedule).toBe(testSchedule); + }); + + test("handler executes onCronTrigger and returns result", async () => { + const config: Config = { schedule: "*/5 * * * *" }; + const runtime = newTestRuntime(); + runtime.config = config; + const handlers = initWorkflow(config); + + const result = handlers[0].fn(runtime, {}); + + expect(result).toBe("Hello world!"); + }); +}); diff --git a/test-wf/main.ts b/test-wf/main.ts new file mode 100644 index 00000000..36682c22 --- /dev/null +++ b/test-wf/main.ts @@ -0,0 +1,28 @@ +import { CronCapability, handler, Runner, type Runtime } from "@chainlink/cre-sdk"; + +export type Config = { + schedule: string; +}; + +export const onCronTrigger = (runtime: Runtime): string => { + runtime.log("Hello world! Workflow triggered."); + return "Hello world!"; +}; + +export const initWorkflow = (config: Config) => { + const cron = new CronCapability(); + + return [ + handler( + cron.trigger( + { schedule: config.schedule } + ), + onCronTrigger + ), + ]; +}; + +export async function main() { + const runner = await Runner.newRunner(); + await runner.run(initWorkflow); +} diff --git a/test-wf/package.json b/test-wf/package.json new file mode 100644 index 00000000..06ac5b04 --- /dev/null +++ b/test-wf/package.json @@ -0,0 +1,16 @@ +{ + "name": "typescript-simple-template", + "version": "1.0.0", + "main": "dist/main.js", + "private": true, + "scripts": { + "postinstall": "bun x cre-setup" + }, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "file:/Users/ryantinianov/ts/cre-sdk-typescript/packages/cre-sdk" + }, + "devDependencies": { + "@types/bun": "1.2.21" + } +} diff --git a/test-wf/tsconfig.json b/test-wf/tsconfig.json new file mode 100644 index 00000000..840fdc79 --- /dev/null +++ b/test-wf/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "main.ts" + ] +} diff --git a/test-wf/workflow.yaml b/test-wf/workflow.yaml new file mode 100644 index 00000000..b8722213 --- /dev/null +++ b/test-wf/workflow.yaml @@ -0,0 +1,34 @@ +# ========================================================================== +# CRE WORKFLOW SETTINGS FILE +# ========================================================================== +# Workflow-specific settings for CRE CLI targets. +# Each target defines user-workflow and workflow-artifacts groups. +# Settings here override CRE Project Settings File values. +# +# Example custom target: +# my-target: +# user-workflow: +# workflow-name: "MyExampleWorkflow" # Required: Workflow Registry name +# workflow-artifacts: +# workflow-path: "./main.ts" # Path to workflow entry point +# config-path: "./config.yaml" # Path to config file +# secrets-path: "../secrets.yaml" # Path to secrets file (project root by default) + +# ========================================================================== +staging-settings: + user-workflow: + workflow-name: "test-wf-staging" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.staging.json" + secrets-path: "" + + +# ========================================================================== +production-settings: + user-workflow: + workflow-name: "test-wf-production" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.production.json" + secrets-path: "" \ No newline at end of file