From e9c80828a785af98136a9c1653dad95903a95f35 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Wed, 18 Feb 2026 12:35:26 -0500 Subject: [PATCH 1/4] Add unit tests to the typescript templates --- cmd/creinit/creinit_test.go | 113 ++++++- .../workflow/typescriptConfHTTP/main.ts.tpl | 4 +- .../typescriptConfHTTP/package.json.tpl | 2 +- .../typescriptPorExampleDev/main.test.ts.tpl | 286 ++++++++++++++++++ .../typescriptPorExampleDev/main.ts.tpl | 10 +- .../typescriptPorExampleDev/package.json.tpl | 2 +- .../typescriptSimpleExample/main.test.ts.tpl | 42 +++ .../typescriptSimpleExample/main.ts.tpl | 6 +- .../typescriptSimpleExample/package.json.tpl | 2 +- test-wf/README.md | 53 ++++ test-wf/config.production.json | 3 + test-wf/config.staging.json | 3 + test-wf/main.test.ts | 49 +++ test-wf/main.ts | 28 ++ test-wf/package.json | 16 + test-wf/tsconfig.json | 16 + test-wf/workflow.yaml | 34 +++ 17 files changed, 654 insertions(+), 15 deletions(-) create mode 100644 cmd/creinit/template/workflow/typescriptPorExampleDev/main.test.ts.tpl create mode 100644 cmd/creinit/template/workflow/typescriptSimpleExample/main.test.ts.tpl create mode 100644 test-wf/README.md create mode 100644 test-wf/config.production.json create mode 100644 test-wf/config.staging.json create mode 100644 test-wf/main.test.ts create mode 100644 test-wf/main.ts create mode 100644 test-wf/package.json create mode 100644 test-wf/tsconfig.json create mode 100644 test-wf/workflow.yaml diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index f414b1b5..1e9c815d 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) { // All inputs are provided via flags to avoid interactive prompts cases := []struct { @@ -86,6 +163,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel string expectWorkflowName string expectTemplateFiles []string + language string // "go" or "typescript" }{ { name: "Go PoR template with all flags", @@ -96,6 +174,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "myproj", expectWorkflowName: "myworkflow", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "Go HelloWorld template with all flags", @@ -106,6 +185,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "alpha", expectWorkflowName: "default-wf", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "Go HelloWorld with different project name", @@ -116,6 +196,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "projX", expectWorkflowName: "workflow-X", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "Go PoR with workflow flag", @@ -126,6 +207,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "projFlag", expectWorkflowName: "flagged-wf", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "Go HelloWorld template by ID", @@ -136,6 +218,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "tplProj", expectWorkflowName: "workflow-Tpl", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "Go PoR template with rpc-url", @@ -146,6 +229,7 @@ func TestInitExecuteFlows(t *testing.T) { expectProjectDirRel: "porWithFlag", expectWorkflowName: "por-wf-01", expectTemplateFiles: GetTemplateFileListGo(), + language: "go", }, { name: "TS HelloWorld template with rpc-url (ignored)", @@ -156,6 +240,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", }, } @@ -184,8 +293,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..4a1d4c6a 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": "^1.1.0", "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..530b45db 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": "^1.1.0", "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..6e3760bd 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": "^1.1.0" }, "devDependencies": { "@types/bun": "1.2.21" 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 From 88a92ed178794f75c0e38564547cd2d28914f943 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Wed, 18 Feb 2026 14:26:45 -0500 Subject: [PATCH 2/4] Bring your own WASM (#236) * Bring your own WASM * Fix lint and regenerate docs * Fix a bug adding extra env variables in tests that caused windows to fail the build * Really fix windows by updating how env vars work in the makefile? * Use filepath.join instead of hard-coded slash * New line replacement from template to fix windows test? * Changes requested by product, remove the template for init, rename the command * Revert test skip * Add project root to convert * make gendoc * fix lint --- Makefile | 6 +- cmd/common/compile.go | 152 +++++++++ cmd/common/compile_test.go | 128 +++++++ cmd/common/utils.go | 82 ++--- cmd/common/utils_test.go | 27 ++ cmd/creinit/creinit.go | 28 +- cmd/creinit/creinit_test.go | 22 +- cmd/root.go | 1 + cmd/workflow/convert/convert.go | 176 ++++++++++ cmd/workflow/convert/convert_test.go | 257 ++++++++++++++ cmd/workflow/deploy/compile.go | 62 +--- cmd/workflow/deploy/compile_test.go | 314 ++++++------------ cmd/workflow/deploy/deploy.go | 2 +- .../testdata/custom_wasm_workflow/Makefile | 8 + .../testdata/custom_wasm_workflow/config.yml | 3 + .../testdata/custom_wasm_workflow/go.mod | 68 ++++ .../testdata/custom_wasm_workflow/go.sum | 158 +++++++++ .../testdata/custom_wasm_workflow/main.go | 74 +++++ .../deploy/testdata/wasm_make_fails/Makefile | 4 + cmd/workflow/simulate/simulate.go | 62 +--- cmd/workflow/simulate/simulate_test.go | 13 +- cmd/workflow/workflow.go | 2 + docs/cre_workflow.md | 1 + docs/cre_workflow_custom-build.md | 38 +++ internal/constants/constants.go | 1 + internal/settings/workflow_settings.go | 67 ++++ internal/testutil/graphql_mock.go | 42 +++ internal/validation/files/path_read.go | 35 +- internal/validation/validation.go | 2 + test/convert_simulate_helper.go | 77 +++++ test/graphql_mock.go | 14 + ...binding_generation_and_simulate_go_test.go | 39 +-- test/init_and_simulate_ts_test.go | 39 +-- test/init_convert_simulate_go_test.go | 123 +++++++ test/init_convert_simulate_ts_test.go | 147 ++++++++ .../workflow_simulator_path.go | 33 +- 36 files changed, 1817 insertions(+), 490 deletions(-) create mode 100644 cmd/common/compile.go create mode 100644 cmd/common/compile_test.go create mode 100644 cmd/common/utils_test.go create mode 100644 cmd/workflow/convert/convert.go create mode 100644 cmd/workflow/convert/convert_test.go create mode 100644 cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile create mode 100644 cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml create mode 100644 cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod create mode 100644 cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum create mode 100644 cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go create mode 100644 cmd/workflow/deploy/testdata/wasm_make_fails/Makefile create mode 100644 docs/cre_workflow_custom-build.md create mode 100644 internal/testutil/graphql_mock.go create mode 100644 test/convert_simulate_helper.go create mode 100644 test/graphql_mock.go create mode 100644 test/init_convert_simulate_go_test.go create mode 100644 test/init_convert_simulate_ts_test.go diff --git a/Makefile b/Makefile index d96186c3..1d55f8c6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-admin lint test test-e2e clean goreleaser-dev-build install-tools install-foundry run-op gendoc +.PHONY: all build build-admin lint test test-e2e test-quick clean goreleaser-dev-build install-tools install-foundry run-op gendoc # Go parameters COMMIT_SHA = $(shell git rev-parse HEAD) @@ -29,6 +29,10 @@ test: lint test-e2e: $(GOTEST) -v -p 5 ./test/ +# test-quick: run tests with 30s timeout, skipping slow/flaky e2e tests. Use -short so TestE2EInit_ConvertToCustomBuild_TS is skipped. +test-quick: + $(GOTEST) ./... -v -short -skip 'MultiCommandHappyPaths|TestPostToGateway|TestBlankWorkflowSimulation|TestWaitForBackendLinkProcessing|TestTryAutoLink|TestCheckLinkStatusViaGraphQL|Fails to run tests with invalid Go code' -timeout 30s + clean: $(GOCLEAN) rm -f $(BINARY_NAME) diff --git a/cmd/common/compile.go b/cmd/common/compile.go new file mode 100644 index 00000000..4e844708 --- /dev/null +++ b/cmd/common/compile.go @@ -0,0 +1,152 @@ +package common + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/constants" +) + +const makefileName = "Makefile" + +var defaultWasmOutput = filepath.Join("wasm", "workflow.wasm") + +// getBuildCmd returns a single step that builds the workflow and returns the WASM bytes. +func getBuildCmd(workflowRootFolder, mainFile, language string) (func() ([]byte, error), error) { + tmpPath := filepath.Join(workflowRootFolder, ".cre_build_tmp.wasm") + switch language { + case constants.WorkflowLanguageTypeScript: + cmd := exec.Command("bun", "cre-compile", mainFile, tmpPath) + cmd.Dir = workflowRootFolder + return func() ([]byte, error) { + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + b, err := os.ReadFile(tmpPath) + _ = os.Remove(tmpPath) + return b, err + }, nil + case constants.WorkflowLanguageGolang: + // Build the package (.) so all .go files (main.go, workflow.go, etc.) are compiled together + cmd := exec.Command( + "go", "build", + "-o", tmpPath, + "-trimpath", + "-ldflags=-buildid= -w -s", + ".", + ) + cmd.Dir = workflowRootFolder + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") + return func() ([]byte, error) { + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + b, err := os.ReadFile(tmpPath) + _ = os.Remove(tmpPath) + return b, err + }, nil + case constants.WorkflowLanguageWasm: + makeRoot, err := findMakefileRoot(workflowRootFolder) + if err != nil { + return nil, err + } + makeCmd := exec.Command("make", "build") + makeCmd.Dir = makeRoot + builtPath := filepath.Join(makeRoot, defaultWasmOutput) + return func() ([]byte, error) { + out, err := makeCmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + return os.ReadFile(builtPath) + }, nil + default: + // Build the package (.) so all .go files are compiled together + cmd := exec.Command( + "go", "build", + "-o", tmpPath, + "-trimpath", + "-ldflags=-buildid= -w -s", + ".", + ) + cmd.Dir = workflowRootFolder + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") + return func() ([]byte, error) { + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out))) + } + b, err := os.ReadFile(tmpPath) + _ = os.Remove(tmpPath) + return b, err + }, nil + } +} + +// CompileWorkflowToWasm compiles the workflow at workflowPath and returns the WASM binary. +// It runs the sequence of commands from getBuildCmds (make build + copy for WASM, or single build for Go/TS), then reads the temp WASM file. +func CompileWorkflowToWasm(workflowPath string) ([]byte, error) { + workflowRootFolder, workflowMainFile, err := WorkflowPathRootAndMain(workflowPath) + if err != nil { + return nil, fmt.Errorf("workflow path: %w", err) + } + workflowAbsFile := filepath.Join(workflowRootFolder, workflowMainFile) + language := GetWorkflowLanguage(workflowMainFile) + + if language != constants.WorkflowLanguageWasm { + if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { + return nil, fmt.Errorf("workflow file not found: %s", workflowAbsFile) + } + } + + switch language { + case constants.WorkflowLanguageTypeScript: + if err := EnsureTool("bun"); err != nil { + return nil, errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") + } + case constants.WorkflowLanguageGolang: + if err := EnsureTool("go"); err != nil { + return nil, errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") + } + case constants.WorkflowLanguageWasm: + if err := EnsureTool("make"); err != nil { + return nil, errors.New("make is required for WASM workflows but was not found in PATH") + } + default: + return nil, fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) + } + + buildStep, err := getBuildCmd(workflowRootFolder, workflowMainFile, language) + if err != nil { + return nil, err + } + wasm, err := buildStep() + if err != nil { + return nil, fmt.Errorf("failed to compile workflow: %w", err) + } + return wasm, nil +} + +// findMakefileRoot walks up from dir and returns the first directory that contains a Makefile. +func findMakefileRoot(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + for { + if _, err := os.Stat(filepath.Join(abs, makefileName)); err == nil { + return abs, nil + } + parent := filepath.Dir(abs) + if parent == abs { + return "", errors.New("no Makefile found in directory or any parent (required for WASM workflow build)") + } + abs = parent + } +} diff --git a/cmd/common/compile_test.go b/cmd/common/compile_test.go new file mode 100644 index 00000000..aec71b63 --- /dev/null +++ b/cmd/common/compile_test.go @@ -0,0 +1,128 @@ +package common + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func deployTestdataPath(elem ...string) string { + _, filename, _, _ := runtime.Caller(0) + dir := filepath.Dir(filename) + return filepath.Join(append([]string{dir, "..", "workflow", "deploy", "testdata"}, elem...)...) +} + +func TestFindMakefileRoot(t *testing.T) { + dir := t.TempDir() + + _, err := findMakefileRoot(dir) + require.Error(t, err) + require.Contains(t, err.Error(), "no Makefile found") + + require.NoError(t, os.WriteFile(filepath.Join(dir, makefileName), []byte("build:\n\techo ok\n"), 0600)) + root, err := findMakefileRoot(dir) + require.NoError(t, err) + absDir, _ := filepath.Abs(dir) + require.Equal(t, absDir, root) + + sub := filepath.Join(dir, "wasm") + require.NoError(t, os.MkdirAll(sub, 0755)) + root, err = findMakefileRoot(sub) + require.NoError(t, err) + require.Equal(t, absDir, root) +} + +func TestCompileWorkflowToWasm_Go_Success(t *testing.T) { + t.Run("basic_workflow", func(t *testing.T) { + path := deployTestdataPath("basic_workflow", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) + + t.Run("configless_workflow", func(t *testing.T) { + path := deployTestdataPath("configless_workflow", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) + + t.Run("missing_go_mod", func(t *testing.T) { + path := deployTestdataPath("missing_go_mod", "main.go") + wasm, err := CompileWorkflowToWasm(path) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + }) +} + +func TestCompileWorkflowToWasm_Go_Malformed_Fails(t *testing.T) { + path := deployTestdataPath("malformed_workflow", "main.go") + _, err := CompileWorkflowToWasm(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to compile workflow") + assert.Contains(t, err.Error(), "undefined: sdk.RemovedFunctionThatFailsCompilation") +} + +func TestCompileWorkflowToWasm_Wasm_Success(t *testing.T) { + wasmPath := deployTestdataPath("custom_wasm_workflow", "wasm", "workflow.wasm") + _ = os.Remove(wasmPath) + t.Cleanup(func() { _ = os.Remove(wasmPath) }) + + wasm, err := CompileWorkflowToWasm(wasmPath) + require.NoError(t, err) + assert.NotEmpty(t, wasm) + + _, err = os.Stat(wasmPath) + require.NoError(t, err, "make build should produce wasm/workflow.wasm") +} + +func TestCompileWorkflowToWasm_Wasm_Fails(t *testing.T) { + t.Run("no_makefile", func(t *testing.T) { + dir := t.TempDir() + wasmDir := filepath.Join(dir, "wasm") + require.NoError(t, os.MkdirAll(wasmDir, 0755)) + wasmPath := filepath.Join(wasmDir, "workflow.wasm") + require.NoError(t, os.WriteFile(wasmPath, []byte("not really wasm"), 0600)) + + _, err := CompileWorkflowToWasm(wasmPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "no Makefile found") + }) + + t.Run("make_build_fails", func(t *testing.T) { + path := deployTestdataPath("wasm_make_fails", "wasm", "workflow.wasm") + _, err := CompileWorkflowToWasm(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to compile workflow") + assert.Contains(t, err.Error(), "build output:") + }) +} + +func TestCompileWorkflowToWasm_TS_Success(t *testing.T) { + if err := EnsureTool("bun"); err != nil { + t.Skip("bun not in PATH, skipping TS compile test") + } + dir := t.TempDir() + mainPath := filepath.Join(dir, "main.ts") + require.NoError(t, os.WriteFile(mainPath, []byte(`export async function main() { return "ok"; } +`), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"latest"}} +`), 0600)) + install := exec.Command("bun", "install") + install.Dir = dir + install.Stdout = os.Stdout + install.Stderr = os.Stderr + if err := install.Run(); err != nil { + t.Skipf("bun install failed (network or cre-sdk): %v", err) + } + wasm, err := CompileWorkflowToWasm(mainPath) + if err != nil { + t.Skipf("TS compile failed (published cre-sdk may lack full layout): %v", err) + } + assert.NotEmpty(t, wasm) +} diff --git a/cmd/common/utils.go b/cmd/common/utils.go index 4a000c2d..4649d549 100644 --- a/cmd/common/utils.go +++ b/cmd/common/utils.go @@ -145,15 +145,52 @@ func ToStringSlice(args []any) []string { } // GetWorkflowLanguage determines the workflow language based on the file extension -// Note: inputFile can be a file path (e.g., "main.ts" or "main.go") or a directory (for Go workflows, e.g., ".") -// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageGolang otherwise +// Note: inputFile can be a file path (e.g., "main.ts", "main.go", or "workflow.wasm") or a directory (for Go workflows, e.g., ".") +// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageWasm for .wasm files, constants.WorkflowLanguageGolang otherwise func GetWorkflowLanguage(inputFile string) string { if strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") { return constants.WorkflowLanguageTypeScript } + if strings.HasSuffix(inputFile, ".wasm") { + return constants.WorkflowLanguageWasm + } return constants.WorkflowLanguageGolang } +// ResolveWorkflowPath turns a workflow-path value from YAML (e.g. "." or "main.ts") into an +// absolute path to the main file. When pathFromYAML is "." or "", looks for main.go then main.ts +// under workflowDir. Callers can use GetWorkflowLanguage on the result to get the language. +func ResolveWorkflowPath(workflowDir, pathFromYAML string) (absPath string, err error) { + workflowDir, err = filepath.Abs(workflowDir) + if err != nil { + return "", fmt.Errorf("workflow directory: %w", err) + } + if pathFromYAML == "" || pathFromYAML == "." { + mainGo := filepath.Join(workflowDir, "main.go") + mainTS := filepath.Join(workflowDir, "main.ts") + if _, err := os.Stat(mainGo); err == nil { + return mainGo, nil + } + if _, err := os.Stat(mainTS); err == nil { + return mainTS, nil + } + return "", fmt.Errorf("no main.go or main.ts in %s", workflowDir) + } + joined := filepath.Join(workflowDir, pathFromYAML) + return filepath.Abs(joined) +} + +// WorkflowPathRootAndMain returns the absolute root directory and main file name for a workflow +// path (e.g. "workflowName/main.go" -> rootDir, "main.go"). Use with GetWorkflowLanguage(mainFile) +// for consistent language detection. +func WorkflowPathRootAndMain(workflowPath string) (rootDir, mainFile string, err error) { + abs, err := filepath.Abs(workflowPath) + if err != nil { + return "", "", fmt.Errorf("workflow path: %w", err) + } + return filepath.Dir(abs), filepath.Base(abs), nil +} + // EnsureTool checks that the binary exists on PATH func EnsureTool(bin string) error { if _, err := exec.LookPath(bin); err != nil { @@ -162,46 +199,9 @@ func EnsureTool(bin string) error { return nil } -// Gets a build command for either Golang or Typescript based on the filename -func GetBuildCmd(inputFile string, outputFile string, rootFolder string) *exec.Cmd { - isTypescriptWorkflow := strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") - - var buildCmd *exec.Cmd - if isTypescriptWorkflow { - buildCmd = exec.Command( - "bun", - "cre-compile", - inputFile, - outputFile, - ) - } else { - // The build command for reproducible and trimmed binaries. - // -trimpath removes all file system paths from the compiled binary. - // -ldflags="-buildid= -w -s" further reduces the binary size: - // -buildid= removes the build ID, ensuring reproducibility. - // -w disables DWARF debugging information. - // -s removes the symbol table. - buildCmd = exec.Command( - "go", - "build", - "-o", outputFile, - "-trimpath", - "-ldflags=-buildid= -w -s", - inputFile, - ) - buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0") - } - - buildCmd.Dir = rootFolder - - return buildCmd -} - func WriteChangesetFile(fileName string, changesetFile *inttypes.ChangesetFile, settings *settings.Settings) error { - // Set project context to ensure we're in the correct directory for writing the changeset file - // This is needed because workflow commands set the workflow directory as the context, but path for changeset file is relative to the project root - err := context.SetProjectContext("") - if err != nil { + // Set project context so the changeset path is resolved from project root + if err := context.SetProjectContext(""); err != nil { return err } diff --git a/cmd/common/utils_test.go b/cmd/common/utils_test.go new file mode 100644 index 00000000..d739abef --- /dev/null +++ b/cmd/common/utils_test.go @@ -0,0 +1,27 @@ +package common + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// ResolveWorkflowDir was removed; convert uses transformation.ResolveWorkflowPath (existing function). +// Project-root behavior for convert is tested in cmd/workflow/convert/convert_test.go TestConvert_ProjectRootFlag_ResolvesWorkflowDir. + +func TestResolveWorkflowPath_WorkflowDir(t *testing.T) { + // Sanity check: ResolveWorkflowPath(workflowDir, ".") returns main.go or main.ts when present + dir := t.TempDir() + mainGo := filepath.Join(dir, "main.go") + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + prev, _ := os.Getwd() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(prev) }) + + absDir, _ := filepath.Abs(dir) + got, err := ResolveWorkflowPath(absDir, ".") + require.NoError(t, err) + require.Equal(t, mainGo, got) +} diff --git a/cmd/creinit/creinit.go b/cmd/creinit/creinit.go index ee6dd145..df078e72 100644 --- a/cmd/creinit/creinit.go +++ b/cmd/creinit/creinit.go @@ -336,7 +336,7 @@ func (h *handler) Execute(inputs Inputs) error { } } - h.printSuccessMessage(projectRoot, workflowName, selectedLanguageTemplate.Lang) + h.printSuccessMessage(projectRoot, workflowName, workflowDirectory, selectedLanguageTemplate.Lang) return nil } @@ -358,25 +358,37 @@ func (h *handler) findExistingProject(dir string) (projectRoot string, language } } -func (h *handler) printSuccessMessage(projectRoot, workflowName string, lang TemplateLanguage) { +func (h *handler) printSuccessMessage(projectRoot, workflowName, workflowDirectory string, lang TemplateLanguage) { ui.Line() ui.Success("Project created successfully!") ui.Line() var steps string - if lang == TemplateLangGo { + workflowDirBase := filepath.Base(workflowDirectory) + projBase := filepath.Base(projectRoot) + readmeHint := filepath.Join(workflowDirBase, "README.md") + + switch lang { + case TemplateLangGo: steps = ui.RenderStep("1. Navigate to your project:") + "\n" + - " " + ui.RenderDim("cd "+filepath.Base(projectRoot)) + "\n\n" + + " " + ui.RenderDim("cd "+projBase) + "\n\n" + ui.RenderStep("2. Run the workflow:") + "\n" + - " " + ui.RenderDim("cre workflow simulate "+workflowName) - } else { + " " + ui.RenderDim("cre workflow simulate "+workflowName) + "\n\n" + + ui.RenderStep("3. (Optional) Consult "+readmeHint+" to learn more about this template.") + case TemplateLangTS: steps = ui.RenderStep("1. Navigate to your project:") + "\n" + - " " + ui.RenderDim("cd "+filepath.Base(projectRoot)) + "\n\n" + + " " + ui.RenderDim("cd "+projBase) + "\n\n" + ui.RenderStep("2. Install Bun (if needed):") + "\n" + " " + ui.RenderDim("npm install -g bun") + "\n\n" + - ui.RenderStep("3. Install dependencies:") + "\n" + + ui.RenderStep("3. Install workflow dependencies:") + "\n" + " " + ui.RenderDim("bun install --cwd ./"+workflowName) + "\n\n" + ui.RenderStep("4. Run the workflow:") + "\n" + + " " + ui.RenderDim("cre workflow simulate "+workflowName) + "\n\n" + + ui.RenderStep("5. (Optional) Consult "+readmeHint+" to learn more about this template.") + default: + steps = ui.RenderStep("1. Navigate to your project:") + "\n" + + " " + ui.RenderDim("cd "+projBase) + "\n\n" + + ui.RenderStep("2. Run the workflow:") + "\n" + " " + ui.RenderDim("cre workflow simulate "+workflowName) } diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index 1e9c815d..aa843052 100644 --- a/cmd/creinit/creinit_test.go +++ b/cmd/creinit/creinit_test.go @@ -243,24 +243,22 @@ func TestInitExecuteFlows(t *testing.T) { language: "typescript", }, { - name: "TS PoR template", - projectNameFlag: "tsPorProj", - templateIDFlag: 4, // TypeScript PoR - workflowNameFlag: "ts-por-wf", - rpcURLFlag: "https://sepolia.example/rpc", - mockResponses: []string{}, + name: "TS PoR template", + projectNameFlag: "tsPorProj", + templateIDFlag: 4, // TypeScript PoR + workflowNameFlag: "ts-por-wf", + rpcURLFlag: "https://sepolia.example/rpc", 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{}, + name: "TS Confidential HTTP template", + projectNameFlag: "tsConfHTTP", + templateIDFlag: 5, // TypeScript Confidential HTTP + workflowNameFlag: "ts-confhttp-wf", + rpcURLFlag: "", expectProjectDirRel: "tsConfHTTP", expectWorkflowName: "ts-confhttp-wf", expectTemplateFiles: GetTemplateFileListTS(), diff --git a/cmd/root.go b/cmd/root.go index 0ea014da..18a2a8d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -378,6 +378,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre help": {}, "cre update": {}, "cre workflow": {}, + "cre workflow custom-build": {}, "cre account": {}, "cre secrets": {}, "cre": {}, diff --git a/cmd/workflow/convert/convert.go b/cmd/workflow/convert/convert.go new file mode 100644 index 00000000..9734630d --- /dev/null +++ b/cmd/workflow/convert/convert.go @@ -0,0 +1,176 @@ +package convert + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/transformation" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +const ( + wasmWorkflowPath = "./wasm/workflow.wasm" + convertWarning = "This will convert your workflow to a custom (self-compiled) build. This cannot be undone by the CLI. Continue?" +) + +type Inputs struct { + WorkflowFolder string + Force bool +} + +func New(runtimeContext *runtime.Context) *cobra.Command { + var force bool + convertCmd := &cobra.Command{ + Use: "custom-build ", + Short: "Converts an existing workflow to a custom (self-compiled) build", + Long: `Converts a Go or TypeScript workflow to use a custom build via Makefile, producing wasm/workflow.wasm. The workflow-path in workflow.yaml is updated to ./wasm/workflow.wasm. This cannot be undone.`, + Args: cobra.ExactArgs(1), + Example: `cre workflow custom-build ./my-workflow`, + RunE: func(cmd *cobra.Command, args []string) error { + handler := newHandler(runtimeContext) + inputs := Inputs{ + WorkflowFolder: args[0], + Force: force, + } + return handler.Execute(inputs) + }, + } + convertCmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt and convert immediately") + return convertCmd +} + +// confirmFn is the type for the confirmation prompt; production uses ui.Confirm (Charm). +type confirmFn func(title string, opts ...ui.ConfirmOption) (bool, error) + +type handler struct { + log *zerolog.Logger + runtimeContext *runtime.Context + confirmFn confirmFn // always set: ui.Confirm in production, test double in tests +} + +func newHandler(runtimeContext *runtime.Context) *handler { + h := &handler{runtimeContext: runtimeContext, confirmFn: ui.Confirm} + if runtimeContext != nil { + h.log = runtimeContext.Logger + } + return h +} + +func (h *handler) Execute(inputs Inputs) error { + projectRoot := "" + if h.runtimeContext != nil && h.runtimeContext.Viper != nil { + projectRoot = h.runtimeContext.Viper.GetString(settings.Flags.ProjectRoot.Name) + } + var workflowDir string + if projectRoot != "" { + // Use the same resolution as other workflow commands: ResolveWorkflowPath resolves relative to CWD + prevWd, err := os.Getwd() + if err != nil { + return fmt.Errorf("workflow folder path: %w", err) + } + if err := os.Chdir(projectRoot); err != nil { + return fmt.Errorf("project root path: %w", err) + } + defer func() { _ = os.Chdir(prevWd) }() + workflowDir, err = transformation.ResolveWorkflowPath(inputs.WorkflowFolder) + if err != nil { + return err + } + } else { + var err error + workflowDir, err = transformation.ResolveWorkflowPath(inputs.WorkflowFolder) + if err != nil { + return err + } + } + workflowYAML := filepath.Join(workflowDir, constants.DefaultWorkflowSettingsFileName) + currentPath, err := settings.GetWorkflowPathFromFile(workflowYAML) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("workflow folder does not contain %s: %w", constants.DefaultWorkflowSettingsFileName, err) + } + return err + } + workflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, currentPath) + if err != nil { + return fmt.Errorf("cannot detect workflow language: %w", err) + } + lang := cmdcommon.GetWorkflowLanguage(workflowPath) + if lang == constants.WorkflowLanguageWasm { + return fmt.Errorf("workflow is already a custom build (workflow-path is %s)", currentPath) + } + + if !inputs.Force { + confirmed, err := h.confirmFn(convertWarning, ui.WithLabels("Yes", "No")) + if err != nil { + return err + } + if !confirmed { + ui.Dim("Convert cancelled.") + return nil + } + } + + if err := settings.SetWorkflowPathInFile(workflowYAML, wasmWorkflowPath); err != nil { + return err + } + + wasmDir := filepath.Join(workflowDir, "wasm") + if err := os.MkdirAll(wasmDir, 0755); err != nil { + return fmt.Errorf("create wasm directory: %w", err) + } + + makefilePath := filepath.Join(workflowDir, "Makefile") + mainFile := filepath.Base(workflowPath) + makefile, err := makefileContent(workflowDir, lang, mainFile) + if err != nil { + return err + } + if err := os.WriteFile(makefilePath, []byte(makefile), 0600); err != nil { + return fmt.Errorf("write Makefile: %w", err) + } + + ui.Success("Workflow converted to custom build. workflow-path is now " + wasmWorkflowPath) + ui.Dim("The Makefile is configured to output the WASM to this path. Run: make build") + return nil +} + +func goMakefile() string { + return `.PHONY: build + +export GOOS := wasip1 +export GOARCH := wasm +export CGO_ENABLED := 0 + +build: + go build -o wasm/workflow.wasm -trimpath -ldflags="-buildid= -w -s" . +` +} + +func makefileContent(workflowDir, lang string, mainFile string) (string, error) { + switch lang { + case constants.WorkflowLanguageGolang: + return goMakefile(), nil + case constants.WorkflowLanguageTypeScript: + return makefileContentTS(workflowDir, mainFile) + default: + return "", fmt.Errorf("unsupported workflow language") + } +} + +func makefileContentTS(_, mainFile string) (string, error) { + return fmt.Sprintf(`.PHONY: build + +build: + bun cre-compile %s wasm/workflow.wasm +`, mainFile), nil +} diff --git a/cmd/workflow/convert/convert_test.go b/cmd/workflow/convert/convert_test.go new file mode 100644 index 00000000..a400749b --- /dev/null +++ b/cmd/workflow/convert/convert_test.go @@ -0,0 +1,257 @@ +package convert + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/testutil" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +func TestConvert_AlreadyWasm_ReturnsError(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + yamlContent := `staging-settings: + user-workflow: + workflow-name: "foo-staging" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "foo-production" + workflow-artifacts: + workflow-path: "./wasm/workflow.wasm" + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + + h := newHandler(nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "already a custom build") +} + +func TestConvert_ProjectRootFlag_ResolvesWorkflowDir(t *testing.T) { + // Project layout: projectRoot/workflowName/ with workflow.yaml and main.go. + // Each subtest gets its own dir so they don't share state (second run would see "already custom build"). + makeWorkflowUnderProjectRoot := func(t *testing.T) (projectRoot, workflowDir, workflowName string) { + t.Helper() + projectRoot = t.TempDir() + workflowName = "my-wf" + workflowDir = filepath.Join(projectRoot, workflowName) + require.NoError(t, os.MkdirAll(workflowDir, 0755)) + workflowYAML := filepath.Join(workflowDir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(workflowDir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + return projectRoot, workflowDir, workflowName + } + + for _, flagName := range []string{"-R", "--project-root"} { + projectRoot, workflowDir, workflowName := makeWorkflowUnderProjectRoot(t) + v := viper.New() + v.Set(settings.Flags.ProjectRoot.Name, projectRoot) + ctx := &runtime.Context{Viper: v, Logger: testutil.NewTestLogger()} + h := newHandler(ctx) + h.confirmFn = func(_ string, _ ...ui.ConfirmOption) (bool, error) { return true, nil } + err := h.Execute(Inputs{WorkflowFolder: workflowName, Force: false}) + require.NoError(t, err) + + workflowYAML := filepath.Join(workflowDir, constants.DefaultWorkflowSettingsFileName) + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath, "flag %s: workflow.yaml should be updated", flagName) + require.FileExists(t, filepath.Join(workflowDir, "Makefile"), "flag %s: Makefile should be created in workflow dir", flagName) + require.DirExists(t, filepath.Join(workflowDir, "wasm"), "flag %s: wasm dir should exist", flagName) + + } +} + +func TestConvert_Force_UpdatesYAMLAndCreatesMakefile(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + h := newHandler(nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + + require.DirExists(t, filepath.Join(dir, "wasm")) + makefile := filepath.Join(dir, "Makefile") + require.FileExists(t, makefile) + content, _ := os.ReadFile(makefile) + require.Contains(t, string(content), "build") + require.Contains(t, string(content), "wasm/workflow.wasm") +} + +func TestConvert_PromptNo_Cancels(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + h := newHandler(nil) + h.confirmFn = func(_ string, _ ...ui.ConfirmOption) (bool, error) { return false, nil } + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), "workflow-path: \".\"") + require.NotContains(t, string(data), wasmWorkflowPath) + require.NoFileExists(t, filepath.Join(dir, "Makefile")) +} + +func TestConvert_PromptYes_Proceeds(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + h := newHandler(nil) + h.confirmFn = func(_ string, _ ...ui.ConfirmOption) (bool, error) { return true, nil } + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + require.FileExists(t, filepath.Join(dir, "Makefile")) + require.DirExists(t, filepath.Join(dir, "wasm")) +} + +func TestConvert_PromptEmpty_DefaultsYes_Proceeds(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainGo := filepath.Join(dir, "main.go") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "." + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "." + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainGo, []byte("package main\n"), 0600)) + + h := newHandler(nil) + h.confirmFn = func(_ string, _ ...ui.ConfirmOption) (bool, error) { return true, nil } + err := h.Execute(Inputs{WorkflowFolder: dir, Force: false}) + require.NoError(t, err) + + data, err := os.ReadFile(workflowYAML) + require.NoError(t, err) + require.Contains(t, string(data), wasmWorkflowPath) + require.FileExists(t, filepath.Join(dir, "Makefile")) +} + +func TestConvert_TS_InstallsDepsIfNoNodeModules(t *testing.T) { + dir := t.TempDir() + workflowYAML := filepath.Join(dir, constants.DefaultWorkflowSettingsFileName) + mainTS := filepath.Join(dir, "main.ts") + packageJSON := filepath.Join(dir, "package.json") + yamlContent := `staging-settings: + user-workflow: + workflow-name: "wf-staging" + workflow-artifacts: + workflow-path: "main.ts" + config-path: "./config.staging.json" +production-settings: + user-workflow: + workflow-name: "wf-production" + workflow-artifacts: + workflow-path: "main.ts" + config-path: "./config.production.json" +` + require.NoError(t, os.WriteFile(workflowYAML, []byte(yamlContent), 0600)) + require.NoError(t, os.WriteFile(mainTS, []byte("export default function run() { return Promise.resolve({ result: \"ok\" }); }\n"), 0600)) + require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name":"test","private":true,"dependencies":{"@chainlink/cre-sdk":"^1.0.3"}}`), 0600)) + + h := newHandler(nil) + err := h.Execute(Inputs{WorkflowFolder: dir, Force: true}) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dir, "Makefile")) + makefile, _ := os.ReadFile(filepath.Join(dir, "Makefile")) + require.Contains(t, string(makefile), "bun cre-compile", "Makefile should match CLI build") + require.Contains(t, string(makefile), "main.ts", "Makefile should build main.ts") + require.Contains(t, string(makefile), "wasm/workflow.wasm", "Makefile should output to wasm/workflow.wasm") + + // CLI must not change the workflow; main.ts unchanged + mainTSContent, _ := os.ReadFile(mainTS) + require.Contains(t, string(mainTSContent), "export default function run()", "convert must not modify workflow source") +} diff --git a/cmd/workflow/deploy/compile.go b/cmd/workflow/deploy/compile.go index d18de7d4..4349319b 100644 --- a/cmd/workflow/deploy/compile.go +++ b/cmd/workflow/deploy/compile.go @@ -3,16 +3,13 @@ package deploy import ( "bytes" "encoding/base64" - "errors" "fmt" "os" - "path/filepath" "strings" "github.com/andybalholm/brotli" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" - "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/ui" ) @@ -35,61 +32,30 @@ func (h *handler) Compile() error { h.inputs.OutputPath += ".b64" // Append ".b64" if it doesn't already end with ".b64" } - workflowAbsFile, err := filepath.Abs(h.inputs.WorkflowPath) + workflowDir, err := os.Getwd() if err != nil { - return fmt.Errorf("failed to get absolute path for the workflow file: %w", err) + return fmt.Errorf("workflow directory: %w", err) } - - if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) { - return fmt.Errorf("workflow file not found: %s", workflowAbsFile) + resolvedWorkflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, h.inputs.WorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } + _, workflowMainFile, err := cmdcommon.WorkflowPathRootAndMain(resolvedWorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) } - - workflowRootFolder := filepath.Dir(h.inputs.WorkflowPath) - - tmpWasmFileName := "tmp.wasm" - workflowMainFile := filepath.Base(h.inputs.WorkflowPath) - - // Set language in runtime context based on workflow file extension if h.runtimeContext != nil { h.runtimeContext.Workflow.Language = cmdcommon.GetWorkflowLanguage(workflowMainFile) - - switch h.runtimeContext.Workflow.Language { - case constants.WorkflowLanguageTypeScript: - if err := cmdcommon.EnsureTool("bun"); err != nil { - return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") - } - case constants.WorkflowLanguageGolang: - if err := cmdcommon.EnsureTool("go"); err != nil { - return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") - } - default: - return fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) - } } - buildCmd := cmdcommon.GetBuildCmd(workflowMainFile, tmpWasmFileName, workflowRootFolder) - h.log.Debug(). - Str("Workflow directory", buildCmd.Dir). - Str("Command", buildCmd.String()). - Msg("Executing go build command") - - buildOutput, err := buildCmd.CombinedOutput() + wasmFile, err := cmdcommon.CompileWorkflowToWasm(resolvedWorkflowPath) if err != nil { ui.Error("Build failed:") - ui.Print(string(buildOutput)) - - out := strings.TrimSpace(string(buildOutput)) - return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + return fmt.Errorf("failed to compile workflow: %w", err) } - h.log.Debug().Msgf("Build output: %s", buildOutput) + h.log.Debug().Msg("Workflow compiled successfully") ui.Success("Workflow compiled successfully") - tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) - wasmFile, err := os.ReadFile(tmpWasmLocation) - if err != nil { - return fmt.Errorf("failed to read workflow binary: %w", err) - } - compressedFile, err := applyBrotliCompressionV2(&wasmFile) if err != nil { return fmt.Errorf("failed to compress WASM binary: %w", err) @@ -101,10 +67,6 @@ func (h *handler) Compile() error { } h.log.Debug().Msg("WASM binary encoded") - if err = os.Remove(tmpWasmLocation); err != nil { - return fmt.Errorf("failed to remove the temporary file: %w", err) - } - return nil } diff --git a/cmd/workflow/deploy/compile_test.go b/cmd/workflow/deploy/compile_test.go index d0ebadd8..51f42911 100644 --- a/cmd/workflow/deploy/compile_test.go +++ b/cmd/workflow/deploy/compile_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/testutil/chainsim" @@ -165,102 +166,6 @@ func TestCompileCmd(t *testing.T) { httpmock.Activate() t.Cleanup(httpmock.DeactivateAndReset) - tests := []struct { - inputs Inputs - wantErr string - compilationErr string - WorkflowOwnerType string - }{ - { - inputs: Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "malformed_workflow", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, - WorkflowOwnerType: constants.WorkflowOwnerTypeEOA, - wantErr: "failed to compile workflow: exit status 1", - compilationErr: "undefined: sdk.RemovedFunctionThatFailsCompilation", - }, - } - - for _, tt := range tests { - t.Run(tt.wantErr, func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - ctx, buf := simulatedEnvironment.NewRuntimeContextWithBufferedOutput() - handler := newHandler(ctx, buf) - - ctx.Settings = createTestSettings( - chainsim.TestAddress, - tt.WorkflowOwnerType, - "test_workflow", - tt.inputs.WorkflowPath, - tt.inputs.ConfigPath, - ) - handler.settings = ctx.Settings - handler.inputs = tt.inputs - err := handler.ValidateInputs() - require.NoError(t, err) - - err = handler.Execute() - - w.Close() - os.Stdout = oldStdout - var output strings.Builder - _, _ = io.Copy(&output, r) - - require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) - - if tt.compilationErr != "" { - assert.Contains(t, output.String(), tt.compilationErr) - } - }) - } - }) - - t.Run("no config", func(t *testing.T) { - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - ctx, _ := simulatedEnvironment.NewRuntimeContextWithBufferedOutput() - - ctx.Settings = createTestSettings( - chainsim.TestAddress, - constants.WorkflowOwnerTypeEOA, - "test_workflow", - "testdata/configless_workflow/main.go", - "", - ) - - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "configless_workflow", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - }) - - t.Run("with config", func(t *testing.T) { simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) defer simulatedEnvironment.Close() @@ -268,142 +173,54 @@ func TestCompileCmd(t *testing.T) { WorkflowName: "test_workflow", WorkflowOwner: chainsim.TestAddress, DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), + WorkflowPath: filepath.Join("testdata", "malformed_workflow", "main.go"), OutputPath: outputPath, - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to compile workflow") + assert.ErrorContains(t, err, "undefined: sdk.RemovedFunctionThatFailsCompilation") }) - - t.Run("compiles even without go.mod", func(t *testing.T) { - // it auto falls back to using the go.mod in the root directory (/cre-cli) - simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) - defer simulatedEnvironment.Close() - - httpmock.Activate() - t.Cleanup(httpmock.DeactivateAndReset) - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "missing_go_mod", "main.go"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - }) - }) } -func TestCompileCreatesBase64EncodedFile(t *testing.T) { +func TestCompileOutputMatchesUnderlying(t *testing.T) { simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) + baseInputs := Inputs{ + WorkflowName: "test_workflow", + WorkflowOwner: chainsim.TestAddress, + DonFamily: "test_label", + WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), + ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), + WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", + WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", + } - t.Run("default output file is binary.wasm.br", func(t *testing.T) { - expectedOutputPath := "./binary.wasm.br.b64" - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(expectedOutputPath) - - require.NoError(t, err) - assert.FileExists(t, expectedOutputPath) + t.Run("default output path", func(t *testing.T) { + inputs := baseInputs + inputs.OutputPath = "./binary.wasm.br.b64" + assertCompileOutputMatchesUnderlying(t, simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) }) - t.Run("ensures output file has .wasm.br.b64 extension", func(t *testing.T) { + t.Run("output path extension variants", func(t *testing.T) { tests := []struct { - name string - outputPath string - expectedOutput string + name string + outputPath string }{ - { - name: "no extension", - outputPath: "./my-binary", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "missing .br and .b64", - outputPath: "./my-binary.wasm", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "missing .b64", - outputPath: "./my-binary.wasm.br", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "all extensions", - outputPath: "./my-binary.wasm.br.b64", - expectedOutput: "./my-binary.wasm.br.b64", - }, - { - name: "all extensions - same as default", - outputPath: "./binary.wasm.br.b64", - expectedOutput: "./binary.wasm.br.b64", - }, + {"no extension", "./my-binary"}, + {"missing .br and .b64", "./my-binary.wasm"}, + {"missing .b64", "./my-binary.wasm.br"}, + {"all extensions", "./my-binary.wasm.br.b64"}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - OutputPath: tt.outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(tt.expectedOutput) - - require.NoError(t, err) - assert.FileExists(t, tt.expectedOutput) + inputs := baseInputs + inputs.OutputPath = tt.outputPath + assertCompileOutputMatchesUnderlying(t, simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) }) } }) - - t.Run("output file is base64 encoded", func(t *testing.T) { - outputPath := "./binary.wasm.br.b64" - - err := runCompile(simulatedEnvironment, Inputs{ - WorkflowName: "test_workflow", - WorkflowOwner: chainsim.TestAddress, - DonFamily: "test_label", - WorkflowPath: filepath.Join("testdata", "basic_workflow", "main.go"), - ConfigPath: filepath.Join("testdata", "basic_workflow", "config.yml"), - OutputPath: outputPath, - WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", - WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", - }, constants.WorkflowOwnerTypeEOA) - defer os.Remove(outputPath) - - require.NoError(t, err) - assert.FileExists(t, outputPath) - - // Read the output file content - content, err := os.ReadFile(outputPath) - require.NoError(t, err) - - // Check if the content is valid base64 - _, err = base64.StdEncoding.DecodeString(string(content)) - assert.NoError(t, err, "Output file content should be valid base64 encoded data") - }) } // createTestSettings is a helper function to construct settings for tests @@ -458,3 +275,76 @@ func runCompile(simulatedEnvironment *chainsim.SimulatedEnvironment, inputs Inpu return handler.Compile() } + +// outputPathWithExtensions returns the path with .wasm.br.b64 appended as in Compile(). +func outputPathWithExtensions(path string) string { + if path == "" { + path = defaultOutputPath + } + if !strings.HasSuffix(path, ".b64") { + if !strings.HasSuffix(path, ".br") { + if !strings.HasSuffix(path, ".wasm") { + path += ".wasm" + } + path += ".br" + } + path += ".b64" + } + return path +} + +// assertCompileOutputMatchesUnderlying compiles via handler.Compile(), then verifies the output +// file content equals CompileWorkflowToWasm(workflowPath) + brotli + base64. +func assertCompileOutputMatchesUnderlying(t *testing.T, simulatedEnvironment *chainsim.SimulatedEnvironment, inputs Inputs, ownerType string) { + t.Helper() + wasm, err := cmdcommon.CompileWorkflowToWasm(inputs.WorkflowPath) + require.NoError(t, err) + compressed, err := applyBrotliCompressionV2(&wasm) + require.NoError(t, err) + expected := base64.StdEncoding.EncodeToString(compressed) + + err = runCompile(simulatedEnvironment, inputs, ownerType) + require.NoError(t, err) + + actualPath := outputPathWithExtensions(inputs.OutputPath) + t.Cleanup(func() { _ = os.Remove(actualPath) }) + actual, err := os.ReadFile(actualPath) + require.NoError(t, err) + assert.Equal(t, expected, string(actual), "handler.Compile() output should match CompileWorkflowToWasm + brotli + base64") +} + +// TestCustomWasmWorkflowRunsMakeBuild ensures that simulate/deploy run "make build" for a custom +// WASM workflow (workflow-path pointing to .wasm) so the user does not need to run make build manually. +func TestCustomWasmWorkflowRunsMakeBuild(t *testing.T) { + customWasmDir := filepath.Join("testdata", "custom_wasm_workflow") + wasmPath := filepath.Join(customWasmDir, "wasm", "workflow.wasm") + + // Remove wasm file if present so we assert the CLI builds it (CompileWorkflowToWasm runs make via ensureWasmBuilt). + _ = os.Remove(wasmPath) + t.Cleanup(func() { _ = os.Remove(wasmPath) }) + + simulatedEnvironment := chainsim.NewSimulatedEnvironment(t) + defer simulatedEnvironment.Close() + + outputPath := filepath.Join(customWasmDir, "test_out.wasm.br.b64") + t.Cleanup(func() { _ = os.Remove(outputPath) }) + + inputs := Inputs{ + WorkflowName: "custom_wasm_workflow", + WorkflowOwner: chainsim.TestAddress, + DonFamily: "test_label", + WorkflowPath: wasmPath, + ConfigPath: filepath.Join(customWasmDir, "config.yml"), + WorkflowRegistryContractAddress: "0x1234567890123456789012345678901234567890", + WorkflowRegistryContractChainName: "ethereum-testnet-sepolia", + OutputPath: outputPath, + } + + // runCompile calls ValidateInputs then Compile; CompileWorkflowToWasm runs make build internally. No manual make build. + err := runCompile(simulatedEnvironment, inputs, constants.WorkflowOwnerTypeEOA) + require.NoError(t, err, "custom WASM workflow should build via CLI (CompileWorkflowToWasm) without manual make build") + + // Ensure the wasm was actually built by the CLI + _, err = os.Stat(wasmPath) + require.NoError(t, err, "wasm/workflow.wasm should exist after compile") +} diff --git a/cmd/workflow/deploy/deploy.go b/cmd/workflow/deploy/deploy.go index 6318acc7..6b4da9bd 100644 --- a/cmd/workflow/deploy/deploy.go +++ b/cmd/workflow/deploy/deploy.go @@ -31,7 +31,7 @@ type Inputs struct { ConfigURL *string `validate:"omitempty,http_url|eq="` KeepAlive bool - WorkflowPath string `validate:"required,path_read"` + WorkflowPath string `validate:"required,workflow_path_read"` ConfigPath string `validate:"omitempty,file,ascii,max=97" cli:"--config"` OutputPath string `validate:"omitempty,filepath,ascii,max=97" cli:"--output"` diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile b/cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile new file mode 100644 index 00000000..8f5b436e --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/Makefile @@ -0,0 +1,8 @@ +.PHONY: build + +export GOOS := wasip1 +export GOARCH := wasm +export CGO_ENABLED := 0 + +build: + go build -o wasm/workflow.wasm -trimpath -ldflags="-buildid= -w -s" . diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml b/cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml new file mode 100644 index 00000000..87df9017 --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/config.yml @@ -0,0 +1,3 @@ +workflowName: "Basic Workflow" +workflowOwner: "0x775edE8C0718c655e5238239aC553E9657bcd8C2" +basicTriggerInterval: 1 # in seconds diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod new file mode 100644 index 00000000..83f89f9e --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.mod @@ -0,0 +1,68 @@ +module custom_wasm_workflow + +go 1.23.3 + +toolchain go1.23.4 + +require ( + github.com/smartcontractkit/chainlink-common v0.4.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.13.0 // indirect + github.com/go-playground/universal-translator v0.17.0 // indirect + github.com/go-playground/validator/v10 v10.4.1 // indirect + github.com/go-viper/mapstructure/v2 v2.1.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/invopop/jsonschema v0.12.0 // indirect + github.com/leodido/go-urn v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.opentelemetry.io/otel v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240823153156-2a54df7bffb9 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.6.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.4.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 // indirect + go.opentelemetry.io/otel/log v0.6.0 // indirect + go.opentelemetry.io/otel/metric v1.30.0 // indirect + go.opentelemetry.io/otel/sdk v1.30.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.6.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.30.0 // indirect + go.opentelemetry.io/otel/trace v1.30.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum new file mode 100644 index 00000000..07060312 --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/go.sum @@ -0,0 +1,158 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= +github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w= +github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/santhosh-tekuri/jsonschema/v5 v5.2.0 h1:WCcC4vZDS1tYNxjWlwRJZQy28r8CMoggKnxNzxsVDMQ= +github.com/santhosh-tekuri/jsonschema/v5 v5.2.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chainlink-common v0.4.0 h1:GZ9MhHt5QHXSaK/sAZvKDxkEqF4fPiFHWHEPqs/2C2o= +github.com/smartcontractkit/chainlink-common v0.4.0/go.mod h1:yti7e1+G9hhkYhj+L5sVUULn9Bn3bBL5/AxaNqdJ5YQ= +github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 h1:NzZGjaqez21I3DU7objl3xExTH4fxYvzTqar8DC6360= +github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12/go.mod h1:fb1ZDVXACvu4frX3APHZaEBp0xi1DIm34DcA0CwTsZM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= +go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240823153156-2a54df7bffb9 h1:UiRNKd1OgqsLbFwE+wkAWTdiAxXtCBqKIHeBIse4FUA= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240823153156-2a54df7bffb9/go.mod h1:eqZlW3pJWhjyexnDPrdQxix1pn0wwhI4AO4GKpP/bMI= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.6.0 h1:QSKmLBzbFULSyHzOdO9JsN9lpE4zkrz1byYGmJecdVE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.6.0/go.mod h1:sTQ/NH8Yrirf0sJ5rWqVu+oT82i4zL9FaF6rWcqnptM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0 h1:U2guen0GhqH8o/G2un8f/aG/y++OuW6MyCo6hT9prXk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0/go.mod h1:yeGZANgEcpdx/WK0IvvRFC+2oLiMS2u4L/0Rj2M2Qr0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0 h1:VrMAbeJz4gnVDg2zEzjHG4dEH86j4jO6VYB+NgtGD8s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0/go.mod h1:qqN/uFdpeitTvm+JDqqnjm517pmQRYxTORbETHq5tOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.4.0 h1:0MH3f8lZrflbUWXVxyBg/zviDFdGE062uKh5+fu8Vv0= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.4.0/go.mod h1:Vh68vYiHY5mPdekTr0ox0sALsqjoVy0w3Os278yX5SQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0 h1:BJee2iLkfRfl9lc7aFmBwkWxY/RI1RDdXepSF6y8TPE= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.28.0/go.mod h1:DIzlHs3DRscCIBU3Y9YSzPfScwnYnzfnCd4g8zA7bZc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0 h1:EVSnY9JbEEW92bEkIYOVMw4q1WJxIAGoFTrtYOzWuRQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.28.0/go.mod h1:Ea1N1QQryNXpCD0I1fdLibBAIpQuBkznMmkdKrapk1Y= +go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= +go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM= +go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w= +go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ= +go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE= +go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= +go.opentelemetry.io/otel/sdk/log v0.6.0 h1:4J8BwXY4EeDE9Mowg+CyhWVBhTSLXVXodiXxS/+PGqI= +go.opentelemetry.io/otel/sdk/log v0.6.0/go.mod h1:L1DN8RMAduKkrwRAFDEX3E3TLOq46+XMGSbUfHU/+vE= +go.opentelemetry.io/otel/sdk/metric v1.30.0 h1:QJLT8Pe11jyHBHfSAgYH7kEmT24eX792jZO1bo4BXkM= +go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y= +go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc= +go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go b/cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go new file mode 100644 index 00000000..d9e8e3ee --- /dev/null +++ b/cmd/workflow/deploy/testdata/custom_wasm_workflow/main.go @@ -0,0 +1,74 @@ +package main + +/* +This file contains the entry point for the WebAssembly (Wasm) executable. +To ensure the code compiles and runs correctly for Wasm (wasip1 target), we must follow these requirements: + +1) **File Name**: + The file must be named `main.go`. This is a Go convention for executables that defines where the program's entry point (`main()` function) is located. + +2) **Package Name**: + The package name must be `main`. This is essential for building an executable in Go. Go's compiler looks for a package named `main` that contains the `main()` function, which acts as the entry point of the program when the Wasm executable is run. +*/ + +import ( + "errors" + "log" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/cli/cmd/testdata/fixtures/capabilities/basictrigger" + "github.com/smartcontractkit/chainlink-common/pkg/workflows/sdk" + "github.com/smartcontractkit/chainlink-common/pkg/workflows/wasm" +) + +type Config struct { + WorkflowName string `yaml:"workflowName"` + WorkflowOwner string `yaml:"workflowOwner"` + BasicTriggerInterval uint64 `yaml:"basicTriggerInterval"` +} + +func BuildWorkflow(config []byte) *sdk.WorkflowSpecFactory { + // Unmarshal the config bytes into the Config struct + var parsedConfig Config + err := yaml.Unmarshal(config, &parsedConfig) + if err != nil { + log.Fatalf("Failed to parse config: %v", err) + } + log.Printf("WorkflowName from config: %v", parsedConfig.WorkflowName) + log.Printf("WorkflowOwner from config: %v", parsedConfig.WorkflowOwner) + log.Printf("BasicTriggerInterval from config: %v", parsedConfig.BasicTriggerInterval) + + // interval is a mandatory field, throw an error if empty + if parsedConfig.BasicTriggerInterval == 0 { + log.Fatalf("Error: BasicTriggerInterval is missing in the YAML file") + } + + workflow := sdk.NewWorkflowSpecFactory() + + // Trigger + triggerCfg := basictrigger.TriggerConfig{Name: "trigger", Number: parsedConfig.BasicTriggerInterval} + trigger := triggerCfg.New(workflow) + + // Action + sdk.Compute1[basictrigger.TriggerOutputs, bool]( + workflow, + "transform", + sdk.Compute1Inputs[basictrigger.TriggerOutputs]{Arg0: trigger}, + func(sdk sdk.Runtime, outputs basictrigger.TriggerOutputs) (bool, error) { + log.Printf("Output from the basic trigger: %v", outputs.CoolOutput) + if outputs.CoolOutput == "cool" { + return false, errors.New("it is cool, not good") + } + return true, nil + }) + + return workflow +} + +func main() { + runner := wasm.NewRunner() + + workflow := BuildWorkflow(runner.Config()) + runner.Run(workflow) +} diff --git a/cmd/workflow/deploy/testdata/wasm_make_fails/Makefile b/cmd/workflow/deploy/testdata/wasm_make_fails/Makefile new file mode 100644 index 00000000..4018c8c5 --- /dev/null +++ b/cmd/workflow/deploy/testdata/wasm_make_fails/Makefile @@ -0,0 +1,4 @@ +.PHONY: build + +build: + false diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 1876aaf4..6af98a6b 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -4,13 +4,11 @@ import ( "context" "crypto/ecdsa" "encoding/json" - "errors" "fmt" "math" "math/big" "os" "os/signal" - "path/filepath" "strconv" "strings" "syscall" @@ -39,7 +37,6 @@ import ( v2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/v2" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" - "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/ui" @@ -47,7 +44,7 @@ import ( ) type Inputs struct { - WorkflowPath string `validate:"required,path_read"` + WorkflowPath string `validate:"required,workflow_path_read"` ConfigPath string `validate:"omitempty,file,ascii,max=97"` SecretsPath string `validate:"omitempty,file,ascii,max=97"` EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` @@ -258,58 +255,33 @@ func (h *handler) ValidateInputs(inputs Inputs) error { } func (h *handler) Execute(inputs Inputs) error { - // Compile the workflow - // terminal command: GOOS=wasip1 GOARCH=wasm go build -trimpath -ldflags="-buildid= -w -s" -o - workflowRootFolder := filepath.Dir(inputs.WorkflowPath) - tmpWasmFileName := "tmp.wasm" - workflowMainFile := filepath.Base(inputs.WorkflowPath) - - // Set language in runtime context based on workflow file extension + workflowDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("workflow directory: %w", err) + } + resolvedWorkflowPath, err := cmdcommon.ResolveWorkflowPath(workflowDir, inputs.WorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } + _, workflowMainFile, err := cmdcommon.WorkflowPathRootAndMain(resolvedWorkflowPath) + if err != nil { + return fmt.Errorf("workflow path: %w", err) + } if h.runtimeContext != nil { h.runtimeContext.Workflow.Language = cmdcommon.GetWorkflowLanguage(workflowMainFile) - - switch h.runtimeContext.Workflow.Language { - case constants.WorkflowLanguageTypeScript: - if err := cmdcommon.EnsureTool("bun"); err != nil { - return errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation") - } - case constants.WorkflowLanguageGolang: - if err := cmdcommon.EnsureTool("go"); err != nil { - return errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl") - } - default: - return fmt.Errorf("unsupported workflow language for file %s", workflowMainFile) - } } - buildCmd := cmdcommon.GetBuildCmd(workflowMainFile, tmpWasmFileName, workflowRootFolder) - - h.log.Debug(). - Str("Workflow directory", buildCmd.Dir). - Str("Command", buildCmd.String()). - Msg("Executing go build command") - - // Execute the build command with spinner spinner := ui.NewSpinner() spinner.Start("Compiling workflow...") - buildOutput, err := buildCmd.CombinedOutput() + wasmFileBinary, err := cmdcommon.CompileWorkflowToWasm(resolvedWorkflowPath) spinner.Stop() - if err != nil { - out := strings.TrimSpace(string(buildOutput)) - h.log.Info().Msg(out) - return fmt.Errorf("failed to compile workflow: %w\nbuild output:\n%s", err, out) + ui.Error("Build failed:") + return fmt.Errorf("failed to compile workflow: %w", err) } - h.log.Debug().Msgf("Build output: %s", buildOutput) + h.log.Debug().Msg("Workflow compiled") ui.Success("Workflow compiled") - // Read the compiled workflow binary - tmpWasmLocation := filepath.Join(workflowRootFolder, tmpWasmFileName) - wasmFileBinary, err := os.ReadFile(tmpWasmLocation) - if err != nil { - return fmt.Errorf("failed to read workflow binary: %w", err) - } - // Read the config file var config []byte if inputs.ConfigPath != "" { diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index e9df7560..08d6d30a 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -29,6 +29,14 @@ func TestBlankWorkflowSimulation(t *testing.T) { absWorkflowPath, err := filepath.Abs(workflowPath) require.NoError(t, err) + // Run test from workflow dir so short relative paths (max 97 chars) work + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(absWorkflowPath)) + t.Cleanup(func() { + _ = os.Chdir(prevWd) + }) + // Clean up common artifacts produced by the compile/simulate flow outB64 := filepath.Join(absWorkflowPath, "binary.wasm.br.b64") t.Cleanup(func() { @@ -47,10 +55,11 @@ func TestBlankWorkflowSimulation(t *testing.T) { rpc.Url = "https://sepolia.infura.io/v3" v.Set(fmt.Sprintf("%s.%s", "staging-settings", settings.RpcsSettingName), []settings.RpcEndpoint{rpc}) + // Use relative paths so validation (max 97 chars) passes; cwd is workflow dir var workflowSettings settings.WorkflowSettings workflowSettings.UserWorkflowSettings.WorkflowName = "blank-workflow" - workflowSettings.WorkflowArtifactSettings.WorkflowPath = filepath.Join(absWorkflowPath, "main.go") - workflowSettings.WorkflowArtifactSettings.ConfigPath = filepath.Join(absWorkflowPath, "config.json") + workflowSettings.WorkflowArtifactSettings.WorkflowPath = "main.go" + workflowSettings.WorkflowArtifactSettings.ConfigPath = "config.json" // Mock `runtime.Context` with a test logger. runtimeCtx := &runtime.Context{ diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 72e5b699..bc4298c2 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/cmd/workflow/activate" + "github.com/smartcontractkit/cre-cli/cmd/workflow/convert" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" @@ -20,6 +21,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { } workflowCmd.AddCommand(activate.New(runtimeContext)) + workflowCmd.AddCommand(convert.New(runtimeContext)) workflowCmd.AddCommand(delete.New(runtimeContext)) workflowCmd.AddCommand(pause.New(runtimeContext)) workflowCmd.AddCommand(test.New(runtimeContext)) diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index a5b83833..0bcc74cb 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -29,6 +29,7 @@ cre workflow [optional flags] * [cre](cre.md) - CRE CLI tool * [cre workflow activate](cre_workflow_activate.md) - Activates workflow on the Workflow Registry contract +* [cre workflow custom-build](cre_workflow_custom-build.md) - Converts an existing workflow to a custom (self-compiled) build * [cre workflow delete](cre_workflow_delete.md) - Deletes all versions of a workflow from the Workflow Registry * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract * [cre workflow pause](cre_workflow_pause.md) - Pauses workflow on the Workflow Registry contract diff --git a/docs/cre_workflow_custom-build.md b/docs/cre_workflow_custom-build.md new file mode 100644 index 00000000..f5a1fc01 --- /dev/null +++ b/docs/cre_workflow_custom-build.md @@ -0,0 +1,38 @@ +## cre workflow custom-build + +Converts an existing workflow to a custom (self-compiled) build + +### Synopsis + +Converts a Go or TypeScript workflow to use a custom build via Makefile, producing wasm/workflow.wasm. The workflow-path in workflow.yaml is updated to ./wasm/workflow.wasm. This cannot be undone. + +``` +cre workflow custom-build [optional flags] +``` + +### Examples + +``` +cre workflow custom-build ./my-workflow +``` + +### Options + +``` + -f, --force Skip confirmation prompt and convert immediately + -h, --help help for custom-build +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info (default ".env") + -R, --project-root string Path to the project root + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows + diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 7c0c854c..c213add7 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -53,6 +53,7 @@ const ( WorkflowLanguageGolang = "golang" WorkflowLanguageTypeScript = "typescript" + WorkflowLanguageWasm = "wasm" TestAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" TestAddress2 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index cf62e3d9..5d7bf476 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -3,14 +3,81 @@ package settings import ( "fmt" "net/url" + "os" "strings" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" + "sigs.k8s.io/yaml" ) +// GetWorkflowPathFromFile reads workflow-path from a workflow.yaml file (same value deploy/simulate get from Settings). +func GetWorkflowPathFromFile(workflowYAMLPath string) (string, error) { + data, err := os.ReadFile(workflowYAMLPath) + if err != nil { + return "", fmt.Errorf("read workflow settings: %w", err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return "", fmt.Errorf("parse workflow settings: %w", err) + } + return workflowPathFromRaw(raw) +} + +// SetWorkflowPathInFile sets workflow-path in workflow.yaml (both staging-settings and production-settings) and writes the file. +func SetWorkflowPathInFile(workflowYAMLPath, newPath string) error { + data, err := os.ReadFile(workflowYAMLPath) + if err != nil { + return fmt.Errorf("read workflow settings: %w", err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parse workflow settings: %w", err) + } + setWorkflowPathInRaw(raw, newPath) + out, err := yaml.Marshal(raw) + if err != nil { + return fmt.Errorf("marshal workflow settings: %w", err) + } + if err := os.WriteFile(workflowYAMLPath, out, 0600); err != nil { + return fmt.Errorf("write workflow settings: %w", err) + } + return nil +} + +func workflowPathFromRaw(raw map[string]interface{}) (string, error) { + for _, key := range []string{"staging-settings", "production-settings"} { + target, _ := raw[key].(map[string]interface{}) + if target == nil { + continue + } + artifacts, _ := target["workflow-artifacts"].(map[string]interface{}) + if artifacts == nil { + continue + } + if p, ok := artifacts["workflow-path"].(string); ok && p != "" { + return p, nil + } + } + return "", fmt.Errorf("workflow-path not found in workflow settings") +} + +func setWorkflowPathInRaw(raw map[string]interface{}, path string) { + for _, key := range []string{"staging-settings", "production-settings"} { + target, _ := raw[key].(map[string]interface{}) + if target == nil { + continue + } + artifacts, _ := target["workflow-artifacts"].(map[string]interface{}) + if artifacts == nil { + continue + } + artifacts["workflow-path"] = path + } +} + type WorkflowSettings struct { UserWorkflowSettings struct { WorkflowOwnerAddress string `mapstructure:"workflow-owner-address" yaml:"workflow-owner-address"` diff --git a/internal/testutil/graphql_mock.go b/internal/testutil/graphql_mock.go new file mode 100644 index 00000000..98bbd188 --- /dev/null +++ b/internal/testutil/graphql_mock.go @@ -0,0 +1,42 @@ +package testutil + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/smartcontractkit/cre-cli/internal/environments" +) + +// NewGraphQLMockServerGetOrganization starts an httptest.Server that responds to +// getOrganization with a fixed organizationId. It sets EnvVarGraphQLURL so CLI +// commands use this server. Caller must defer srv.Close(). +func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { + var req struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(req.Query, "getOrganization") { + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "getOrganization": map[string]any{"organizationId": "test-org-id"}, + }, + }) + return + } + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, + }) + } + })) + t.Setenv(environments.EnvVarGraphQLURL, srv.URL+"/graphql") + return srv +} diff --git a/internal/validation/files/path_read.go b/internal/validation/files/path_read.go index 84d6cce2..07034651 100644 --- a/internal/validation/files/path_read.go +++ b/internal/validation/files/path_read.go @@ -3,32 +3,45 @@ package files import ( "fmt" "os" + "path/filepath" "reflect" + "strings" "github.com/go-playground/validator/v10" ) func HasReadAccessToPath(fl validator.FieldLevel) bool { - field := fl.Field() - - if field.Kind() != reflect.String { - panic(fmt.Sprintf("input field name is not a string: %s", fl.FieldName())) - } - - path := field.String() + path := mustBeString(fl) + return checkReadAccess(path) +} - // Check if the file or directory exists +func checkReadAccess(path string) bool { _, err := os.Stat(path) if err != nil { return false } - - // Attempt to open the file or directory to verify read access file, err := os.Open(path) if err != nil { return false } defer file.Close() - return true } + +// HasReadAccessToWorkflowPath validates workflow-path: for .wasm paths only the containing +// directory must exist (CompileWorkflowToWasm will run make build); otherwise same as path_read. +func HasReadAccessToWorkflowPath(fl validator.FieldLevel) bool { + path := mustBeString(fl) + if strings.HasSuffix(path, ".wasm") { + return checkReadAccess(filepath.Dir(path)) + } + return checkReadAccess(path) +} + +func mustBeString(fl validator.FieldLevel) string { + field := fl.Field() + if field.Kind() != reflect.String { + panic(fmt.Sprintf("input field name is not a string: %s", fl.FieldName())) + } + return field.String() +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index c36a07e8..799dbf46 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -22,6 +22,7 @@ var customValidators = map[string]validator.Func{ "wasm": files.IsValidWASM, "workflow_name": isWorkflowName, "workflow_owner": isWorkflowOwner, + "workflow_path_read": files.HasReadAccessToWorkflowPath, "yaml": files.IsValidYAML, } @@ -37,6 +38,7 @@ var customTranslations = map[string]string{ "http_url|eq=": "{0} must be empty or a valid HTTP URL: {1}", "json": "{0} must be a valid JSON file: {1}", "path_read": "{0} must have read access to path: {1}", + "workflow_path_read": "{0} must have read access to path: {1}", "project_name": "{0} must be non-empty, no longer than 64 characters, and contain only letters (a-z, A-Z), numbers (0-9), dashes (-), and underscores (_): {1}", "wasm": "{0} must be a valid WASM file: {1}", "workflow_name": "{0} must be non-empty, no longer than 64 characters, and contain only letters (a-z, A-Z), numbers (0-9), dashes (-), and underscores (_): {1}", diff --git a/test/convert_simulate_helper.go b/test/convert_simulate_helper.go new file mode 100644 index 00000000..cfda81f2 --- /dev/null +++ b/test/convert_simulate_helper.go @@ -0,0 +1,77 @@ +package test + +import ( + "bytes" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +func convertSimulateCaptureOutput(t *testing.T, projectRoot, workflowName string) string { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "simulate (before convert) failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), stderr.String()) + return stdout.String() +} + +func convertSimulateRequireOutputContains(t *testing.T, projectRoot, workflowName, expectedSubstring string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "simulate (after convert) failed:\nSTDOUT:\n%s\nSTDERR:\n%s", + stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), expectedSubstring, + "simulate output after convert should contain %q", expectedSubstring) +} + +// ConvertSimulateBeforeAfter runs simulate (capture output), convert, then simulate again +// and verifies output contains the same expectedSubstring. Simulate runs make build internally when needed. +func ConvertSimulateBeforeAfter(t *testing.T, projectRoot, workflowDir, workflowName, expectedSubstring string) { + t.Helper() + beforeOutput := convertSimulateCaptureOutput(t, projectRoot, workflowName) + require.Contains(t, beforeOutput, expectedSubstring, + "baseline simulate output should contain %q", expectedSubstring) + convertRunConvert(t, projectRoot, workflowDir) + convertSimulateRequireOutputContains(t, projectRoot, workflowName, expectedSubstring) +} + +func convertRunConvert(t *testing.T, projectRoot, workflowDir string) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "custom-build", workflowDir, "-f") + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "convert failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) +} + +func convertRunMakeBuild(t *testing.T, workflowDir string, makeArgs ...string) { + t.Helper() + var stdout, stderr bytes.Buffer + args := []string{"build"} + args = append(args, makeArgs...) + cmd := exec.Command("make", args...) + cmd.Dir = workflowDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), + "make build failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) +} diff --git a/test/graphql_mock.go b/test/graphql_mock.go new file mode 100644 index 00000000..8298fe02 --- /dev/null +++ b/test/graphql_mock.go @@ -0,0 +1,14 @@ +package test + +import ( + "net/http/httptest" + "testing" + + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +// NewGraphQLMockServerGetOrganization starts a mock GraphQL server that responds to +// getOrganization and sets EnvVarGraphQLURL. Caller must defer srv.Close(). +func NewGraphQLMockServerGetOrganization(t *testing.T) *httptest.Server { + return testutil.NewGraphQLMockServerGetOrganization(t) +} diff --git a/test/init_and_binding_generation_and_simulate_go_test.go b/test/init_and_binding_generation_and_simulate_go_test.go index c12d9d1c..190d1922 100644 --- a/test/init_and_binding_generation_and_simulate_go_test.go +++ b/test/init_and_binding_generation_and_simulate_go_test.go @@ -2,20 +2,15 @@ package test import ( "bytes" - "encoding/json" - "net/http" - "net/http/httptest" "os" "os/exec" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -33,41 +28,9 @@ func TestE2EInit_DevPoRTemplate(t *testing.T) { // Set dummy API key t.Setenv(credentials.CreApiKeyVar, "test-api") - // Set up mock GraphQL server for authentication validation - // This is needed because validation now runs early in command execution - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - initArgs := []string{ "init", "--project-root", tempDir, diff --git a/test/init_and_simulate_ts_test.go b/test/init_and_simulate_ts_test.go index b4265c54..563ba5a9 100644 --- a/test/init_and_simulate_ts_test.go +++ b/test/init_and_simulate_ts_test.go @@ -2,19 +2,14 @@ package test import ( "bytes" - "encoding/json" - "net/http" - "net/http/httptest" "os/exec" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -32,41 +27,9 @@ func TestE2EInit_DevPoRTemplateTS(t *testing.T) { // Set dummy API key t.Setenv(credentials.CreApiKeyVar, "test-api") - // Set up mock GraphQL server for authentication validation - // This is needed because validation now runs early in command execution - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req struct { - Query string `json:"query"` - Variables map[string]interface{} `json:"variables"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - initArgs := []string{ "init", "--project-root", tempDir, diff --git a/test/init_convert_simulate_go_test.go b/test/init_convert_simulate_go_test.go new file mode 100644 index 00000000..f1a1b33f --- /dev/null +++ b/test/init_convert_simulate_go_test.go @@ -0,0 +1,123 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// TestE2EInit_ConvertToCustomBuild_Go: init (blank Go), simulate (capture), convert, make build, simulate (require match), +// then add FlagProof/constA/constB/Makefile FLAG, make with FLAG=customFlag/differentFlag, simulate and assert. +func TestE2EInit_ConvertToCustomBuild_Go(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-convert-go" + workflowName := "goWorkflow" + templateID := "2" // blank Go template + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with blank Go template --- + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + ) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + require.NoError(t, initCmd.Run(), "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDirectory) + require.FileExists(t, filepath.Join(workflowDirectory, "main.go")) + + // go mod tidy so simulate can build + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = workflowDirectory + tidyCmd.Stdout = &stdout + tidyCmd.Stderr = &stderr + require.NoError(t, tidyCmd.Run(), "go mod tidy failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + // Before/after: simulate (capture), convert, make build, simulate (verify same key content) + ConvertSimulateBeforeAfter(t, projectRoot, workflowDirectory, workflowName, "Fired at") + require.FileExists(t, filepath.Join(workflowDirectory, "Makefile")) + require.DirExists(t, filepath.Join(workflowDirectory, "wasm")) + + // Now make test-specific changes: FlagProof, constA/constB, Makefile FLAG + mainPath := filepath.Join(workflowDirectory, "main.go") + mainBytes, err := os.ReadFile(mainPath) + require.NoError(t, err) + mainStr := string(mainBytes) + var nl = "\n" + if runtime.GOOS == "windows" { + nl = "\r\n" + } + + mainStr = strings.Replace(mainStr, "type ExecutionResult struct {"+nl+"\tResult string"+nl+"}", "type ExecutionResult struct {"+nl+"\tResult string"+nl+"\tFlagProof string"+nl+"}", 1) + mainStr = strings.Replace(mainStr, "\t// Your logic here..."+nl+nl+"\treturn &ExecutionResult{Result: fmt.Sprintf(\"Fired at %s\", scheduledTime)}, nil", + "\treturn &ExecutionResult{Result: fmt.Sprintf(\"Fired at %s\", scheduledTime), FlagProof: FlagProof}, nil", 1) + + require.NoError(t, os.WriteFile(mainPath, []byte(mainStr), 0600)) + + constA := `//go:build customFlag + +package main + +const FlagProof = "set" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDirectory, "constA.go"), []byte(constA), 0600)) + + constB := `//go:build !customFlag + +package main + +const FlagProof = "unset" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDirectory, "constB.go"), []byte(constB), 0600)) + + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefile, err := os.ReadFile(makefilePath) + require.NoError(t, err) + makefileStr := strings.Replace(string(makefile), "go build -o", "go build -tags $(FLAG) -o", 1) + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileStr), 0600)) + + convertGoBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=customFlag", "set", "FlagProof") + convertGoBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=differentFlag", "unset", "FlagProof") +} + +func convertGoBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, workflowName, envVar, wantSubstr, wantSubstr2 string) { + t.Helper() + convertRunMakeBuild(t, workflowDir, envVar) + var stdout, stderr bytes.Buffer + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if envVar != "" { + cmd.Env = append(os.Environ(), envVar) + } + require.NoError(t, cmd.Run(), "simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), wantSubstr) + require.Contains(t, stdout.String(), wantSubstr2) +} diff --git a/test/init_convert_simulate_ts_test.go b/test/init_convert_simulate_ts_test.go new file mode 100644 index 00000000..362493e4 --- /dev/null +++ b/test/init_convert_simulate_ts_test.go @@ -0,0 +1,147 @@ +package test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// TestE2EInit_ConvertToCustomBuild_TS: init (typescriptSimpleExample), bun install, simulate (capture), +// convert, simulate (verify same). Verify conversion did not change main.ts. Then test-only: copy +// workflow-wrapper, write custom compile-to-js with define section in Bun.build, patch main.ts, Makefile. +// make with FLAG=customFlag/differentFlag, simulate and assert. +func TestE2EInit_ConvertToCustomBuild_TS(t *testing.T) { + tempDir := t.TempDir() + projectName := "e2e-convert-ts" + workflowName := "tsWorkflow" + templateID := "3" // typescriptSimpleExample + projectRoot := filepath.Join(tempDir, projectName) + workflowDirectory := filepath.Join(projectRoot, workflowName) + + t.Setenv(settings.EthPrivateKeyEnvVar, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + t.Setenv(credentials.CreApiKeyVar, "test-api") + + gqlSrv := NewGraphQLMockServerGetOrganization(t) + defer gqlSrv.Close() + + // --- cre init with typescriptSimpleExample --- + var stdout, stderr bytes.Buffer + initCmd := exec.Command(CLIPath, "init", + "--project-root", tempDir, + "--project-name", projectName, + "--template-id", templateID, + "--workflow-name", workflowName, + ) + initCmd.Dir = tempDir + initCmd.Stdout = &stdout + initCmd.Stderr = &stderr + require.NoError(t, initCmd.Run(), "cre init failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + require.FileExists(t, filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.DirExists(t, workflowDirectory) + require.FileExists(t, filepath.Join(workflowDirectory, "main.ts")) + + // bun install so simulate can build + installCmd := exec.Command("bun", "install") + installCmd.Dir = workflowDirectory + installCmd.Stdout = &stdout + installCmd.Stderr = &stderr + require.NoError(t, installCmd.Run(), "bun install failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + + ConvertSimulateBeforeAfter(t, projectRoot, workflowDirectory, workflowName, "Hello world!") + require.FileExists(t, filepath.Join(workflowDirectory, "Makefile")) + require.DirExists(t, filepath.Join(workflowDirectory, "wasm")) + + // Verify conversion did not change main.ts + mainPath := filepath.Join(workflowDirectory, "main.ts") + mainBefore, err := os.ReadFile(mainPath) + require.NoError(t, err) + require.Contains(t, string(mainBefore), `return "Hello world!";`, "convert must not modify workflow source") + + // Test-only: copy compile-to-js and workflow-wrapper from SDK, then patch to add define (so FLAG env drives the build). + scriptsDir := filepath.Join(workflowDirectory, "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0755)) + srcDir := filepath.Join(workflowDirectory, "node_modules", "@chainlink", "cre-sdk", "scripts", "src") + for _, name := range []string{"compile-to-js.ts", "workflow-wrapper.ts"} { + b, err := os.ReadFile(filepath.Join(srcDir, name)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, name), b, 0600)) + } + compileToJSPath := filepath.Join(scriptsDir, "compile-to-js.ts") + compileToJS, err := os.ReadFile(compileToJSPath) + require.NoError(t, err) + src := string(compileToJS) + // Use local workflow-wrapper (script is under scripts/, not SDK entry) + if !strings.Contains(src, "workflow-wrapper") { + src = strings.Replace(src, `import { $ } from "bun";`, `import { $ } from "bun"; +import { wrapWorkflowCode } from "./workflow-wrapper";`, 1) + } + // Argv slice(2) so "bun scripts/compile-to-js.ts main.ts wasm/workflow.js" passes both args + // it's called differently than the SDK so we need to patch it + src = strings.Replace(src, "process.argv.slice(3)", "process.argv.slice(2)", 1) + + defineBlock := "define: {\n\t\t\tBUILD_FLAG: JSON.stringify(process.env.FLAG ?? \"\"),\n\t\t},\n\t\t" + anchor := "naming: path.basename(resolvedOutput)," + if idx := strings.Index(src, anchor); idx >= 0 { + src = src[:idx] + defineBlock + src[idx:] + } + require.Contains(t, src, "define:", "patch must add define section to Bun.build") + if !strings.Contains(src, "main().catch") && !strings.Contains(src, "await main()") { + src = src + "\nmain().catch((err: unknown) => { console.error(err); process.exit(1); });\n" + } + require.NoError(t, os.WriteFile(compileToJSPath, []byte(src), 0600)) + + mainStr := string(mainBefore) + mainStr = "declare const BUILD_FLAG: string;\n" + mainStr + newReturn := `return BUILD_FLAG === "customFlag" ? "Hello World (custom)" : "Hello World (default)";` + for _, oldReturn := range []string{` return "Hello world!";`, `return "Hello world!";`} { + if strings.Contains(mainStr, oldReturn) { + mainStr = strings.Replace(mainStr, oldReturn, newReturn, 1) + break + } + } + require.Contains(t, mainStr, "Hello World (custom)", "main.ts return patch must apply") + require.NoError(t, os.WriteFile(mainPath, []byte(mainStr), 0600)) + + makefilePath := filepath.Join(workflowDirectory, "Makefile") + makefileContent := `.PHONY: build + +build: + FLAG=$(FLAG) bun scripts/compile-to-js.ts main.ts wasm/workflow.js + bunx cre-compile-workflow wasm/workflow.js wasm/workflow.wasm +` + require.NoError(t, os.WriteFile(makefilePath, []byte(makefileContent), 0600)) + + convertTSBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=customFlag", "Hello World (custom)") + convertTSBuildWithFlagAndAssert(t, projectRoot, workflowDirectory, workflowName, "FLAG=differentFlag", "Hello World (default)") +} + +func convertTSBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, workflowName, envVar, wantSubstr string) { + t.Helper() + convertRunMakeBuild(t, workflowDir, envVar) + var stdout, stderr bytes.Buffer + workflowDirAbs, err := filepath.Abs(workflowDir) + require.NoError(t, err) + cmd := exec.Command(CLIPath, "workflow", "simulate", workflowDirAbs, + "--project-root", projectRoot, + "--non-interactive", "--trigger-index=0", + ) + cmd.Dir = projectRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + // Simulate runs CompileWorkflowToWasm which runs make build again; pass env so the rebuild uses the same FLAG + if envVar != "" { + cmd.Env = append(os.Environ(), envVar) + } + require.NoError(t, cmd.Run(), "simulate failed:\nSTDOUT:\n%s\nSTDERR:\n%s", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), wantSubstr) +} diff --git a/test/multi_command_flows/workflow_simulator_path.go b/test/multi_command_flows/workflow_simulator_path.go index cdafd1bd..9f8bd17f 100644 --- a/test/multi_command_flows/workflow_simulator_path.go +++ b/test/multi_command_flows/workflow_simulator_path.go @@ -8,13 +8,12 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "time" "github.com/stretchr/testify/require" - "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/testutil" ) type testEVMConfig struct { @@ -84,37 +83,9 @@ func RunSimulationHappyPath(t *testing.T, tc TestConfig, projectDir string) { t.Helper() t.Run("Simulate", func(t *testing.T) { - // Set up GraphQL mock server for authentication validation - gqlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/graphql") && r.Method == http.MethodPost { - var req graphQLRequest - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - // Handle authentication validation query - if strings.Contains(req.Query, "getOrganization") { - _ = json.NewEncoder(w).Encode(map[string]any{ - "data": map[string]any{ - "getOrganization": map[string]any{ - "organizationId": "test-org-id", - }, - }, - }) - return - } - - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "errors": []map[string]string{{"message": "Unsupported GraphQL query"}}, - }) - } - })) + gqlSrv := testutil.NewGraphQLMockServerGetOrganization(t) defer gqlSrv.Close() - // Point GraphQL client to mock server - t.Setenv(environments.EnvVarGraphQLURL, gqlSrv.URL+"/graphql") - srv := startMockPORServer(t) patchWorkflowConfigURL(t, projectDir, "por_workflow", srv.URL) From 3ebb1c88ba47a2d9ddd13a34344794e95868a1c6 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Thu, 19 Feb 2026 09:33:50 -0500 Subject: [PATCH 3/4] Add bun to ci test unit --- .github/workflows/pull-request-main.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pull-request-main.yml b/.github/workflows/pull-request-main.yml index 0665ac03..542432c9 100644 --- a/.github/workflows/pull-request-main.yml +++ b/.github/workflows/pull-request-main.yml @@ -43,6 +43,11 @@ jobs: contents: read actions: read steps: + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + - name: ci-test uses: smartcontractkit/.github/actions/ci-test-go@ci-test-go/0.3.5 with: From 012877ac97a6cea1c93908b1516366fb86de4cc5 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Thu, 19 Feb 2026 12:47:11 -0500 Subject: [PATCH 4/4] Update to newer SDK, fix the imports --- cmd/creinit/creinit_test.go | 11 ----------- .../workflow/typescriptConfHTTP/package.json.tpl | 2 +- .../workflow/typescriptPorExampleDev/main.test.ts.tpl | 10 +++++----- .../workflow/typescriptPorExampleDev/package.json.tpl | 2 +- .../workflow/typescriptSimpleExample/package.json.tpl | 2 +- 5 files changed, 8 insertions(+), 19 deletions(-) diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index aa843052..607fb9eb 100644 --- a/cmd/creinit/creinit_test.go +++ b/cmd/creinit/creinit_test.go @@ -253,17 +253,6 @@ func TestInitExecuteFlows(t *testing.T) { expectTemplateFiles: GetTemplateFileListTS(), language: "typescript", }, - { - name: "TS Confidential HTTP template", - projectNameFlag: "tsConfHTTP", - templateIDFlag: 5, // TypeScript Confidential HTTP - workflowNameFlag: "ts-confhttp-wf", - rpcURLFlag: "", - expectProjectDirRel: "tsConfHTTP", - expectWorkflowName: "ts-confhttp-wf", - expectTemplateFiles: GetTemplateFileListTS(), - language: "typescript", - }, } for _, tc := range cases { diff --git a/cmd/creinit/template/workflow/typescriptConfHTTP/package.json.tpl b/cmd/creinit/template/workflow/typescriptConfHTTP/package.json.tpl index 4a1d4c6a..e0af3745 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.1.0", + "@chainlink/cre-sdk": "^1.1.1", "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 index b28ff48f..c44aa6e6 100644 --- a/cmd/creinit/template/workflow/typescriptPorExampleDev/main.test.ts.tpl +++ b/cmd/creinit/template/workflow/typescriptPorExampleDev/main.test.ts.tpl @@ -3,8 +3,8 @@ import { describe, expect } from "bun:test"; import { newTestRuntime, test, - CapabilitiesNetworkingHttpV1alphaClientMock, - ClientCapabilityMock as EVMClientCapabilityMock, + HttpActionsMock, + EvmMock, } from "@chainlink/cre-sdk/test"; import { initWorkflow, onCronTrigger, onLogTrigger, fetchReserveInfo } from "./main"; import type { Config } from "./main"; @@ -46,7 +46,7 @@ const setupEVMMocks = (config: Config) => { throw new Error(`Network not found for chain selector: ${config.evms[0].chainSelectorName}`); } - const evmMock = EVMClientCapabilityMock.testInstance(network.chainSelector.selector); + const evmMock = EvmMock.testInstance(network.chainSelector.selector); // Mock contract calls - route based on target address and function signature evmMock.callContract = (req) => { @@ -152,7 +152,7 @@ describe("fetchReserveInfo", () => { const runtime = newTestRuntime(); runtime.config = mockConfig; - const httpMock = CapabilitiesNetworkingHttpV1alphaClientMock.testInstance(); + const httpMock = HttpActionsMock.testInstance(); const mockPORResponse = { accountName: "test-account", @@ -188,7 +188,7 @@ describe("onCronTrigger", () => { runtime.config = mockConfig; // Setup HTTP mock for reserve info - const httpMock = CapabilitiesNetworkingHttpV1alphaClientMock.testInstance(); + const httpMock = HttpActionsMock.testInstance(); const mockPORResponse = { accountName: "TrueUSD", totalTrust: 1000000, diff --git a/cmd/creinit/template/workflow/typescriptPorExampleDev/package.json.tpl b/cmd/creinit/template/workflow/typescriptPorExampleDev/package.json.tpl index 530b45db..e613002f 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.1.0", + "@chainlink/cre-sdk": "^1.1.1", "viem": "2.34.0", "zod": "3.25.76" }, diff --git a/cmd/creinit/template/workflow/typescriptSimpleExample/package.json.tpl b/cmd/creinit/template/workflow/typescriptSimpleExample/package.json.tpl index 6e3760bd..0dc43de0 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.1.0" + "@chainlink/cre-sdk": "^1.1.1" }, "devDependencies": { "@types/bun": "1.2.21"