diff --git a/bun.lock b/bun.lock index c525d5ed..73d21f08 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "@chainlink/cre-sdk-monorepo", @@ -92,7 +92,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ=="], - "@bufbuild/buf": ["@bufbuild/buf@1.56.0", "", { "optionalDependencies": { "@bufbuild/buf-darwin-arm64": "1.56.0", "@bufbuild/buf-darwin-x64": "1.56.0", "@bufbuild/buf-linux-aarch64": "1.56.0", "@bufbuild/buf-linux-armv7": "1.56.0", "@bufbuild/buf-linux-x64": "1.56.0", "@bufbuild/buf-win32-arm64": "1.56.0", "@bufbuild/buf-win32-x64": "1.56.0" }, "bin": { "buf": "bin/buf", "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" } }, "sha512-1xQWOf3FCDDTi+5B/VScQ73EP6ACwQPCP4ODvCq2L6IVgFtvYX49ur6cQ2qCM8yFitIHESm/Nbff93sh+V/Iog=="], + "@bufbuild/buf": ["@bufbuild/buf@1.56.0", "", { "optionalDependencies": { "@bufbuild/buf-darwin-arm64": "1.56.0", "@bufbuild/buf-darwin-x64": "1.56.0", "@bufbuild/buf-linux-aarch64": "1.56.0", "@bufbuild/buf-linux-armv7": "1.56.0", "@bufbuild/buf-linux-x64": "1.56.0", "@bufbuild/buf-win32-arm64": "1.56.0", "@bufbuild/buf-win32-x64": "1.56.0" }, "bin": { "buf": "bin/buf", "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint", "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking" } }, "sha512-1xQWOf3FCDDTi+5B/VScQ73EP6ACwQPCP4ODvCq2L6IVgFtvYX49ur6cQ2qCM8yFitIHESm/Nbff93sh+V/Iog=="], "@bufbuild/buf-darwin-arm64": ["@bufbuild/buf-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9neaI9gx1sxOGl9xrL7kw6H+0WmVAFlIQTIDc3vt1qRhfgOt/8AWOHSOWppGTRjNiB0qh6Xie1LYHv/jgDVN0g=="], @@ -144,9 +144,9 @@ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], - "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], + "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - "@typescript/vfs": ["@typescript/vfs@1.6.1", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA=="], + "@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="], "abitype": ["abitype@1.1.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], @@ -184,7 +184,7 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -264,7 +264,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], @@ -295,5 +295,9 @@ "@chainlink/cre-sdk/viem/abitype": ["abitype@1.0.8", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3 >=3.22.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg=="], "@chainlink/cre-sdk/viem/ox": ["ox@0.8.7", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.8", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-W1f0FiMf9NZqtHPEDEAEkyzZDwbIKfmH2qmQx8NNiQ/9JhxrSblmtLJsSfTtQG5YKowLOnBlLVguCyxm/7ztxw=="], + + "@chainlink/cre-sdk-examples/viem/ox/abitype": ["abitype@1.1.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], + + "@chainlink/cre-sdk/viem/ox/abitype": ["abitype@1.1.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], } } diff --git a/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm b/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm index 1d2e29f5..449e9d8d 100755 Binary files a/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm and b/packages/cre-sdk-javy-plugin/dist/javy_chainlink_sdk.wasm differ diff --git a/packages/cre-sdk/README.md b/packages/cre-sdk/README.md index d4a90287..95ad9648 100644 --- a/packages/cre-sdk/README.md +++ b/packages/cre-sdk/README.md @@ -32,6 +32,7 @@ The Chainlink Runtime Environment (CRE) SDK for TypeScript enables developers to - [Core Functions](#core-functions) - [Capabilities](#capabilities) - [Utilities](#utilities) +- [Testing](#testing) - [Building from Source](#building-from-source) - [Protobuf Generation](#protobuf-generation) - [Chain Selectors Generation](#chain-selectors-generation) @@ -430,6 +431,34 @@ See the [star-wars example](https://github.com/smartcontractkit/cre-sdk-typescri - `getAllNetworks()`: Get all supported networks - `getNetwork(options)`: Get specific network metadata +## Testing + +The CRE SDK includes a built-in test framework for unit testing your workflows without compiling to WASM or running on a DON. It provides capability mocks, secrets simulation, time control, and log capture — all with full type safety. + +See the [Testing Guide](./TESTING.md) for full documentation, including examples for mocking EVM, HTTP, consensus, and more. + +Quick example: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime, EvmMock } from "@chainlink/cre-sdk/test"; +import { ClientCapability as EvmClient } from "@cre/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen"; + +describe("my workflow", () => { + test("reads from EVM", () => { + const mock = EvmMock.testInstance(11155111n); + mock.callContract = () => ({ data: "AQID" }); + + const runtime = newTestRuntime(); + const result = new EvmClient(11155111n) + .callContract(runtime, { call: { to: "", data: "" } }) + .result(); + + expect(result.data).toEqual(new Uint8Array([1, 2, 3])); + }); +}); +``` + ## Building from Source To build the SDK locally: diff --git a/packages/cre-sdk/TESTING.md b/packages/cre-sdk/TESTING.md new file mode 100644 index 00000000..8c4dfc6b --- /dev/null +++ b/packages/cre-sdk/TESTING.md @@ -0,0 +1,466 @@ +# Testing CRE Workflows + +The CRE SDK includes a built-in test framework that lets you unit test your TypeScript workflows without compiling to WASM or running on a DON. You can mock capabilities (HTTP, EVM, etc.), control secrets and time, capture logs, and assert on results — all with full type safety. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Core Concepts](#core-concepts) + - [The test() Function](#the-test-function) + - [Creating a Test Runtime](#creating-a-test-runtime) +- [Mocking Capabilities](#mocking-capabilities) + - [Setting Up Mocks](#setting-up-mocks) + - [Available Mocks](#available-mocks) + - [EVM Mocks with Chain Selectors](#evm-mocks-with-chain-selectors) + - [HTTP Mocks](#http-mocks) + - [Flexible Return Types](#flexible-return-types) +- [Testing Secrets](#testing-secrets) +- [Testing Consensus & Node Mode](#testing-consensus--node-mode) +- [Testing Reports](#testing-reports) +- [Controlling Time](#controlling-time) +- [Capturing Logs](#capturing-logs) +- [Response Size Validation](#response-size-validation) +- [API Reference](#api-reference) + - [Test Functions](#test-functions) + - [TestRuntime](#testruntime) + - [Capability Mocks](#capability-mocks) + - [Constants](#constants) + +## Prerequisites + +1. The [bun runtime](https://bun.com/) (>= 1.2.21) +2. A CRE workflow project set up with `@chainlink/cre-sdk` + +## Quick Start + +Tests use the `test` function and `newTestRuntime` from `@chainlink/cre-sdk/test`: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime } from "@chainlink/cre-sdk/test"; + +describe("my workflow", () => { + test("returns the expected value", () => { + const runtime = newTestRuntime(); + + runtime.log("hello from test"); + expect(runtime.getLogs()).toContain("hello from test"); + }); +}); +``` + +Run tests with: + +```bash +bun test +``` + +**Important**: You must use `test` from `@chainlink/cre-sdk/test`, **not** from `bun:test`. The CRE `test` function sets up an isolated capability registry for each test. Using the standard `bun:test` `test` directly will cause mocks to fail at runtime. `describe` and `expect` should still be imported from `bun:test`. + +## Core Concepts + +### The test() Function + +The CRE `test()` function wraps Bun's test runner with per-test isolation. Each test gets its own capability registry, so mocks registered in one test are invisible to other tests: + +```typescript +import { test, newTestRuntime, BasicTestActionMock } from "@chainlink/cre-sdk/test"; + +test("test A registers a mock", () => { + BasicTestActionMock.testInstance(); // only visible in this test + const runtime = newTestRuntime(); + // ... +}); + +test("test B has a clean slate", () => { + // BasicTestActionMock is NOT registered here + const runtime = newTestRuntime(); + // ... +}); +``` + +### Creating a Test Runtime + +`newTestRuntime()` creates a `TestRuntime` instance that behaves like the real CRE runtime but executes capability calls synchronously against your mocks: + +```typescript +import { test, newTestRuntime } from "@chainlink/cre-sdk/test"; + +test("basic runtime", () => { + // No secrets, default options + const runtime = newTestRuntime(); + + // With secrets + const secrets = new Map([["myNamespace", new Map([["apiKey", "test-key-123"]])]]); + const runtimeWithSecrets = newTestRuntime(secrets); + + // With options + const runtimeWithOptions = newTestRuntime(null, { + timeProvider: () => 1700000000000, + maxResponseSize: 1024, + }); +}); +``` + +## Mocking Capabilities + +### Setting Up Mocks + +Each capability has a corresponding mock class with a `testInstance()` factory. Register the mock, set method handlers, then use the real capability class as normal: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime, BasicTestActionMock } from "@chainlink/cre-sdk/test"; +import { BasicActionCapability } from "@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen"; + +describe("capability mocking", () => { + test("mock returns the value you define", () => { + // 1. Get the mock instance and set handlers + const mock = BasicTestActionMock.testInstance(); + mock.performAction = (input) => { + return { adaptedThing: "mocked-result" }; + }; + + // 2. Create the runtime + const runtime = newTestRuntime(); + + // 3. Use the real capability — it routes through the mock + const capability = new BasicActionCapability(); + const result = capability.performAction(runtime, { inputThing: true }).result(); + + // 4. Assert + expect(result.adaptedThing).toBe("mocked-result"); + }); +}); +``` + +The pattern is always: + +1. Call `MockClass.testInstance()` to register the mock +2. Assign handler functions to the mock's method properties +3. Create the runtime with `newTestRuntime()` +4. Use the real capability classes — calls are intercepted by the mock +5. Call `.result()` to get the response and assert on it + +If you invoke a capability method without setting a handler, the framework throws a clear error: + +``` +PerformAction: no implementation provided; set the mock's performAction property to define the return value. +``` + +### Available Mocks + +Generated mocks are available for all capabilities. Import them from `@chainlink/cre-sdk/test`: + +| Mock Class | Capability | Methods | +|---|---|---| +| `EvmMock` | EVM blockchain client | `callContract`, `filterLogs`, `balanceAt`, `estimateGas`, `getTransactionByHash`, `getTransactionReceipt`, `headerByNumber`, `writeReport` | +| `HttpActionsMock` | HTTP client | `sendRequest` | +| `ConfidentialHttpMock` | Confidential HTTP client | `sendRequest` | +| `ConsensusMock` | Consensus (auto-registered) | `simple`, `report` | +| `BasicTestActionMock` | Basic action (internal) | `performAction` | +| `BasicTestActionTriggerMock` | Action and trigger (internal) | `performAction` | + +### EVM Mocks with Chain Selectors + +EVM mocks are tag-aware — different chain selectors produce different, independent mock instances: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime, EvmMock } from "@chainlink/cre-sdk/test"; +import { ClientCapability as EvmClient } from "@cre/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen"; + +describe("multi-chain EVM mocks", () => { + test("different chains have independent mocks", () => { + const sepoliaSelector = 11155111n; + const mumbaiSelector = 80001n; + + // Each chain gets its own mock instance + const sepoliaMock = EvmMock.testInstance(sepoliaSelector); + const mumbaiMock = EvmMock.testInstance(mumbaiSelector); + + sepoliaMock.callContract = () => ({ data: "AQID" }); // base64 for [1, 2, 3] + mumbaiMock.callContract = () => ({ data: "BAUG" }); // base64 for [4, 5, 6] + + const runtime = newTestRuntime(); + + const sepoliaResult = new EvmClient(sepoliaSelector) + .callContract(runtime, { call: { to: "", data: "" } }) + .result(); + const mumbaiResult = new EvmClient(mumbaiSelector) + .callContract(runtime, { call: { to: "", data: "" } }) + .result(); + + expect(sepoliaResult.data).toEqual(new Uint8Array([1, 2, 3])); + expect(mumbaiResult.data).toEqual(new Uint8Array([4, 5, 6])); + }); +}); +``` + +Calling `testInstance()` with the same chain selector always returns the same instance: + +```typescript +const instance1 = EvmMock.testInstance(11155111n); +const instance2 = EvmMock.testInstance(11155111n); +// instance1 === instance2 +``` + +### HTTP Mocks + +Mock HTTP responses to test workflows that call external APIs: + +```typescript +import { describe, expect } from "bun:test"; +import { + test, + newTestRuntime, + HttpActionsMock, +} from "@chainlink/cre-sdk/test"; +import { ClientCapability as HTTPClient } from "@cre/generated-sdk/capabilities/networking/http/v1alpha/client_sdk_gen"; + +describe("HTTP mocking", () => { + test("mock an API response", () => { + const mock = HttpActionsMock.testInstance(); + mock.sendRequest = (input) => { + return { + statusCode: 200, + headers: {}, + body: new TextEncoder().encode(JSON.stringify({ price: 42000 })), + }; + }; + + const runtime = newTestRuntime(); + const http = new HTTPClient(); + const response = http + .sendRequest(runtime, { url: "https://api.example.com/price", method: "GET" }) + .result(); + + expect(response.statusCode).toBe(200); + }); +}); +``` + +### Flexible Return Types + +Mock handlers accept either protobuf message types or plain JSON objects. The framework handles conversion automatically: + +```typescript +// Plain JSON — simpler, recommended for most tests +mock.performAction = (input) => { + return { adaptedThing: "result" }; +}; + +// Protobuf message type — use when you need precise control +import { create } from "@bufbuild/protobuf"; +import { OutputsSchema } from "@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb"; + +mock.performAction = (input) => { + return create(OutputsSchema, { adaptedThing: "result" }); +}; +``` + +## Testing Secrets + +Pass a `Map>` to `newTestRuntime()` to provide test secrets: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime } from "@chainlink/cre-sdk/test"; + +describe("secrets", () => { + test("secret is accessible by namespace and id", () => { + const secrets = new Map([ + ["myNamespace", new Map([["apiKey", "test-key-123"]])], + ]); + const runtime = newTestRuntime(secrets); + + const secret = runtime.getSecret({ id: "apiKey", namespace: "myNamespace" }).result(); + expect(secret.value).toBe("test-key-123"); + }); + + test("missing secret throws SecretsError", () => { + const runtime = newTestRuntime(); + expect(() => + runtime.getSecret({ id: "missing", namespace: "ns" }).result() + ).toThrow(); + }); +}); +``` + +## Testing Consensus & Node Mode + +The test framework includes a default consensus handler that supports both `Simple` (median aggregation) and `Report` methods. You do not need to mock consensus manually — `runInNodeMode` and `report()` work out of the box: + +```typescript +import { describe, expect } from "bun:test"; +import { consensusMedianAggregation } from "@chainlink/cre-sdk"; +import { test, newTestRuntime } from "@chainlink/cre-sdk/test"; + +describe("consensus", () => { + test("runInNodeMode returns the observed value", () => { + const runtime = newTestRuntime(); + const result = runtime.runInNodeMode(() => 42, consensusMedianAggregation())(); + expect(result.result()).toBe(42); + }); + + test("error with default falls back to default value", () => { + const runtime = newTestRuntime(); + const result = runtime.runInNodeMode( + () => { throw new Error("fail"); }, + consensusMedianAggregation().withDefault(100), + )(); + expect(result.result()).toBe(100); + }); + + test("error without default propagates", () => { + const runtime = newTestRuntime(); + const result = runtime.runInNodeMode( + () => { throw new Error("no default"); }, + consensusMedianAggregation(), + )(); + expect(() => result.result()).toThrow("no default"); + }); +}); +``` + +## Testing Reports + +The default `Report` consensus handler generates test metadata and signatures automatically: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime, REPORT_METADATA_HEADER_LENGTH } from "@chainlink/cre-sdk/test"; + +describe("reports", () => { + test("report generates metadata and signatures", () => { + const runtime = newTestRuntime(); + const payload = Buffer.from(new Uint8Array([1, 2, 3])).toString("base64"); + const result = runtime.report({ encodedPayload: payload }).result(); + + const unwrapped = result.x_generatedCodeOnly_unwrap(); + expect(unwrapped.rawReport.length).toBe(REPORT_METADATA_HEADER_LENGTH + 3); + expect(unwrapped.sigs).toHaveLength(2); + }); +}); +``` + +## Controlling Time + +Override `Date.now()` for deterministic time-dependent tests: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime } from "@chainlink/cre-sdk/test"; + +describe("time control", () => { + test("via constructor option", () => { + const fixedTime = 1700000000000; + const runtime = newTestRuntime(null, { + timeProvider: () => fixedTime, + }); + expect(runtime.now().getTime()).toBe(fixedTime); + }); + + test("via setTimeProvider after creation", () => { + const runtime = newTestRuntime(); + runtime.setTimeProvider(() => 999888777666); + expect(runtime.now().getTime()).toBe(999888777666); + }); +}); +``` + +## Capturing Logs + +`TestRuntime.getLogs()` returns all messages written via `runtime.log()`: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime } from "@chainlink/cre-sdk/test"; + +describe("log capture", () => { + test("captures log messages in order", () => { + const runtime = newTestRuntime(); + runtime.log("step 1 complete"); + runtime.log("step 2 complete"); + + expect(runtime.getLogs()).toEqual(["step 1 complete", "step 2 complete"]); + }); +}); +``` + +## Response Size Validation + +Test that your workflow handles response size limits correctly by setting `maxResponseSize`: + +```typescript +import { describe, expect } from "bun:test"; +import { test, newTestRuntime, RESPONSE_BUFFER_TOO_SMALL } from "@chainlink/cre-sdk/test"; + +describe("response size", () => { + test("oversized response throws", () => { + const runtime = newTestRuntime(null, { maxResponseSize: 1 }); + const payload = Buffer.from(new Uint8Array([1, 2, 3])).toString("base64"); + + expect(() => + runtime.report({ encodedPayload: payload }).result() + ).toThrow(RESPONSE_BUFFER_TOO_SMALL); + }); +}); +``` + +The default maximum response size is 5 MB. + +## API Reference + +### Test Functions + +- `test(title, fn)`: Run a test with an isolated capability registry. Use this instead of `bun:test`'s `test`. +- `newTestRuntime(secrets?, options?)`: Create a `TestRuntime` instance for use in tests. + +**`newTestRuntime` parameters:** + +| Parameter | Type | Description | +|---|---|---| +| `secrets` | `Map>` \| `null` | Map of `namespace` -> `id` -> `value` for secret simulation | +| `options.timeProvider` | `() => number` | Override `Date.now()` for deterministic time | +| `options.maxResponseSize` | `number` | Maximum response size in bytes (default: 5 MB) | + +### TestRuntime + +`TestRuntime` extends the real runtime with test-specific methods: + +- `getLogs(): string[]` — Returns all captured log messages +- `setTimeProvider(fn: () => number): void` — Override the time provider after creation + +All standard runtime methods work as expected: `log()`, `now()`, `getSecret()`, `runInNodeMode()`, `report()`, and capability calls. + +### Capability Mocks + +Every generated capability mock follows the same pattern: + +```typescript +class XMock { + static readonly CAPABILITY_ID: string; + + // One property per capability method + methodName?: (input: InputType) => OutputType | OutputTypeJson; + + // Singleton factory — must be called inside test() + static testInstance(...tags): XMock; +} +``` + +**Key behaviors:** + +- `testInstance()` returns the same instance when called with identical arguments within a single test +- Mocks self-register with the test runtime's capability registry on construction +- Handlers receive decoded protobuf inputs and may return either typed messages or plain JSON +- Invoking a capability method without a handler set throws a descriptive error +- Calling `testInstance()` outside of a CRE `test()` block throws an error + +### Constants + +- `RESPONSE_BUFFER_TOO_SMALL` — Error message thrown when response exceeds `maxResponseSize` +- `DEFAULT_MAX_RESPONSE_SIZE_BYTES` — Default max response size (5 MB) +- `REPORT_METADATA_HEADER_LENGTH` — Length of test report metadata header (109 bytes) diff --git a/packages/cre-sdk/package.json b/packages/cre-sdk/package.json index 92480995..8504ae04 100644 --- a/packages/cre-sdk/package.json +++ b/packages/cre-sdk/package.json @@ -16,6 +16,10 @@ "./pb": { "types": "./dist/pb.d.ts", "import": "./dist/pb.js" + }, + "./test": { + "types": "./dist/sdk/test/index.d.ts", + "import": "./dist/sdk/test/index.js" } }, "bin": { @@ -46,12 +50,12 @@ "full-checks": "bun generate:sdk && bun build && bun typecheck && bun check && bun test && bun test:standard", "generate:chain-selectors": "bun scripts/run.ts generate-chain-selectors && BIOME_PATHS=\"src/generated\" bun check", "generate:proto": "bunx @bufbuild/buf generate && BIOME_PATHS=\"src/generated\" bun check", - "generate:sdk": "bun generate:proto && bun generate:chain-selectors && bun scripts/run generate-sdks && BIOME_PATHS=\"src/generated src/generated-sdk\" bun check", + "generate:sdk": "bun generate:proto && bun generate:chain-selectors && bun scripts/run generate-sdks && BIOME_PATHS=\"src/generated src/generated-sdk src/sdk/test/generated\" bun check", "lint": "biome lint --write", "prepublishOnly": "bun typecheck && bun check && bun test && bun test:standard", "test": "bun test", "test:standard": "./scripts/run-standard-tests.sh", - "typecheck": "tsc" + "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@bufbuild/protobuf": "2.6.3", diff --git a/packages/cre-sdk/scripts/src/generate-sdks.ts b/packages/cre-sdk/scripts/src/generate-sdks.ts index c940641f..1d035c4b 100644 --- a/packages/cre-sdk/scripts/src/generate-sdks.ts +++ b/packages/cre-sdk/scripts/src/generate-sdks.ts @@ -1,3 +1,4 @@ +import { rmSync } from 'node:fs' import { file_capabilities_blockchain_evm_v1alpha_client } from '@cre/generated/capabilities/blockchain/evm/v1alpha/client_pb' import { file_capabilities_internal_actionandtrigger_v1_action_and_trigger } from '@cre/generated/capabilities/internal/actionandtrigger/v1/action_and_trigger_pb' import { file_capabilities_internal_basicaction_v1_basic_action } from '@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb' @@ -8,32 +9,83 @@ import { file_capabilities_networking_confidentialhttp_v1alpha_client } from '@c import { file_capabilities_networking_http_v1alpha_client } from '@cre/generated/capabilities/networking/http/v1alpha/client_pb' import { file_capabilities_networking_http_v1alpha_trigger } from '@cre/generated/capabilities/networking/http/v1alpha/trigger_pb' import { file_capabilities_scheduler_cron_v1_trigger } from '@cre/generated/capabilities/scheduler/cron/v1/trigger_pb' +import { + type GeneratedMockExport, + generateMocks, + writeTestGeneratedBarrel, +} from '@cre/generator/generate-mock' import { generateSdk } from '@cre/generator/generate-sdk' +const TEST_GENERATED_DIR = './src/sdk/test/generated' + export const main = () => { + // Clean stale mock files before regenerating + rmSync(TEST_GENERATED_DIR + '/capabilities', { recursive: true, force: true }) + + const allMockExports: GeneratedMockExport[] = [] + // Internal Testing SDKs generateSdk(file_capabilities_internal_basicaction_v1_basic_action, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_internal_basicaction_v1_basic_action, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_internal_basictrigger_v1_basic_trigger, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_internal_basictrigger_v1_basic_trigger, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_internal_nodeaction_v1_node_action, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_internal_nodeaction_v1_node_action, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_internal_consensus_v1alpha_consensus, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_internal_consensus_v1alpha_consensus, TEST_GENERATED_DIR), + ) generateSdk( file_capabilities_internal_actionandtrigger_v1_action_and_trigger, './src/generated-sdk', ) + allMockExports.push( + ...generateMocks( + file_capabilities_internal_actionandtrigger_v1_action_and_trigger, + TEST_GENERATED_DIR, + ), + ) // Production SDKs generateSdk(file_capabilities_blockchain_evm_v1alpha_client, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_blockchain_evm_v1alpha_client, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_networking_http_v1alpha_client, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_networking_http_v1alpha_client, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_networking_http_v1alpha_trigger, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_networking_http_v1alpha_trigger, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_scheduler_cron_v1_trigger, './src/generated-sdk') + allMockExports.push( + ...generateMocks(file_capabilities_scheduler_cron_v1_trigger, TEST_GENERATED_DIR), + ) generateSdk(file_capabilities_networking_confidentialhttp_v1alpha_client, './src/generated-sdk') + allMockExports.push( + ...generateMocks( + file_capabilities_networking_confidentialhttp_v1alpha_client, + TEST_GENERATED_DIR, + ), + ) + + // Write barrel file that re-exports all generated mocks + writeTestGeneratedBarrel(allMockExports, TEST_GENERATED_DIR) } diff --git a/packages/cre-sdk/src/generator/generate-mock.ts b/packages/cre-sdk/src/generator/generate-mock.ts new file mode 100644 index 00000000..f80ddbdb --- /dev/null +++ b/packages/cre-sdk/src/generator/generate-mock.ts @@ -0,0 +1,226 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { type DescService, getExtension } from '@bufbuild/protobuf' +import type { GenFile } from '@bufbuild/protobuf/codegenv2' +import type { CapabilityMetadata } from '@cre/generated/tools/generator/v1alpha/cre_metadata_pb' +import { + capability, + method as methodOption, +} from '@cre/generated/tools/generator/v1alpha/cre_metadata_pb' +import { processLabels } from './label-utils' +import { getImportPathForFile, lowerCaseFirstLetter, toPascalCase } from './utils' + +const MOCK_OUTSIDE_TEST_ERROR = + "Capability mocks must be used within the CRE test framework's test() method." + +function escapeForDoubleQuotedStringLiteral(s: string): string { + // Use JSON.stringify to correctly escape backslashes, quotes, and control characters, + // then strip the surrounding quotes it adds. + return JSON.stringify(s).slice(1, -1) +} + +export type GeneratedMockExport = { className: string; relativePath: string } + +function getCapabilityServiceOptions(service: DescService): CapabilityMetadata | false { + if (!service.proto.options) { + return false + } + const capabilityOption = getExtension(service.proto.options, capability) + return capabilityOption ?? false +} + +const NO_IMPL_MESSAGE = (methodName: string, propName: string) => + `${methodName}: no implementation provided; set the mock's ${propName} property to define the return value.` + +/** + * Generates a capability mock for the test package. One mock class per capability service; + * mocks self-register with the test runtime. Set per-method handler properties to define return values. + * + * @param file - The proto file containing capability service(s). + * @param outputDir - Directory under the test package (e.g. src/sdk/test/generated). + * @returns List of { className, relativePath } for each mock written (path relative to outputDir, no extension). + */ +export function generateMocks(file: GenFile, outputDir: string): GeneratedMockExport[] { + const capabilityServices = file.services.filter(getCapabilityServiceOptions) + if (capabilityServices.length === 0) { + return [] + } + + const dirDepth = dirname(file.name).split('/').length + 2 + const registerImportPath = '../'.repeat(dirDepth) + 'testutils/test-runtime' + const exports: GeneratedMockExport[] = [] + + capabilityServices.forEach((service) => { + const capOption = getCapabilityServiceOptions(service) + if (!capOption) return + + const serviceMethods = service.methods.filter((method) => { + const methodMeta = method.proto.options + ? getExtension(method.proto.options, methodOption) + : null + return !methodMeta?.mapToUntypedApi + }) + + const actionMethods = serviceMethods.filter((m) => m.methodKind !== 'server_streaming') + if (actionMethods.length === 0) { + return + } + + const typeImports = new Map>() + function addType(fileDesc: { name: string }, typeName: string) { + const path = + fileDesc.name === file.name + ? `@cre/generated/${file.name}_pb` + : getImportPathForFile(fileDesc.name) + if (!typeImports.has(path)) { + typeImports.set(path, new Set()) + } + typeImports.get(path)!.add(typeName) + typeImports.get(path)!.add(`${typeName}Schema`) + } + for (const method of actionMethods) { + addType(method.input.file, method.input.name) + addType(method.output.file, method.output.name) + typeImports + .get( + method.output.file.name === file.name + ? `@cre/generated/${file.name}_pb` + : getImportPathForFile(method.output.file.name), + )! + .add(`${method.output.name}Json`) + } + + const imports: string[] = [ + 'import { fromJson } from "@bufbuild/protobuf"', + 'import { anyPack, anyUnpack } from "@bufbuild/protobuf/wkt"', + `import { registerTestCapability, __getTestMockInstance, __setTestMockInstance } from "${registerImportPath}"`, + ] + typeImports.forEach((types, path) => { + const sorted = Array.from(types).sort() + const parts = sorted.map((s) => (s.endsWith('Schema') ? s : `type ${s}`)) + imports.push(`import { ${parts.join(', ')} } from "${path}"`) + }) + + const handlerCases = actionMethods + .map((method) => { + const propName = lowerCaseFirstLetter(method.name) + const inputType = method.input.name + const outputType = method.output.name + const errMsg = NO_IMPL_MESSAGE(method.name, propName) + const outputJsonType = `${outputType}Json` + return ` case "${method.name}": { + const input = anyUnpack(req.payload, ${inputType}Schema) as ${inputType}; + const handler = self.${propName}; + if (typeof handler !== "function") throw new Error("${escapeForDoubleQuotedStringLiteral(errMsg)}"); + const raw = handler(input); + const output = raw && typeof (raw as unknown as { $typeName?: string }).$typeName === "string" ? (raw as ${outputType}) : fromJson(${outputType}Schema, raw as ${outputJsonType}); + return { response: { case: "payload", value: anyPack(${outputType}Schema, output) } }; + }` + }) + .join('\n') + + const propertyDecls = actionMethods + .map( + (method) => + ` /** Set to define the return value for ${method.name}. May return a plain object (${method.output.name}Json) or the message type. */\n ${lowerCaseFirstLetter(method.name)}?: (input: ${method.input.name}) => ${method.output.name} | ${method.output.name}Json;`, + ) + .join('\n\n') + + // Extract name and version from capability ID (e.g., "evm@1.0.0" -> name="evm", version="1.0.0") + const capIdParts = capOption.capabilityId.split('@') + const capabilityName = capIdParts[0] + const capabilityVersion = capIdParts[1] || '' + + const capabilityClassName = `${service.name}Capability` + const mockClassName = `${toPascalCase(capabilityName)}Mock` + + const labels = processLabels(capOption) + const hasLabels = labels.length > 0 + + // Generate testInstance parameters and capability ID computation + let testInstanceParams = '' + let qualifiedCapabilityIdComputation = '' + let constructorParams = '' + let constructorParamPass = '' + if (hasLabels) { + const paramList = labels.map((l) => `${lowerCaseFirstLetter(l.name)}: ${l.tsType}`).join(', ') + testInstanceParams = paramList + constructorParams = paramList + constructorParamPass = labels.map((l) => lowerCaseFirstLetter(l.name)).join(', ') + const labelParts = labels + .map((l) => `:${l.name}:\${${lowerCaseFirstLetter(l.name)}}`) + .join('') + // Match SDK format: ${name}:Label:${value}@${version} + qualifiedCapabilityIdComputation = `\`${capabilityName}${labelParts}@${capabilityVersion}\`` + } else { + qualifiedCapabilityIdComputation = `${mockClassName}.CAPABILITY_ID` + } + + const output = `${imports.join('\n')} + +/** + * Mock for ${capabilityClassName}. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class ${mockClassName} { + static readonly CAPABILITY_ID = "${capOption.capabilityId}"; + +${propertyDecls} + + private constructor(${constructorParams}) { + const self = this; + const qualifiedId = ${qualifiedCapabilityIdComputation}; + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { +${handlerCases} + default: + return { response: { case: "error", value: \`unknown method \${req.method}\` } }; + } + }); + } catch { + throw new Error("${MOCK_OUTSIDE_TEST_ERROR}") + } + } + + /** + * Returns the mock instance for this capability${hasLabels ? ' and the specified tags' : ''}. + * Multiple calls with the same ${hasLabels ? 'tag values' : 'arguments'} return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(${testInstanceParams}): ${mockClassName} { + const qualifiedId = ${qualifiedCapabilityIdComputation}; + let instance = __getTestMockInstance<${mockClassName}>(qualifiedId); + if (!instance) { + instance = new ${mockClassName}(${constructorParamPass}); + __setTestMockInstance(qualifiedId, instance); + } + return instance; + } +} +` + + const relDir = dirname(file.name) + const fileName = `${capabilityName.replace(/-/g, '_')}_mock_gen.ts` + const outPath = join(outputDir, relDir, fileName) + mkdirSync(dirname(outPath), { recursive: true }) + writeFileSync(outPath, output) + exports.push({ + className: mockClassName, + relativePath: `${relDir}/${fileName.replace(/\.ts$/, '')}`, + }) + console.log(`✅ Generated mock for ${service.name} at: ${outPath}`) + }) + + return exports +} + +/** + * Writes a barrel file that re-exports all generated mocks. Use so the test package can export * from './generated'. + */ +export function writeTestGeneratedBarrel(exports: GeneratedMockExport[], outputDir: string): void { + const lines = exports.map((e) => `export { ${e.className} } from "./${e.relativePath}"`) + const output = `/** Auto-generated barrel of capability mocks. Do not edit. */\n\n${lines.join('\n')}\n` + writeFileSync(join(outputDir, 'index.ts'), output) + console.log(`✅ Wrote test generated barrel (${exports.length} mocks)`) +} diff --git a/packages/cre-sdk/src/generator/utils.ts b/packages/cre-sdk/src/generator/utils.ts index 84d4a116..4ad8551e 100644 --- a/packages/cre-sdk/src/generator/utils.ts +++ b/packages/cre-sdk/src/generator/utils.ts @@ -6,6 +6,15 @@ import { ReportRequestSchema, ReportResponseSchema } from '@cre/generated/sdk/v1 */ export const lowerCaseFirstLetter = (str: string) => `${str.charAt(0).toLowerCase()}${str.slice(1)}` +/** + * Converts a hyphenated string to PascalCase (e.g. "http-actions" -> "HttpActions") + */ +export const toPascalCase = (str: string): string => + str + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + /** * Gets the import path for a given protobuf file */ diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts index 721fa6aa..a77441b7 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts @@ -49,6 +49,7 @@ import { } from '@cre/sdk/utils' import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' import { DonModeError, NodeModeError, SecretsError } from '../errors' +import { RESPONSE_BUFFER_TOO_SMALL } from '../testutils/test-runtime' import { type RuntimeHelpers, RuntimeImpl } from './runtime-impl' // Helper function to create a RuntimeHelpers mock with error-throwing defaults @@ -273,6 +274,62 @@ describe('test runtime', () => { }), ) }) + + test('await throws RESPONSE_BUFFER_TOO_SMALL when response exceeds max size', () => { + const helpers = createRuntimeHelpersMock({ + call: mock((_: CapabilityRequest) => true), + await: mock((_: AwaitCapabilitiesRequest) => { + throw new Error(RESPONSE_BUFFER_TOO_SMALL) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const workflowAction1 = new BasicActionCapability() + const call1 = workflowAction1.performAction( + runtime, + create(InputsSchema, { inputThing: true }), + ) + + expect(() => call1.result()).toThrow(RESPONSE_BUFFER_TOO_SMALL) + }) + + test('await returns unparsable payload throws CapabilityError', () => { + // Any with correct type_url but invalid value bytes so fromBinary throws + const validAny = anyPack(OutputsSchema, create(OutputsSchema, { adaptedThing: 'x' })) + const corruptPayload = { + typeUrl: validAny.typeUrl, + value: new Uint8Array([0xff, 0xff]), + } + + const helpers = createRuntimeHelpersMock({ + call: mock((_: CapabilityRequest) => true), + await: mock((request: AwaitCapabilitiesRequest) => { + const id = request.ids[0] + return create(AwaitCapabilitiesResponseSchema, { + responses: { + [id]: create(CapabilityResponseSchema, { + response: { case: 'payload', value: corruptPayload as Any }, + }), + }, + }) + }), + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const workflowAction1 = new BasicActionCapability() + const call1 = workflowAction1.performAction( + runtime, + create(InputsSchema, { inputThing: true }), + ) + + expect(() => call1.result()).toThrow( + new CapabilityError('Error cannot unwrap payload', { + callbackId: 1, + capabilityId: BasicActionCapability.CAPABILITY_ID, + method: 'PerformAction', + }), + ) + }) }) }) @@ -710,6 +767,69 @@ describe('test run in node mode', () => { expect(() => result.result()).toThrow(new Error(anyError)) }) + test('primitive consensus with unused default returns observation value', () => { + const observationValue = 99 + const defaultValue = 100 + const helpers = createRuntimeHelpersMock({ + switchModes: mock((_: Mode) => {}), + }) + + ConsensusCapability.prototype.simple = mock( + (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + const inputsProto = inputs as SimpleConsensusInputs + expect(inputsProto.observation.case).toEqual('value') + expect(Value.wrap(inputsProto.observation.value as ProtoValue).unwrap()).toEqual( + observationValue, + ) + expect(inputsProto.default).toBeDefined() + expect(Value.wrap(inputsProto.default as ProtoValue).unwrap()).toEqual(defaultValue) + return { + result: () => Value.from(observationValue).proto(), + } + }, + ) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const result = runtime + .runInNodeMode( + (_: NodeRuntime) => observationValue, + consensusMedianAggregation().withDefault(defaultValue), + )() + .result() + + expect(result).toEqual(observationValue) + }) + + test('primitive consensus with used default returns default when function errors', () => { + const defaultVal = 100 + const anyError = 'error' + const helpers = createRuntimeHelpersMock({ + switchModes: mock((_: Mode) => {}), + }) + + ConsensusCapability.prototype.simple = mock( + (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + const inputsProto = inputs as SimpleConsensusInputs + expect(inputsProto.observation.case).toEqual('error') + expect(inputsProto.observation.value).toEqual(anyError) + expect(inputsProto.default).toBeDefined() + expect(Value.wrap(inputsProto.default as ProtoValue).unwrap()).toEqual(defaultVal) + return { + result: () => Value.from(defaultVal).proto(), + } + }, + ) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const result = runtime + .runInNodeMode((_: NodeRuntime) => { + throw new Error(anyError) + }, consensusMedianAggregation().withDefault(defaultVal))() + .result() + + expect(result).toEqual(defaultVal) + }) + test('node runtime in don mode fails', () => { const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/blockchain/evm/v1alpha/evm_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/blockchain/evm/v1alpha/evm_mock_gen.ts new file mode 100644 index 00000000..8eeadda6 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/blockchain/evm/v1alpha/evm_mock_gen.ts @@ -0,0 +1,252 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type BalanceAtReply, + type BalanceAtReplyJson, + BalanceAtReplySchema, + type BalanceAtRequest, + BalanceAtRequestSchema, + type CallContractReply, + type CallContractReplyJson, + CallContractReplySchema, + type CallContractRequest, + CallContractRequestSchema, + type EstimateGasReply, + type EstimateGasReplyJson, + EstimateGasReplySchema, + type EstimateGasRequest, + EstimateGasRequestSchema, + type FilterLogsReply, + type FilterLogsReplyJson, + FilterLogsReplySchema, + type FilterLogsRequest, + FilterLogsRequestSchema, + type GetTransactionByHashReply, + type GetTransactionByHashReplyJson, + GetTransactionByHashReplySchema, + type GetTransactionByHashRequest, + GetTransactionByHashRequestSchema, + type GetTransactionReceiptReply, + type GetTransactionReceiptReplyJson, + GetTransactionReceiptReplySchema, + type GetTransactionReceiptRequest, + GetTransactionReceiptRequestSchema, + type HeaderByNumberReply, + type HeaderByNumberReplyJson, + HeaderByNumberReplySchema, + type HeaderByNumberRequest, + HeaderByNumberRequestSchema, + type WriteReportReply, + type WriteReportReplyJson, + WriteReportReplySchema, + type WriteReportRequest, + WriteReportRequestSchema, +} from '@cre/generated/capabilities/blockchain/evm/v1alpha/client_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for ClientCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class EvmMock { + static readonly CAPABILITY_ID = 'evm@1.0.0' + + /** Set to define the return value for CallContract. May return a plain object (CallContractReplyJson) or the message type. */ + callContract?: (input: CallContractRequest) => CallContractReply | CallContractReplyJson + + /** Set to define the return value for FilterLogs. May return a plain object (FilterLogsReplyJson) or the message type. */ + filterLogs?: (input: FilterLogsRequest) => FilterLogsReply | FilterLogsReplyJson + + /** Set to define the return value for BalanceAt. May return a plain object (BalanceAtReplyJson) or the message type. */ + balanceAt?: (input: BalanceAtRequest) => BalanceAtReply | BalanceAtReplyJson + + /** Set to define the return value for EstimateGas. May return a plain object (EstimateGasReplyJson) or the message type. */ + estimateGas?: (input: EstimateGasRequest) => EstimateGasReply | EstimateGasReplyJson + + /** Set to define the return value for GetTransactionByHash. May return a plain object (GetTransactionByHashReplyJson) or the message type. */ + getTransactionByHash?: ( + input: GetTransactionByHashRequest, + ) => GetTransactionByHashReply | GetTransactionByHashReplyJson + + /** Set to define the return value for GetTransactionReceipt. May return a plain object (GetTransactionReceiptReplyJson) or the message type. */ + getTransactionReceipt?: ( + input: GetTransactionReceiptRequest, + ) => GetTransactionReceiptReply | GetTransactionReceiptReplyJson + + /** Set to define the return value for HeaderByNumber. May return a plain object (HeaderByNumberReplyJson) or the message type. */ + headerByNumber?: (input: HeaderByNumberRequest) => HeaderByNumberReply | HeaderByNumberReplyJson + + /** Set to define the return value for WriteReport. May return a plain object (WriteReportReplyJson) or the message type. */ + writeReport?: (input: WriteReportRequest) => WriteReportReply | WriteReportReplyJson + + private constructor(chainSelector: bigint) { + const self = this + const qualifiedId = `evm:ChainSelector:${chainSelector}@1.0.0` + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'CallContract': { + const input = anyUnpack(req.payload, CallContractRequestSchema) as CallContractRequest + const handler = self.callContract + if (typeof handler !== 'function') + throw new Error( + "CallContract: no implementation provided; set the mock's callContract property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as CallContractReply) + : fromJson(CallContractReplySchema, raw as CallContractReplyJson) + return { + response: { case: 'payload', value: anyPack(CallContractReplySchema, output) }, + } + } + case 'FilterLogs': { + const input = anyUnpack(req.payload, FilterLogsRequestSchema) as FilterLogsRequest + const handler = self.filterLogs + if (typeof handler !== 'function') + throw new Error( + "FilterLogs: no implementation provided; set the mock's filterLogs property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as FilterLogsReply) + : fromJson(FilterLogsReplySchema, raw as FilterLogsReplyJson) + return { response: { case: 'payload', value: anyPack(FilterLogsReplySchema, output) } } + } + case 'BalanceAt': { + const input = anyUnpack(req.payload, BalanceAtRequestSchema) as BalanceAtRequest + const handler = self.balanceAt + if (typeof handler !== 'function') + throw new Error( + "BalanceAt: no implementation provided; set the mock's balanceAt property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as BalanceAtReply) + : fromJson(BalanceAtReplySchema, raw as BalanceAtReplyJson) + return { response: { case: 'payload', value: anyPack(BalanceAtReplySchema, output) } } + } + case 'EstimateGas': { + const input = anyUnpack(req.payload, EstimateGasRequestSchema) as EstimateGasRequest + const handler = self.estimateGas + if (typeof handler !== 'function') + throw new Error( + "EstimateGas: no implementation provided; set the mock's estimateGas property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as EstimateGasReply) + : fromJson(EstimateGasReplySchema, raw as EstimateGasReplyJson) + return { response: { case: 'payload', value: anyPack(EstimateGasReplySchema, output) } } + } + case 'GetTransactionByHash': { + const input = anyUnpack( + req.payload, + GetTransactionByHashRequestSchema, + ) as GetTransactionByHashRequest + const handler = self.getTransactionByHash + if (typeof handler !== 'function') + throw new Error( + "GetTransactionByHash: no implementation provided; set the mock's getTransactionByHash property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as GetTransactionByHashReply) + : fromJson(GetTransactionByHashReplySchema, raw as GetTransactionByHashReplyJson) + return { + response: { + case: 'payload', + value: anyPack(GetTransactionByHashReplySchema, output), + }, + } + } + case 'GetTransactionReceipt': { + const input = anyUnpack( + req.payload, + GetTransactionReceiptRequestSchema, + ) as GetTransactionReceiptRequest + const handler = self.getTransactionReceipt + if (typeof handler !== 'function') + throw new Error( + "GetTransactionReceipt: no implementation provided; set the mock's getTransactionReceipt property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as GetTransactionReceiptReply) + : fromJson(GetTransactionReceiptReplySchema, raw as GetTransactionReceiptReplyJson) + return { + response: { + case: 'payload', + value: anyPack(GetTransactionReceiptReplySchema, output), + }, + } + } + case 'HeaderByNumber': { + const input = anyUnpack( + req.payload, + HeaderByNumberRequestSchema, + ) as HeaderByNumberRequest + const handler = self.headerByNumber + if (typeof handler !== 'function') + throw new Error( + "HeaderByNumber: no implementation provided; set the mock's headerByNumber property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as HeaderByNumberReply) + : fromJson(HeaderByNumberReplySchema, raw as HeaderByNumberReplyJson) + return { + response: { case: 'payload', value: anyPack(HeaderByNumberReplySchema, output) }, + } + } + case 'WriteReport': { + const input = anyUnpack(req.payload, WriteReportRequestSchema) as WriteReportRequest + const handler = self.writeReport + if (typeof handler !== 'function') + throw new Error( + "WriteReport: no implementation provided; set the mock's writeReport property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as WriteReportReply) + : fromJson(WriteReportReplySchema, raw as WriteReportReplyJson) + return { response: { case: 'payload', value: anyPack(WriteReportReplySchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability and the specified tags. + * Multiple calls with the same tag values return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(chainSelector: bigint): EvmMock { + const qualifiedId = `evm:ChainSelector:${chainSelector}@1.0.0` + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new EvmMock(chainSelector) + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/actionandtrigger/v1/basic_test_action_trigger_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/actionandtrigger/v1/basic_test_action_trigger_mock_gen.ts new file mode 100644 index 00000000..1ec0cc10 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/actionandtrigger/v1/basic_test_action_trigger_mock_gen.ts @@ -0,0 +1,71 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type Input, + InputSchema, + type Output, + type OutputJson, + OutputSchema, +} from '@cre/generated/capabilities/internal/actionandtrigger/v1/action_and_trigger_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for BasicCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class BasicTestActionTriggerMock { + static readonly CAPABILITY_ID = 'basic-test-action-trigger@1.0.0' + + /** Set to define the return value for Action. May return a plain object (OutputJson) or the message type. */ + action?: (input: Input) => Output | OutputJson + + private constructor() { + const self = this + const qualifiedId = BasicTestActionTriggerMock.CAPABILITY_ID + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'Action': { + const input = anyUnpack(req.payload, InputSchema) as Input + const handler = self.action + if (typeof handler !== 'function') + throw new Error( + "Action: no implementation provided; set the mock's action property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as Output) + : fromJson(OutputSchema, raw as OutputJson) + return { response: { case: 'payload', value: anyPack(OutputSchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability. + * Multiple calls with the same arguments return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(): BasicTestActionTriggerMock { + const qualifiedId = BasicTestActionTriggerMock.CAPABILITY_ID + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new BasicTestActionTriggerMock() + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/basicaction/v1/basic_test_action_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/basicaction/v1/basic_test_action_mock_gen.ts new file mode 100644 index 00000000..00f85ea1 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/basicaction/v1/basic_test_action_mock_gen.ts @@ -0,0 +1,71 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type Inputs, + InputsSchema, + type Outputs, + type OutputsJson, + OutputsSchema, +} from '@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for BasicActionCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class BasicTestActionMock { + static readonly CAPABILITY_ID = 'basic-test-action@1.0.0' + + /** Set to define the return value for PerformAction. May return a plain object (OutputsJson) or the message type. */ + performAction?: (input: Inputs) => Outputs | OutputsJson + + private constructor() { + const self = this + const qualifiedId = BasicTestActionMock.CAPABILITY_ID + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'PerformAction': { + const input = anyUnpack(req.payload, InputsSchema) as Inputs + const handler = self.performAction + if (typeof handler !== 'function') + throw new Error( + "PerformAction: no implementation provided; set the mock's performAction property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as Outputs) + : fromJson(OutputsSchema, raw as OutputsJson) + return { response: { case: 'payload', value: anyPack(OutputsSchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability. + * Multiple calls with the same arguments return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(): BasicTestActionMock { + const qualifiedId = BasicTestActionMock.CAPABILITY_ID + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new BasicTestActionMock() + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/consensus/v1alpha/consensus_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/consensus/v1alpha/consensus_mock_gen.ts new file mode 100644 index 00000000..76d279fd --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/consensus/v1alpha/consensus_mock_gen.ts @@ -0,0 +1,94 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type ReportRequest, + ReportRequestSchema, + type ReportResponse, + type ReportResponseJson, + ReportResponseSchema, + type SimpleConsensusInputs, + SimpleConsensusInputsSchema, +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import { type Value, type ValueJson, ValueSchema } from '@cre/generated/values/v1/values_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for ConsensusCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class ConsensusMock { + static readonly CAPABILITY_ID = 'consensus@1.0.0-alpha' + + /** Set to define the return value for Simple. May return a plain object (ValueJson) or the message type. */ + simple?: (input: SimpleConsensusInputs) => Value | ValueJson + + /** Set to define the return value for Report. May return a plain object (ReportResponseJson) or the message type. */ + report?: (input: ReportRequest) => ReportResponse | ReportResponseJson + + private constructor() { + const self = this + const qualifiedId = ConsensusMock.CAPABILITY_ID + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'Simple': { + const input = anyUnpack( + req.payload, + SimpleConsensusInputsSchema, + ) as SimpleConsensusInputs + const handler = self.simple + if (typeof handler !== 'function') + throw new Error( + "Simple: no implementation provided; set the mock's simple property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as Value) + : fromJson(ValueSchema, raw as ValueJson) + return { response: { case: 'payload', value: anyPack(ValueSchema, output) } } + } + case 'Report': { + const input = anyUnpack(req.payload, ReportRequestSchema) as ReportRequest + const handler = self.report + if (typeof handler !== 'function') + throw new Error( + "Report: no implementation provided; set the mock's report property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as ReportResponse) + : fromJson(ReportResponseSchema, raw as ReportResponseJson) + return { response: { case: 'payload', value: anyPack(ReportResponseSchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability. + * Multiple calls with the same arguments return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(): ConsensusMock { + const qualifiedId = ConsensusMock.CAPABILITY_ID + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new ConsensusMock() + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/nodeaction/v1/basic_test_node_action_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/nodeaction/v1/basic_test_node_action_mock_gen.ts new file mode 100644 index 00000000..b1b74943 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/internal/nodeaction/v1/basic_test_node_action_mock_gen.ts @@ -0,0 +1,71 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type NodeInputs, + NodeInputsSchema, + type NodeOutputs, + type NodeOutputsJson, + NodeOutputsSchema, +} from '@cre/generated/capabilities/internal/nodeaction/v1/node_action_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for BasicActionCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class BasicTestNodeActionMock { + static readonly CAPABILITY_ID = 'basic-test-node-action@1.0.0' + + /** Set to define the return value for PerformAction. May return a plain object (NodeOutputsJson) or the message type. */ + performAction?: (input: NodeInputs) => NodeOutputs | NodeOutputsJson + + private constructor() { + const self = this + const qualifiedId = BasicTestNodeActionMock.CAPABILITY_ID + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'PerformAction': { + const input = anyUnpack(req.payload, NodeInputsSchema) as NodeInputs + const handler = self.performAction + if (typeof handler !== 'function') + throw new Error( + "PerformAction: no implementation provided; set the mock's performAction property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as NodeOutputs) + : fromJson(NodeOutputsSchema, raw as NodeOutputsJson) + return { response: { case: 'payload', value: anyPack(NodeOutputsSchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability. + * Multiple calls with the same arguments return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(): BasicTestNodeActionMock { + const qualifiedId = BasicTestNodeActionMock.CAPABILITY_ID + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new BasicTestNodeActionMock() + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/networking/confidentialhttp/v1alpha/confidential_http_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/networking/confidentialhttp/v1alpha/confidential_http_mock_gen.ts new file mode 100644 index 00000000..15a834a4 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/networking/confidentialhttp/v1alpha/confidential_http_mock_gen.ts @@ -0,0 +1,74 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type ConfidentialHTTPRequest, + ConfidentialHTTPRequestSchema, + type HTTPResponse, + type HTTPResponseJson, + HTTPResponseSchema, +} from '@cre/generated/capabilities/networking/confidentialhttp/v1alpha/client_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for ClientCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class ConfidentialHttpMock { + static readonly CAPABILITY_ID = 'confidential-http@1.0.0-alpha' + + /** Set to define the return value for SendRequest. May return a plain object (HTTPResponseJson) or the message type. */ + sendRequest?: (input: ConfidentialHTTPRequest) => HTTPResponse | HTTPResponseJson + + private constructor() { + const self = this + const qualifiedId = ConfidentialHttpMock.CAPABILITY_ID + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'SendRequest': { + const input = anyUnpack( + req.payload, + ConfidentialHTTPRequestSchema, + ) as ConfidentialHTTPRequest + const handler = self.sendRequest + if (typeof handler !== 'function') + throw new Error( + "SendRequest: no implementation provided; set the mock's sendRequest property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as HTTPResponse) + : fromJson(HTTPResponseSchema, raw as HTTPResponseJson) + return { response: { case: 'payload', value: anyPack(HTTPResponseSchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability. + * Multiple calls with the same arguments return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(): ConfidentialHttpMock { + const qualifiedId = ConfidentialHttpMock.CAPABILITY_ID + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new ConfidentialHttpMock() + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/capabilities/networking/http/v1alpha/http_actions_mock_gen.ts b/packages/cre-sdk/src/sdk/test/generated/capabilities/networking/http/v1alpha/http_actions_mock_gen.ts new file mode 100644 index 00000000..10d7f762 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/capabilities/networking/http/v1alpha/http_actions_mock_gen.ts @@ -0,0 +1,71 @@ +import { fromJson } from '@bufbuild/protobuf' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { + type Request, + RequestSchema, + type Response, + type ResponseJson, + ResponseSchema, +} from '@cre/generated/capabilities/networking/http/v1alpha/client_pb' +import { + __getTestMockInstance, + __setTestMockInstance, + registerTestCapability, +} from '../../../../../../testutils/test-runtime' + +/** + * Mock for ClientCapability. Use testInstance() to obtain an instance; do not construct directly. + * Set per-method properties (e.g. performAction) to define return values. If a method is invoked without a handler set, an error is thrown. + */ +export class HttpActionsMock { + static readonly CAPABILITY_ID = 'http-actions@1.0.0-alpha' + + /** Set to define the return value for SendRequest. May return a plain object (ResponseJson) or the message type. */ + sendRequest?: (input: Request) => Response | ResponseJson + + private constructor() { + const self = this + const qualifiedId = HttpActionsMock.CAPABILITY_ID + try { + registerTestCapability(qualifiedId, (req) => { + switch (req.method) { + case 'SendRequest': { + const input = anyUnpack(req.payload, RequestSchema) as Request + const handler = self.sendRequest + if (typeof handler !== 'function') + throw new Error( + "SendRequest: no implementation provided; set the mock's sendRequest property to define the return value.", + ) + const raw = handler(input) + const output = + raw && typeof (raw as unknown as { $typeName?: string }).$typeName === 'string' + ? (raw as Response) + : fromJson(ResponseSchema, raw as ResponseJson) + return { response: { case: 'payload', value: anyPack(ResponseSchema, output) } } + } + default: + return { response: { case: 'error', value: `unknown method ${req.method}` } } + } + }) + } catch { + throw new Error( + "Capability mocks must be used within the CRE test framework's test() method.", + ) + } + } + + /** + * Returns the mock instance for this capability. + * Multiple calls with the same arguments return the same instance. + * Must be called within the test framework's test() method. + */ + static testInstance(): HttpActionsMock { + const qualifiedId = HttpActionsMock.CAPABILITY_ID + let instance = __getTestMockInstance(qualifiedId) + if (!instance) { + instance = new HttpActionsMock() + __setTestMockInstance(qualifiedId, instance) + } + return instance + } +} diff --git a/packages/cre-sdk/src/sdk/test/generated/index.ts b/packages/cre-sdk/src/sdk/test/generated/index.ts new file mode 100644 index 00000000..52e25d83 --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/generated/index.ts @@ -0,0 +1,9 @@ +/** Auto-generated barrel of capability mocks. Do not edit. */ + +export { EvmMock } from './capabilities/blockchain/evm/v1alpha/evm_mock_gen' +export { BasicTestActionTriggerMock } from './capabilities/internal/actionandtrigger/v1/basic_test_action_trigger_mock_gen' +export { BasicTestActionMock } from './capabilities/internal/basicaction/v1/basic_test_action_mock_gen' +export { ConsensusMock } from './capabilities/internal/consensus/v1alpha/consensus_mock_gen' +export { BasicTestNodeActionMock } from './capabilities/internal/nodeaction/v1/basic_test_node_action_mock_gen' +export { ConfidentialHttpMock } from './capabilities/networking/confidentialhttp/v1alpha/confidential_http_mock_gen' +export { HttpActionsMock } from './capabilities/networking/http/v1alpha/http_actions_mock_gen' diff --git a/packages/cre-sdk/src/sdk/test/index.ts b/packages/cre-sdk/src/sdk/test/index.ts new file mode 100644 index 00000000..7e0a121d --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/index.ts @@ -0,0 +1,22 @@ +/** + * Test-only surface for the CRE SDK: test framework and (when generated) capability mocks. + * Use for unit testing runtime logic without the WASM execution environment. + */ + +export { + type CapabilityHandler, + DEFAULT_MAX_RESPONSE_SIZE_BYTES, + getTestCapabilityHandler, + type NewTestRuntimeOptions, + newTestRuntime, + REPORT_METADATA_HEADER_LENGTH, + RESPONSE_BUFFER_TOO_SMALL, + registerTestCapability, + type Secrets, + TestRuntime, + type TestRuntimeState, + test, +} from '../testutils/test-runtime' +export { TestWriter } from '../testutils/test-writer' + +export * from './generated' diff --git a/packages/cre-sdk/src/sdk/test/mock-integration.test.ts b/packages/cre-sdk/src/sdk/test/mock-integration.test.ts new file mode 100644 index 00000000..7c65477b --- /dev/null +++ b/packages/cre-sdk/src/sdk/test/mock-integration.test.ts @@ -0,0 +1,129 @@ +/** + * Verifies that generated capability mocks work with the test framework: + * mocks self-register when constructed inside test(); per-method handlers decode payload, + * invoke the user implementation, and encode the response. Tests both "no implementation" error + * and happy path (callCapability + await path) with typed overrides. + */ +import { test as bunTest, describe, expect } from 'bun:test' +import { BasicTestActionMock, EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' +import { ClientCapability as EvmClientCapability } from '@cre/generated-sdk/capabilities/blockchain/evm/v1alpha/client_sdk_gen' +import { BasicActionCapability } from '@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen' + +const MOCK_OUTSIDE_TEST_ERROR = + "Capability mocks must be used within the CRE test framework's test() method." + +const NO_IMPL_PATTERN = + /PerformAction: no implementation provided; set the mock's performAction property to define the return value/ + +describe('Generated capability mocks', () => { + test('invoking capability without setting handler throws clear error', async () => { + BasicTestActionMock.testInstance() // registers mock; do not set performAction + const runtime = newTestRuntime() + const capability = new BasicActionCapability() + + const response = capability.performAction(runtime, { inputThing: true }) + expect(() => response.result()).toThrow(NO_IMPL_PATTERN) + }) + + test('handler receives decoded input exactly as passed at call site', async () => { + const expectedInputValue = true + const mock = BasicTestActionMock.testInstance() + let receivedInputThing: boolean | undefined + mock.performAction = (input) => { + receivedInputThing = input.inputThing + return { adaptedThing: 'test-output' } + } + const runtime = newTestRuntime() + const capability = new BasicActionCapability() + + capability.performAction(runtime, { inputThing: expectedInputValue }).result() + + expect(receivedInputThing).toBeDefined() + expect(receivedInputThing).toBe(expectedInputValue) + }) + + test('returned output matches handler return value exactly', async () => { + const expectedOutputValue = 'custom-adapted-result' + const mock = BasicTestActionMock.testInstance() + mock.performAction = () => { + return { adaptedThing: expectedOutputValue } + } + const runtime = newTestRuntime() + const capability = new BasicActionCapability() + + const result = capability.performAction(runtime, { inputThing: false }).result() + + expect(result.adaptedThing).toBe(expectedOutputValue) + }) + + test('both callCapability and awaitCapability paths return identical handler result', async () => { + const expectedOutput = 'result-from-handler' + const inputValue = true + const mock = BasicTestActionMock.testInstance() + mock.performAction = (input) => { + expect(input.inputThing).toBe(inputValue) + return { adaptedThing: expectedOutput } + } + const runtime = newTestRuntime() + const cap = new BasicActionCapability() + + const result1 = cap.performAction(runtime, { inputThing: inputValue }).result() + expect(result1.adaptedThing).toBe(expectedOutput) + + const result2 = cap.performAction(runtime, { inputThing: inputValue }).result() + expect(result2.adaptedThing).toBe(expectedOutput) + }) + + test('calling testInstance twice returns same instance', async () => { + const instance1 = BasicTestActionMock.testInstance() + const instance2 = BasicTestActionMock.testInstance() + expect(instance1).toBe(instance2) + }) +}) + +describe('Tag-aware capability mocks (EVM with chain selectors)', () => { + test('testInstance with same chain selector returns same instance', async () => { + const chainSelector = 11155111n // Sepolia + const instance1 = EvmMock.testInstance(chainSelector) + const instance2 = EvmMock.testInstance(chainSelector) + expect(instance1).toBe(instance2) + }) + + test('testInstance with different chain selectors returns different instances', async () => { + const sepoliaSelector = 11155111n + const mumbaiSelector = 80001n + const sepoliaInstance = EvmMock.testInstance(sepoliaSelector) + const mumbaiInstance = EvmMock.testInstance(mumbaiSelector) + expect(sepoliaInstance).not.toBe(mumbaiInstance) + }) + + test('different chain selectors register under distinct capability IDs', async () => { + const sepoliaSelector = 11155111n + const mumbaiSelector = 80001n + + const sepoliaMock = EvmMock.testInstance(sepoliaSelector) + const mumbaiMock = EvmMock.testInstance(mumbaiSelector) + + // Use JSON types with base64 strings for bytes + sepoliaMock.callContract = () => ({ data: 'AQID' }) // base64 for [1, 2, 3] + mumbaiMock.callContract = () => ({ data: 'BAUG' }) // base64 for [4, 5, 6] + + const runtime = newTestRuntime() + const sepoliaCapability = new EvmClientCapability(sepoliaSelector) + const mumbaiCapability = new EvmClientCapability(mumbaiSelector) + + const sepoliaResult = sepoliaCapability + .callContract(runtime, { call: { to: '', data: '' } }) + .result() + const mumbaiResult = mumbaiCapability + .callContract(runtime, { call: { to: '', data: '' } }) + .result() + + expect(sepoliaResult.data).toEqual(new Uint8Array([1, 2, 3])) + expect(mumbaiResult.data).toEqual(new Uint8Array([4, 5, 6])) + }) +}) + +bunTest('mock throws when used outside CRE test()', () => { + expect(() => BasicTestActionMock.testInstance()).toThrow(MOCK_OUTSIDE_TEST_ERROR) +}) diff --git a/packages/cre-sdk/src/sdk/testutils/index.ts b/packages/cre-sdk/src/sdk/testutils/index.ts new file mode 100644 index 00000000..9d2d280f --- /dev/null +++ b/packages/cre-sdk/src/sdk/testutils/index.ts @@ -0,0 +1,20 @@ +/** + * Test-only utilities for the CRE SDK runtime. Not part of the public API. + * Use for unit testing runtime logic without the WASM execution environment. + */ + +export { + type CapabilityHandler, + DEFAULT_MAX_RESPONSE_SIZE_BYTES, + getTestCapabilityHandler, + type NewTestRuntimeOptions, + newTestRuntime, + REPORT_METADATA_HEADER_LENGTH, + RESPONSE_BUFFER_TOO_SMALL, + registerTestCapability, + type Secrets, + TestRuntime, + type TestRuntimeState, + test, +} from './test-runtime' +export { TestWriter } from './test-writer' diff --git a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts new file mode 100644 index 00000000..55a5102e --- /dev/null +++ b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for the test-runtime helper layer only. Covers Registry (via test()), + * createTestRuntimeHelpers, default consensus handler, and TestRuntime getLogs/setTimeProvider. + * Does not re-test RuntimeImpl behaviour covered in runtime-impl.test.ts. + */ +import { test as bunTest, describe, expect } from 'bun:test' +import { create } from '@bufbuild/protobuf' +import { AnySchema } from '@bufbuild/protobuf/wkt' +import { BasicActionCapability } from '@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen' +import { consensusMedianAggregation } from '@cre/sdk/utils' +import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' +import { SecretsError } from '../errors' +import { BasicTestActionMock } from '../test/generated/capabilities/internal/basicaction/v1/basic_test_action_mock_gen' +import { + __testOnlyRegistryStore, + __testOnlyRunWithRegistry, + getTestCapabilityHandler, + newTestRuntime, + REPORT_METADATA_HEADER_LENGTH, + RESPONSE_BUFFER_TOO_SMALL, + registerTestCapability, + test, +} from './test-runtime' + +describe('Registry (via test)', () => { + test('get returns undefined for unregistered id', async () => { + expect(getTestCapabilityHandler('missing')).toBeUndefined() + }) + + test('register and get return handler', async () => { + const handler = () => ({ + response: { case: 'error' as const, value: 'x' }, + }) + registerTestCapability('my-cap', handler) + expect(getTestCapabilityHandler('my-cap')).toBe(handler) + }) + + test('register throws when id already exists', async () => { + registerTestCapability('dup', () => ({ + response: { case: 'error' as const, value: '' }, + })) + expect(() => + registerTestCapability('dup', () => ({ + response: { case: 'error' as const, value: '' }, + })), + ).toThrow('capability already exists: dup') + }) +}) + +describe('TestRuntime / helper layer', () => { + test('getLogs returns messages written via helper log()', () => { + const rt = newTestRuntime() + rt.log('msg1') + rt.log('msg2') + expect(rt.getLogs()).toEqual(['msg1', 'msg2']) + }) + + test('now() uses Date.now() when setTimeProvider not set', () => { + const rt = newTestRuntime() + const before = Date.now() + const t = rt.now().getTime() + const after = Date.now() + expect(t).toBeGreaterThanOrEqual(before) + expect(t).toBeLessThanOrEqual(after) + }) + + test('setTimeProvider causes helper now() to return provided value', () => { + const rt = newTestRuntime() + const fixed = 999888777666 + rt.setTimeProvider(() => fixed) + expect(rt.now().getTime()).toBe(fixed) + }) + + test('helper call returns false for unregistered capability', () => { + const rt = newTestRuntime() + const cap = new BasicActionCapability() + const call = cap.performAction(rt, { inputThing: true }) + expect(() => call.result()).toThrow(CapabilityError) + expect(() => call.result()).toThrow(/Capability not found/) + }) + + test('registered capability: callCapability and await path both route to handler and return result', () => { + const expectedResult = 'result-from-registered-handler' + const mock = BasicTestActionMock.testInstance() + mock.performAction = () => ({ adaptedThing: expectedResult }) + const rt = newTestRuntime() + + // Sync path: callCapability (via performAction) then .result() triggers internal await + const call1 = new BasicActionCapability().performAction(rt, { + inputThing: true, + }) + expect(call1.result().adaptedThing).toBe(expectedResult) + + // Async path: two in-flight calls, then both .result() — helper routes by callbackId + const call2 = new BasicActionCapability().performAction(rt, { + inputThing: false, + }) + const call3 = new BasicActionCapability().performAction(rt, { + inputThing: true, + }) + expect(call2.result().adaptedThing).toBe(expectedResult) + expect(call3.result().adaptedThing).toBe(expectedResult) + }) + + test('helper call catches handler throw and await returns error response', () => { + const rt = newTestRuntime() + const errMsg = 'node function error' + const p = rt.runInNodeMode(() => { + throw new Error(errMsg) + }, consensusMedianAggregation())() + expect(() => p.result()).toThrow(errMsg) + }) + + test('helper await throws RESPONSE_BUFFER_TOO_SMALL when serialized response exceeds maxResponseSize', () => { + const rt = newTestRuntime(null, { maxResponseSize: 1 }) + const payload = new Uint8Array(new ArrayBuffer(3)) + payload.set([1, 2, 3]) + const reportCall = rt.report({ + encodedPayload: Buffer.from(payload).toString('base64'), + }) + expect(() => reportCall.result()).toThrow(RESPONSE_BUFFER_TOO_SMALL) + }) + + test('default Report handler: defaultReport metadata + payload + sigs', () => { + const rt = newTestRuntime() + const payloadBytes = new TextEncoder().encode('some_encoded_report_data') + const payload = new Uint8Array(new ArrayBuffer(payloadBytes.length)) + payload.set(payloadBytes) + const result = rt.report({ encodedPayload: Buffer.from(payload).toString('base64') }).result() + const unwrapped = result.x_generatedCodeOnly_unwrap() + expect(unwrapped.rawReport.length).toBe(REPORT_METADATA_HEADER_LENGTH + payload.length) + const expectedMetadata = new Uint8Array(REPORT_METADATA_HEADER_LENGTH) + for (let i = 0; i < REPORT_METADATA_HEADER_LENGTH; i++) { + expectedMetadata[i] = i % 256 + } + expect(unwrapped.rawReport.slice(0, REPORT_METADATA_HEADER_LENGTH)).toEqual(expectedMetadata) + expect(unwrapped.rawReport.slice(REPORT_METADATA_HEADER_LENGTH)).toEqual(payload) + expect(unwrapped.sigs).toHaveLength(2) + expect(new TextDecoder().decode(unwrapped.sigs[0].signature)).toBe('default_signature_1') + expect(new TextDecoder().decode(unwrapped.sigs[1].signature)).toBe('default_signature_2') + }) + + test('default Simple handler: observation value branch returns value', () => { + const rt = newTestRuntime() + const p = rt.runInNodeMode(() => 42, consensusMedianAggregation())() + expect(p.result()).toBe(42) + }) + + test('default Simple handler: observation error with default returns default', () => { + const rt = newTestRuntime() + const p = rt.runInNodeMode(() => { + throw new Error('fail') + }, consensusMedianAggregation().withDefault(100))() + expect(p.result()).toBe(100) + }) + + test('default Simple handler: observation error without default throws', () => { + const rt = newTestRuntime() + const p = rt.runInNodeMode(() => { + throw new Error('no default') + }, consensusMedianAggregation())() + expect(() => p.result()).toThrow('no default') + }) + + test('default consensus handler returns error for unknown method', async () => { + newTestRuntime() // registers consensus handler on current registry + const handler = getTestCapabilityHandler('consensus@1.0.0-alpha') + if (!handler) throw new Error('expected handler') + const res = handler({ + id: 'consensus@1.0.0-alpha', + method: 'Other', + payload: create(AnySchema, { value: new Uint8Array(0) }), + }) + expect(res.response.case).toBe('error') + if (res.response.case === 'error') { + expect(res.response.value).toBe('unknown method Other') + } + }) + + test('helper getSecrets: secret found returns value', () => { + const secrets = new Map>() + secrets.set('ns1', new Map([['id1', 'val1']])) + const rt = newTestRuntime(secrets) + const result = rt.getSecret({ id: 'id1', namespace: 'ns1' }).result() + expect(result.value).toBe('val1') + expect(result.id).toBe('id1') + expect(result.namespace).toBe('ns1') + }) + + test('helper getSecrets: secret not found returns error response', () => { + const rt = newTestRuntime() + expect(() => rt.getSecret({ id: 'missing', namespace: 'ns' }).result()).toThrow(SecretsError) + }) + + test('newTestRuntime uses options.maxResponseSize', () => { + const rt = newTestRuntime(null, { maxResponseSize: 1 }) + const payload = new Uint8Array(new ArrayBuffer(2)) + payload.set([1, 2]) + expect(() => + rt.report({ encodedPayload: Buffer.from(payload).toString('base64') }).result(), + ).toThrow(RESPONSE_BUFFER_TOO_SMALL) + }) + + test('newTestRuntime with null/undefined secrets uses empty map', () => { + const rt = newTestRuntime(null) + expect(() => rt.getSecret({ id: 'x', namespace: 'y' }).result()).toThrow(SecretsError) + const rt2 = newTestRuntime() + expect(rt2.getLogs()).toEqual([]) + }) +}) + +describe('test wrapper', () => { + test('registry is available inside test body (set/read without passing registry)', async () => { + registerTestCapability('cap-a', () => ({ + response: { case: 'error' as const, value: 'a' }, + })) + const handler = getTestCapabilityHandler('cap-a') + if (!handler) throw new Error('expected handler') + expect( + handler({ + id: 'cap-a', + method: 'M', + payload: create(AnySchema, { value: new Uint8Array(0) }), + }).response, + ).toEqual({ + case: 'error', + value: 'a', + }) + }) + + test('isolation: test A registers only-a', async () => { + registerTestCapability('only-a', () => ({ + response: { case: 'error' as const, value: 'a' }, + })) + expect(getTestCapabilityHandler('only-a')).toBeDefined() + }) + + test('isolation: test B does not see test A registry', async () => { + expect(getTestCapabilityHandler('only-a')).toBeUndefined() + }) + + bunTest('cleanup: after test finishes, store is undefined', async () => { + await __testOnlyRunWithRegistry(async () => { + expect(__testOnlyRegistryStore()).toBeDefined() + }) + expect(__testOnlyRegistryStore()).toBeUndefined() + }) + + bunTest('failure path: cleanup happens when test body throws', async () => { + await expect( + __testOnlyRunWithRegistry(async () => { + expect(__testOnlyRegistryStore()).toBeDefined() + throw new Error('intentional failure') + }), + ).rejects.toThrow('intentional failure') + expect(__testOnlyRegistryStore()).toBeUndefined() + }) +}) diff --git a/packages/cre-sdk/src/sdk/testutils/test-runtime.ts b/packages/cre-sdk/src/sdk/testutils/test-runtime.ts new file mode 100644 index 00000000..01844874 --- /dev/null +++ b/packages/cre-sdk/src/sdk/testutils/test-runtime.ts @@ -0,0 +1,405 @@ +/** + * TestRuntime and harness for testing the CRE SDK runtime without WASM. + * Registry is scoped per test via AsyncLocalStorage; use testWithRuntime to run tests with a registry. + */ + +import { test as bunTest } from 'bun:test' +import { AsyncLocalStorage } from 'node:async_hooks' +import { create, toBinary } from '@bufbuild/protobuf' +import type { Any } from '@bufbuild/protobuf/wkt' +import { anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import type { + AwaitCapabilitiesResponse, + AwaitSecretsResponse, + CapabilityResponse, + GetSecretsRequest, + Mode, + ReportRequest, + ReportRequestJson, + SecretResponse, + SecretResponses, + SimpleConsensusInputs, +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import { + AttributedSignatureSchema, + AwaitCapabilitiesResponseSchema, + AwaitSecretsResponseSchema, + CapabilityResponseSchema, + ReportRequestSchema, + ReportResponseSchema, + SecretErrorSchema, + SecretResponseSchema, + SecretResponsesSchema, + SecretSchema, + SimpleConsensusInputsSchema, +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' +import { ValueSchema } from '@cre/generated/values/v1/values_pb' +import type { RuntimeHelpers } from '../impl/runtime-impl' +import { RuntimeImpl } from '../impl/runtime-impl' +import { TestWriter } from './test-writer' + +/** Error message when response exceeds max size. Used by the harness when its await implements the size check. */ +export const RESPONSE_BUFFER_TOO_SMALL = 'response buffer too small' + +export const DEFAULT_MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024 +export const REPORT_METADATA_HEADER_LENGTH = 109 + +export type Secrets = Map> + +export type CapabilityHandler = (request: { + id: string + method: string + payload: Any +}) => { response: { case: 'payload'; value: Any } } | { response: { case: 'error'; value: string } } + +/** Registry is private; stored in AsyncLocalStorage when running inside testWithRuntime. */ +class Registry { + private capabilities = new Map() + private mockInstances = new Map() + + register(id: string, handler: CapabilityHandler): void { + if (this.capabilities.has(id)) { + throw new Error(`capability already exists: ${id}`) + } + this.capabilities.set(id, handler) + } + + get(id: string): CapabilityHandler | undefined { + return this.capabilities.get(id) + } + + getMockInstance(id: string): T | undefined { + return this.mockInstances.get(id) as T | undefined + } + + setMockInstance(id: string, instance: T): void { + this.mockInstances.set(id, instance) + } +} + +const registryStorage = new AsyncLocalStorage() + +/** + * Returns the capability handler for the given id from the current test's registry, if any. + * Only defined when called inside a testWithRuntime scope (after the handler is registered). + */ +export function getTestCapabilityHandler(id: string): CapabilityHandler | undefined { + return registryStorage.getStore()?.get(id) +} + +/** + * Registers a capability handler for the current test's registry. + * Must be called inside a testWithRuntime scope; throws if no registry is active. + */ +export function registerTestCapability(id: string, handler: CapabilityHandler): void { + const registry = registryStorage.getStore() + if (!registry) { + throw new Error('registerTestCapability must be called from within a test in this package') + } + registry.register(id, handler) +} + +/** + * Gets a mock instance from the current test's registry, or undefined if not found or no registry active. + * @internal Used by generated mocks for singleton pattern. + */ +export function __getTestMockInstance(id: string): T | undefined { + return registryStorage.getStore()?.getMockInstance(id) +} + +/** + * Stores a mock instance in the current test's registry. + * @internal Used by generated mocks for singleton pattern. + */ +export function __setTestMockInstance(id: string, instance: T): void { + const registry = registryStorage.getStore() + if (!registry) { + throw new Error('mock instance management must be called from within a test in this package') + } + registry.setMockInstance(id, instance) +} + +/** + * Test-only: returns the current registry store (for cleanup/isolation assertions). + * Do not use in production code. + */ +export function __testOnlyRegistryStore(): object | undefined { + return registryStorage.getStore() ?? undefined +} + +/** + * Test-only: runs a callback with a fresh registry and cleans up (for cleanup/failure tests). + * Do not use in production code. + */ +export async function __testOnlyRunWithRegistry(fn: () => void | Promise): Promise { + const registry = new Registry() + await registryStorage.run(registry, async () => { + try { + await fn() + } finally { + registryStorage.enterWith(undefined) + } + }) +} + +type CapabilityResponsePayload = { case: 'payload'; value: Any } | { case: 'error'; value: string } + +function defaultSimpleConsensus(input: SimpleConsensusInputs): ProtoValue { + const obs = input.observation + if (obs.case === 'value') { + return reportFromValue(obs.value) + } + if (obs.case === 'error') { + if (input.default != null && input.default.value != null) { + return reportFromValue(input.default) + } + throw new Error(obs.value) + } + throw new Error(`unknown observation type`) +} + +function reportFromValue(value: ProtoValue): ProtoValue { + return create(ValueSchema, { value: value.value }) as ProtoValue +} + +function createTestReportMetadata(): Uint8Array { + const metadata = new Uint8Array(REPORT_METADATA_HEADER_LENGTH) + for (let i = 0; i < REPORT_METADATA_HEADER_LENGTH; i++) { + metadata[i] = (i % 256) as number + } + return metadata +} + +function defaultReport(input: ReportRequest | ReportRequestJson): { + rawReport: Uint8Array + sigs: Array<{ signature: Uint8Array; signerId: number }> +} { + const encodedPayload = + typeof (input as ReportRequest).encodedPayload !== 'undefined' + ? (input as ReportRequest).encodedPayload + : new Uint8Array(0) + const metadata = createTestReportMetadata() + const rawReport = new Uint8Array(metadata.length + encodedPayload.length) + rawReport.set(metadata) + rawReport.set(encodedPayload, metadata.length) + const sigs = [ + { + signature: new TextEncoder().encode('default_signature_1'), + signerId: 1, + }, + { + signature: new TextEncoder().encode('default_signature_2'), + signerId: 2, + }, + ] + return { rawReport, sigs } +} + +const CONSENSUS_CAPABILITY_ID = 'consensus@1.0.0-alpha' + +export interface TestRuntimeState { + timeProvider?: () => number +} + +function createTestRuntimeHelpers( + registry: Registry, + secrets: Secrets, + testWriter: TestWriter, + state: TestRuntimeState, + _maxResponseSize: bigint, +): RuntimeHelpers { + const pendingCalls = new Map() + const pendingSecrets = new Map() + + function now(): number { + return state.timeProvider ? state.timeProvider() : Date.now() + } + + return { + call(request: Parameters[0]): boolean { + const handler = registry.get(request.id) + if (!handler) return false + const payload = request.payload ?? ({} as Any) + try { + const result = handler({ + id: request.id, + method: request.method, + payload, + }) + pendingCalls.set(request.callbackId, result.response) + } catch (e) { + pendingCalls.set(request.callbackId, { + case: 'error', + value: e instanceof Error ? e.message : String(e), + }) + } + return true + }, + + await(request: { ids: number[] }, maxResponseSizeBytes: bigint): AwaitCapabilitiesResponse { + const responses: Record = {} + for (const id of request.ids) { + const resp = pendingCalls.get(id) + if (resp) { + responses[id] = create(CapabilityResponseSchema, { response: resp }) + pendingCalls.delete(id) + } + } + const response = create(AwaitCapabilitiesResponseSchema, { responses }) + const bytes = toBinary(AwaitCapabilitiesResponseSchema, response) + if (bytes.length > Number(maxResponseSizeBytes)) { + throw new Error(RESPONSE_BUFFER_TOO_SMALL) + } + return response + }, + + getSecrets(req: GetSecretsRequest, _maxResponseSize: bigint): boolean { + const resp: SecretResponse[] = [] + for (const secretReq of req.requests) { + const ns = secrets.get(secretReq.namespace || 'default') + const value = ns?.get(secretReq.id) + if (value === undefined) { + resp.push( + create(SecretResponseSchema, { + response: { + case: 'error', + value: create(SecretErrorSchema, { + id: secretReq.id, + namespace: secretReq.namespace || 'default', + owner: '', + error: `could not find secret ${secretReq.namespace || 'default'}`, + }), + }, + }), + ) + } else { + resp.push( + create(SecretResponseSchema, { + response: { + case: 'secret', + value: create(SecretSchema, { + id: secretReq.id, + namespace: secretReq.namespace || 'default', + value, + }), + }, + }), + ) + } + } + pendingSecrets.set(req.callbackId, resp) + return true + }, + + awaitSecrets(request: { ids: number[] }, _maxResponseSize: bigint): AwaitSecretsResponse { + const responses: Record = {} + for (const id of request.ids) { + const resp = pendingSecrets.get(id) + if (!resp) { + throw new Error(`could not find call with id ${id}`) + } + responses[id] = create(SecretResponsesSchema, { responses: resp }) + pendingSecrets.delete(id) + } + return create(AwaitSecretsResponseSchema, { responses }) + }, + + switchModes(_mode: Mode): void {}, + + now, + + log(message: string): void { + testWriter.log(message) + }, + } +} + +export interface NewTestRuntimeOptions { + timeProvider?: () => number + maxResponseSize?: number +} + +/** + * Runs a test using the CRE runtime. + */ +export function test(title: string, fn: () => void | Promise): void { + bunTest(title, async () => { + const registry = new Registry() + try { + return await registryStorage.run(registry, async () => fn()) + } finally { + registryStorage.enterWith(undefined) + } + }) +} + +/** + * Creates a test runtime. This must be called from within a test in this package. + */ +export function newTestRuntime( + secrets?: Secrets | null, + options: NewTestRuntimeOptions = {}, +): TestRuntime { + const secretsMap = secrets ?? new Map>() + const testWriter = new TestWriter() + const registry = registryStorage.getStore() ?? new Registry() + + if (!registry.get(CONSENSUS_CAPABILITY_ID)) { + registry.register(CONSENSUS_CAPABILITY_ID, (req) => { + if (req.method === 'Simple') { + const input = anyUnpack(req.payload, SimpleConsensusInputsSchema) as SimpleConsensusInputs + const value = defaultSimpleConsensus(input) + const packed = anyPack(ValueSchema, value) + return { response: { case: 'payload', value: packed } } + } + if (req.method === 'Report') { + const input = anyUnpack(req.payload, ReportRequestSchema) as ReportRequest + const { rawReport, sigs } = defaultReport(input) + const reportResp = create(ReportResponseSchema, { + configDigest: new Uint8Array(0), + seqNr: 0n, + reportContext: new Uint8Array(0), + rawReport, + sigs: sigs.map((s) => create(AttributedSignatureSchema, s)), + }) + const packed = anyPack(ReportResponseSchema, reportResp) + return { response: { case: 'payload', value: packed } } + } + return { + response: { case: 'error', value: `unknown method ${req.method}` }, + } + }) + } + + const state: TestRuntimeState = { + timeProvider: options.timeProvider, + } + const maxResponseSize = BigInt(options.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE_BYTES) + const helpers = createTestRuntimeHelpers(registry, secretsMap, testWriter, state, maxResponseSize) + + return new TestRuntime(helpers, maxResponseSize, testWriter, state) +} + +/** + * TestRuntime is a Runtime implementation for unit tests. Extends RuntimeImpl; construct via newTestRuntime. + * Adds getLogs() and setTimeProvider(). Registry is accessed via getTestCapabilityHandler when inside testWithRuntime. + */ +export class TestRuntime extends RuntimeImpl { + constructor( + helpers: RuntimeHelpers, + maxResponseSize: bigint, + private readonly testWriter: TestWriter, + private readonly state: TestRuntimeState, + ) { + super({}, 0, helpers, maxResponseSize) + } + + getLogs(): string[] { + return this.testWriter.getLogs() + } + + setTimeProvider(timeProvider: () => number): void { + this.state.timeProvider = timeProvider + } +} diff --git a/packages/cre-sdk/src/sdk/testutils/test-writer.test.ts b/packages/cre-sdk/src/sdk/testutils/test-writer.test.ts new file mode 100644 index 00000000..ceea728f --- /dev/null +++ b/packages/cre-sdk/src/sdk/testutils/test-writer.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for TestWriter. Harness test framework (Step 3). + */ +import { describe, expect, test } from 'bun:test' +import { TestWriter } from './test-writer' + +describe('TestWriter', () => { + test('getLogs returns empty array when no messages logged', () => { + const w = new TestWriter() + expect(w.getLogs()).toEqual([]) + }) + + test('getLogs returns messages in order after log()', () => { + const w = new TestWriter() + w.log('a') + w.log('b') + w.log('c') + expect(w.getLogs()).toEqual(['a', 'b', 'c']) + }) + + test('getLogs returns a copy so mutating result does not affect internal buffer', () => { + const w = new TestWriter() + w.log('x') + const logs = w.getLogs() + logs.push('y') + expect(w.getLogs()).toEqual(['x']) + }) + + test('clear empties the log buffer', () => { + const w = new TestWriter() + w.log('one') + w.log('two') + w.clear() + expect(w.getLogs()).toEqual([]) + }) + + test('log after clear appends to empty buffer', () => { + const w = new TestWriter() + w.log('before') + w.clear() + w.log('after') + expect(w.getLogs()).toEqual(['after']) + }) + + test('preserves message content exactly (encoding)', () => { + const w = new TestWriter() + const msg = 'hello \n\t\u00a0' + w.log(msg) + expect(w.getLogs()).toHaveLength(1) + expect(w.getLogs()[0]).toBe(msg) + }) +}) diff --git a/packages/cre-sdk/src/sdk/testutils/test-writer.ts b/packages/cre-sdk/src/sdk/testutils/test-writer.ts new file mode 100644 index 00000000..7c620b3c --- /dev/null +++ b/packages/cre-sdk/src/sdk/testutils/test-writer.ts @@ -0,0 +1,22 @@ +/** + * In-memory log sink for tests. Captures messages so tests can assert on log output. + * Equivalent to Go's cre/testutils/test_writer.go. + */ +export class TestWriter { + private logs: string[] = [] + + /** Appends a message to the captured log buffer. */ + log(message: string): void { + this.logs.push(message) + } + + /** Returns a copy of all captured log messages in order. */ + getLogs(): string[] { + return [...this.logs] + } + + /** Clears the captured log buffer. */ + clear(): void { + this.logs = [] + } +} diff --git a/packages/cre-sdk/tsconfig.json b/packages/cre-sdk/tsconfig.json index ff7b2b43..1a5841ff 100644 --- a/packages/cre-sdk/tsconfig.json +++ b/packages/cre-sdk/tsconfig.json @@ -16,7 +16,8 @@ // Path aliases "baseUrl": ".", "paths": { - "@cre/*": ["src/*"] + "@cre/*": ["src/*"], + "@chainlink/cre-sdk/test": ["src/sdk/test/index.ts"] }, // Best practices