From 3525b7b0c6375ff7d35f6fbaccd289d22eef05f5 Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Tue, 5 May 2026 15:40:40 -0400 Subject: [PATCH 1/2] Added getSecrets method to fetch multiple secrets --- packages/cre-sdk-examples/.env.example | 7 +- packages/cre-sdk-examples/.gitignore | 3 +- packages/cre-sdk-examples/package.json | 4 +- packages/cre-sdk-examples/secrets.yaml | 10 +- .../src/workflows/secrets/index.ts | 69 +- packages/cre-sdk/package.json | 2 +- packages/cre-sdk/src/sdk/errors.ts | 12 + .../cre-sdk/src/sdk/impl/runtime-impl.test.ts | 1314 ++++++++++------- packages/cre-sdk/src/sdk/impl/runtime-impl.ts | 378 +++-- .../src/sdk/testutils/test-runtime.test.ts | 411 +++--- packages/cre-sdk/src/sdk/wasm/runner.test.ts | 436 +++--- packages/cre-sdk/src/sdk/wasm/runner.ts | 137 +- packages/cre-sdk/src/sdk/workflow.ts | 34 +- 13 files changed, 1654 insertions(+), 1163 deletions(-) diff --git a/packages/cre-sdk-examples/.env.example b/packages/cre-sdk-examples/.env.example index e10aed2a..fe668a14 100644 --- a/packages/cre-sdk-examples/.env.example +++ b/packages/cre-sdk-examples/.env.example @@ -13,7 +13,10 @@ CRE_ETH_PRIVATE_KEY=000000000000000000000000000000000000000000000000000000000000 CRE_TARGET=local-simulation # This one will be used in PoR workflow SECRET_ADDRESS_ALL=0x4700A50d858Cb281847ca4Ee0938F80DEfB3F1dd -# This one will be used in secrets workflow -SECRET_CHARACTER_ID=5 +# These will be used in secrets workflow +SECRET_URL_VALUE="https://swapi.info/api/people/{characterId}" +SECRET_CHARACTER_ID1=5 +SECRET_CHARACTER_ID2=6 +SECRET_CHARACTER_ID3=7 # Secret header value for HTTP trigger SECRET_HEADER_VALUE=abcd1234 diff --git a/packages/cre-sdk-examples/.gitignore b/packages/cre-sdk-examples/.gitignore index 5f97ba11..386df487 100644 --- a/packages/cre-sdk-examples/.gitignore +++ b/packages/cre-sdk-examples/.gitignore @@ -2,4 +2,5 @@ node_modules dist .turbo tmp.js -tmp.wasm \ No newline at end of file +tmp.wasm +.cre_build_tmp.js \ No newline at end of file diff --git a/packages/cre-sdk-examples/package.json b/packages/cre-sdk-examples/package.json index fe67082a..3f38557c 100644 --- a/packages/cre-sdk-examples/package.json +++ b/packages/cre-sdk-examples/package.json @@ -1,7 +1,7 @@ { "name": "@chainlink/cre-sdk-examples", "private": true, - "version": "1.6.0", + "version": "1.7.0-alpha.1", "type": "module", "author": "Ernest Nowacki", "license": "BUSL-1.1", @@ -16,7 +16,7 @@ }, "dependencies": { "@bufbuild/protobuf": "2.6.3", - "@chainlink/cre-sdk": "workspace:*", + "@chainlink/cre-sdk": "1.7.0-alpha.1", "viem": "2.34.0", "zod": "3.25.76" }, diff --git a/packages/cre-sdk-examples/secrets.yaml b/packages/cre-sdk-examples/secrets.yaml index 1691a194..871840e6 100644 --- a/packages/cre-sdk-examples/secrets.yaml +++ b/packages/cre-sdk-examples/secrets.yaml @@ -1,7 +1,13 @@ secretsNames: SECRET_ADDRESS: - SECRET_ADDRESS_ALL - CHARACTER_ID: - - SECRET_CHARACTER_ID + SECRET_URL: + - SECRET_URL_VALUE + CHARACTER_ID1: + - SECRET_CHARACTER_ID1 + CHARACTER_ID2: + - SECRET_CHARACTER_ID2 + CHARACTER_ID3: + - SECRET_CHARACTER_ID3 SECRET_HEADER: - SECRET_HEADER_VALUE diff --git a/packages/cre-sdk-examples/src/workflows/secrets/index.ts b/packages/cre-sdk-examples/src/workflows/secrets/index.ts index 7b7e7089..b00c0efa 100644 --- a/packages/cre-sdk-examples/src/workflows/secrets/index.ts +++ b/packages/cre-sdk-examples/src/workflows/secrets/index.ts @@ -8,14 +8,14 @@ import { ok, Runner, type Runtime, -} from '@chainlink/cre-sdk' -import { z } from 'zod' +} from "@chainlink/cre-sdk"; +import { z } from "zod"; const configSchema = z.object({ url: z.string(), -}) +}); -type Config = z.infer +type Config = z.infer; const responseSchema = z.object({ name: z.string(), @@ -34,52 +34,75 @@ const responseSchema = z.object({ created: z.string().datetime(), edited: z.string().datetime(), url: z.string(), -}) +}); -type StarWarsCharacter = z.infer +type StarWarsCharacter = z.infer; const fetchStarWarsCharacter = ( sendRequester: HTTPSendRequester, config: Config, + url: string, characterId: string, ): StarWarsCharacter => { - const url = config.url.replace('{characterId}', characterId) - const response = sendRequester.sendRequest({ url, method: 'GET' }).result() + url = config.url.replace("{characterId}", characterId); + const response = sendRequester.sendRequest({ url, method: "GET" }).result(); // Check if the response is successful using the helper function if (!ok(response)) { - throw new Error(`HTTP request failed with status: ${response.statusCode}`) + throw new Error(`HTTP request failed with status: ${response.statusCode}`); } - const character = responseSchema.parse(json(response)) + const character = responseSchema.parse(json(response)); - return character -} + return character; +}; const onHTTPTrigger = async (runtime: Runtime) => { - const httpCapability = new HTTPClient() - const characterId = runtime.getSecret({ id: 'CHARACTER_ID' }).result().value + const httpCapability = new HTTPClient(); + // Fetch a single secret + const secretUrlValue = runtime.getSecret({ id: "SECRET_URL" }).result().value; + + // Fetch multiple secrets + const secretsToFetch = [ + { id: "CHARACTER_ID1" }, + { id: "CHARACTER_ID2" }, + { id: "CHARACTER_ID3" }, + ]; + const secretResponses = runtime.getSecrets(secretsToFetch).result(); + const characterIds = secretResponses.flatMap((response) => + response.response.case === "secret" && response.response.value?.id + ? [response.response.value.value] + : [], + ); + if (characterIds.length === 0) { + throw new Error("No character ID secrets available"); + } + + // choose a random character id + // Math.random() is safe to use in the workflow + const characterId = + characterIds[Math.floor(Math.random() * characterIds.length)]; const result: StarWarsCharacter = httpCapability .sendRequest( runtime, fetchStarWarsCharacter, consensusIdenticalAggregation(), - )(runtime.config, characterId) - .result() + )(runtime.config, secretUrlValue, characterId) + .result(); - return result -} + return result; +}; const initWorkflow = () => { - const httpTrigger = new HTTPCapability() + const httpTrigger = new HTTPCapability(); - return [handler(httpTrigger.trigger({}), onHTTPTrigger)] -} + return [handler(httpTrigger.trigger({}), onHTTPTrigger)]; +}; export async function main() { const runner = await Runner.newRunner({ configSchema, - }) - await runner.run(initWorkflow) + }); + await runner.run(initWorkflow); } diff --git a/packages/cre-sdk/package.json b/packages/cre-sdk/package.json index 90de2554..b3c3094e 100644 --- a/packages/cre-sdk/package.json +++ b/packages/cre-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@chainlink/cre-sdk", - "version": "1.6.0", + "version": "1.7.0-alpha.1", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/cre-sdk/src/sdk/errors.ts b/packages/cre-sdk/src/sdk/errors.ts index 02fa2186..b3681532 100644 --- a/packages/cre-sdk/src/sdk/errors.ts +++ b/packages/cre-sdk/src/sdk/errors.ts @@ -26,6 +26,18 @@ export class SecretsError extends Error { } } +export class SecretsBatchError extends Error { + constructor( + public readonly secretRequests: SecretRequest[], + public readonly error: string, + ) { + super( + `batch secret retrieval failed for ${secretRequests.length} request(s): ${error}. Verify the host response is complete and that the workflow has access to the requested secrets`, + ) + this.name = 'SecretsBatchError' + } +} + export class NullReportError extends Error { constructor() { super('null report') 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 45f566b1..45b29132 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts @@ -1,21 +1,21 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' -import { create } from '@bufbuild/protobuf' -import { type Any, anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { create } from "@bufbuild/protobuf"; +import { type Any, anyPack, anyUnpack } from "@bufbuild/protobuf/wkt"; import { InputSchema, OutputSchema, -} from '@cre/generated/capabilities/internal/actionandtrigger/v1/action_and_trigger_pb' +} from "@cre/generated/capabilities/internal/actionandtrigger/v1/action_and_trigger_pb"; import { InputsSchema, OutputsSchema, -} from '@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb' +} from "@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb"; import { type NodeInputs, type NodeInputsJson, NodeInputsSchema, type NodeOutputs, NodeOutputsSchema, -} from '@cre/generated/capabilities/internal/nodeaction/v1/node_action_pb' +} from "@cre/generated/capabilities/internal/nodeaction/v1/node_action_pb"; import { AggregationType, type AwaitCapabilitiesRequest, @@ -31,13 +31,13 @@ import { SecretResponsesSchema, type SimpleConsensusInputs, type SimpleConsensusInputsJson, -} from '@cre/generated/sdk/v1alpha/sdk_pb' -import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' -import { BasicCapability } from '@cre/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen' -import { BasicActionCapability } from '@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen' -import { ConsensusCapability } from '@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen' -import { BasicActionCapability as NodeActionCapability } from '@cre/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen' -import type { NodeRuntime, Runtime } from '@cre/sdk/cre' +} from "@cre/generated/sdk/v1alpha/sdk_pb"; +import type { Value as ProtoValue } from "@cre/generated/values/v1/values_pb"; +import { BasicCapability } from "@cre/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen"; +import { BasicActionCapability } from "@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen"; +import { ConsensusCapability } from "@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen"; +import { BasicActionCapability as NodeActionCapability } from "@cre/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen"; +import type { NodeRuntime, Runtime } from "@cre/sdk/cre"; import { ConsensusAggregationByFields, ConsensusFieldAggregation, @@ -46,138 +46,159 @@ import { ignore, median, Value, -} 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' +} from "@cre/sdk/utils"; +import { CapabilityError } from "@cre/sdk/utils/capabilities/capability-error"; +import { + DonModeError, + NodeModeError, + SecretsBatchError, + 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 -function createRuntimeHelpersMock(overrides: Partial = {}): RuntimeHelpers { +function createRuntimeHelpersMock( + overrides: Partial = {}, +): RuntimeHelpers { // Create default implementation that throws errors for all methods const defaultMock: RuntimeHelpers = { call: mock(() => { - throw new Error('Method not implemented: call') + throw new Error("Method not implemented: call"); }), await: mock(() => { - throw new Error('Method not implemented: await') + throw new Error("Method not implemented: await"); }), getSecrets: mock(() => { - throw new Error('Method not implemented: getSecrets') + throw new Error("Method not implemented: getSecrets"); }), awaitSecrets: mock(() => { - throw new Error('Method not implemented: awaitSecrets') + throw new Error("Method not implemented: awaitSecrets"); }), // switchModes is used in every test, most will ignore it, so it's safe to default to a no-op. switchModes: mock(() => {}), now: mock(() => { - throw new Error('Method not implemented: now') + throw new Error("Method not implemented: now"); }), log: mock(() => {}), - } + }; // Return a merged object with overrides taking precedence - return { ...defaultMock, ...overrides } + return { ...defaultMock, ...overrides }; } -const anyMaxSize = 1024n * 1024n +const anyMaxSize = 1024n * 1024n; // Store original prototypes for manual restoration -const originalConsensusSimple = ConsensusCapability.prototype.simple -const originalNodeActionPerformAction = NodeActionCapability.prototype.performAction +const originalConsensusSimple = ConsensusCapability.prototype.simple; +const originalNodeActionPerformAction = + NodeActionCapability.prototype.performAction; afterEach(() => { // Restore all mocks after each test - mock.restore() + mock.restore(); // Manually restore prototype methods - ConsensusCapability.prototype.simple = originalConsensusSimple - NodeActionCapability.prototype.performAction = originalNodeActionPerformAction -}) - -describe('test runtime', () => { - describe('test call capability', () => { - test('allows awaiting multiple capability results in different order than calls', () => { - const anyResult1 = 'ok1' - const anyResult2 = 'ok2' - var expectedCall = 1 - var expectedAwait = 2 - - const input1 = create(InputsSchema, { inputThing: true }) - const input2 = create(InputSchema, { name: 'input' }) + ConsensusCapability.prototype.simple = originalConsensusSimple; + NodeActionCapability.prototype.performAction = + originalNodeActionPerformAction; +}); + +describe("test runtime", () => { + describe("test call capability", () => { + test("allows awaiting multiple capability results in different order than calls", () => { + const anyResult1 = "ok1"; + const anyResult2 = "ok2"; + var expectedCall = 1; + var expectedAwait = 2; + + const input1 = create(InputsSchema, { inputThing: true }); + const input2 = create(InputSchema, { name: "input" }); const helpers = createRuntimeHelpersMock({ call: mock((request: CapabilityRequest) => { switch (request.callbackId) { case 1: - expect(expectedCall).toEqual(1) - expectedCall++ - expect(request.id).toEqual(BasicActionCapability.CAPABILITY_ID) - expect(request.method).toEqual('PerformAction') - expect(anyUnpack(request.payload as Any, InputsSchema)).toEqual(input1) - return true + expect(expectedCall).toEqual(1); + expectedCall++; + expect(request.id).toEqual(BasicActionCapability.CAPABILITY_ID); + expect(request.method).toEqual("PerformAction"); + expect(anyUnpack(request.payload as Any, InputsSchema)).toEqual( + input1, + ); + return true; case 2: - expect(expectedCall).toEqual(2) - expectedCall++ - expect(request.id).toEqual(BasicCapability.CAPABILITY_ID) - expect(request.method).toEqual('Action') - expect(anyUnpack(request.payload as Any, InputSchema)).toEqual(input2) - return true + expect(expectedCall).toEqual(2); + expectedCall++; + expect(request.id).toEqual(BasicCapability.CAPABILITY_ID); + expect(request.method).toEqual("Action"); + expect(anyUnpack(request.payload as Any, InputSchema)).toEqual( + input2, + ); + return true; default: - throw new Error(`Unexpected call with callbackId: ${request.callbackId}`) + throw new Error( + `Unexpected call with callbackId: ${request.callbackId}`, + ); } }), await: mock((request: AwaitCapabilitiesRequest) => { - expect(request.ids.length).toEqual(1) - var payload: Any - const id = request.ids[0] + expect(request.ids.length).toEqual(1); + var payload: Any; + const id = request.ids[0]; switch (id) { case 1: - expect(1).toEqual(expectedAwait) - expectedAwait-- - payload = anyPack(OutputsSchema, create(OutputsSchema, { adaptedThing: anyResult1 })) - break + expect(1).toEqual(expectedAwait); + expectedAwait--; + payload = anyPack( + OutputsSchema, + create(OutputsSchema, { adaptedThing: anyResult1 }), + ); + break; case 2: - expect(2).toEqual(expectedAwait) - expectedAwait-- - payload = anyPack(OutputSchema, create(OutputSchema, { welcome: anyResult2 })) - break + expect(2).toEqual(expectedAwait); + expectedAwait--; + payload = anyPack( + OutputSchema, + create(OutputSchema, { welcome: anyResult2 }), + ); + break; default: - throw new Error(`Unexpected await with id: ${request.ids[0]}`) + throw new Error(`Unexpected await with id: ${request.ids[0]}`); } return create(AwaitCapabilitiesResponseSchema, { responses: { [id]: create(CapabilityResponseSchema, { - response: { case: 'payload', value: payload }, + response: { case: "payload", value: payload }, }), }, - }) + }); }), - }) - - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() - const call1 = workflowAction1.performAction(runtime, input1) - const workflowAction2 = new BasicCapability() - const call2 = workflowAction2.action(runtime, input2) - const result2 = call2.result() - expect(result2.welcome).toEqual(anyResult2) - const result1 = call1.result() - expect(result1.adaptedThing).toEqual(anyResult1) - }) - - test('call capability errors', () => { + }); + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const workflowAction1 = new BasicActionCapability(); + const call1 = workflowAction1.performAction(runtime, input1); + const workflowAction2 = new BasicCapability(); + const call2 = workflowAction2.action(runtime, input2); + const result2 = call2.result(); + expect(result2.welcome).toEqual(anyResult2); + const result1 = call1.result(); + expect(result1.adaptedThing).toEqual(anyResult1); + }); + + test("call capability errors", () => { const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return false + return false; }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() + 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( @@ -185,36 +206,36 @@ describe('test runtime', () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: 'PerformAction', + method: "PerformAction", }, ), - ) - }) + ); + }); - test('capability errors are returned to the caller', () => { - const anyError = 'error' + test("capability errors are returned to the caller", () => { + const anyError = "error"; const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return true + return true; }), await: mock((request: AwaitCapabilitiesRequest) => { - expect(request.ids.length).toEqual(1) + expect(request.ids.length).toEqual(1); return create(AwaitCapabilitiesResponseSchema, { responses: { [request.ids[0]]: create(CapabilityResponseSchema, { - response: { case: 'error', value: anyError }, + response: { case: "error", value: anyError }, }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() + 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( @@ -222,55 +243,55 @@ describe('test runtime', () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: 'PerformAction', + method: "PerformAction", }, ), - ) - }) + ); + }); - test('await errors', () => { - const anyError = 'error' + test("await errors", () => { + const anyError = "error"; const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return true + return true; }), await: mock((_: AwaitCapabilitiesRequest) => { - throw new Error(anyError) + throw new Error(anyError); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() + 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(anyError, { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: 'PerformAction', + method: "PerformAction", }), - ) - }) + ); + }); - test('await missing response', () => { + test("await missing response", () => { const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return true + return true; }), await: mock((_: AwaitCapabilitiesRequest) => { - return create(AwaitCapabilitiesResponseSchema, { responses: {} }) + return create(AwaitCapabilitiesResponseSchema, { responses: {} }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() + 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( @@ -278,58 +299,61 @@ describe('test runtime', () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: 'PerformAction', + method: "PerformAction", }, ), - ) - }) + ); + }); - test('await throws RESPONSE_BUFFER_TOO_SMALL when response exceeds max size', () => { + 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) + throw new Error(RESPONSE_BUFFER_TOO_SMALL); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() + 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) - }) + expect(() => call1.result()).toThrow(RESPONSE_BUFFER_TOO_SMALL); + }); - test('await returns unparsable payload throws CapabilityError', () => { + 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 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] + const id = request.ids[0]; return create(AwaitCapabilitiesResponseSchema, { responses: { [id]: create(CapabilityResponseSchema, { - response: { case: 'payload', value: corruptPayload as Any }, + response: { case: "payload", value: corruptPayload as Any }, }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const workflowAction1 = new BasicActionCapability() + 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( @@ -337,173 +361,325 @@ describe('test runtime', () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: 'PerformAction', + method: "PerformAction", }, ), - ) - }) - }) -}) + ); + }); + }); +}); -describe('test now converts to date', () => { - test('now converts to date', () => { +describe("test now converts to date", () => { + test("now converts to date", () => { const helpers = createRuntimeHelpersMock({ now: mock(() => 1716153600000), - }) + }); + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const now = runtime.now(); + expect(now).toEqual(new Date(1716153600000)); + }); +}); + +describe("test getSecret", () => { + test("getSecrets returns ordered batched responses", () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock((request) => { + expect(request.callbackId).toEqual(1); + expect(request.requests.length).toEqual(2); + expect(request.requests[0].id).toEqual("secret-1"); + expect(request.requests[1].id).toEqual("secret-2"); + }), + awaitSecrets: mock((request) => { + expect(request.ids.length).toEqual(1); + expect(request.ids[0]).toEqual(1); + return create(AwaitSecretsResponseSchema, { + responses: { + 1: create(SecretResponsesSchema, { + responses: [ + create(SecretResponseSchema, { + response: { + case: "secret", + value: { + id: "secret-1", + namespace: "ns", + owner: "owner-1", + value: "value-1", + }, + }, + }), + create(SecretResponseSchema, { + response: { + case: "secret", + value: { + id: "secret-2", + namespace: "ns", + owner: "owner-2", + value: "value-2", + }, + }, + }), + ], + }), + }, + }); + }), + }); + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const responses = runtime + .getSecrets([ + { id: "secret-1", namespace: "ns" }, + { id: "secret-2", namespace: "ns" }, + ]) + .result(); + + expect(responses.length).toEqual(2); + expect(responses[0].response.case).toEqual("secret"); + expect(responses[1].response.case).toEqual("secret"); + if (responses[0].response.case === "secret") { + expect(responses[0].response.value.id).toEqual("secret-1"); + } + if (responses[1].response.case === "secret") { + expect(responses[1].response.value.id).toEqual("secret-2"); + } + }); + + test("getSecrets returns mixed success and error responses without throwing", () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => undefined), + awaitSecrets: mock(() => { + return create(AwaitSecretsResponseSchema, { + responses: { + 1: create(SecretResponsesSchema, { + responses: [ + create(SecretResponseSchema, { + response: { + case: "secret", + value: { + id: "ok-secret", + namespace: "ns", + owner: "owner", + value: "ok-value", + }, + }, + }), + create(SecretResponseSchema, { + response: { + case: "error", + value: { + id: "missing-secret", + namespace: "ns", + owner: "owner", + error: "secret not found", + }, + }, + }), + ], + }), + }, + }); + }), + }); + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const responses = runtime + .getSecrets([ + { id: "ok-secret", namespace: "ns" }, + { id: "missing-secret", namespace: "ns" }, + ]) + .result(); + + expect(responses.length).toEqual(2); + expect(responses[0].response.case).toEqual("secret"); + expect(responses[1].response.case).toEqual("error"); + }); + + test("getSecrets throws SecretsBatchError when host getSecrets call fails", () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => { + throw new Error("vault: signer unreachable"); + }), + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const now = runtime.now() - expect(now).toEqual(new Date(1716153600000)) - }) -}) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + expect(() => + runtime + .getSecrets([ + { id: "secret-a", namespace: "ns" }, + { id: "secret-b", namespace: "ns" }, + ]) + .result(), + ).toThrow(SecretsBatchError); + }); -describe('test getSecret', () => { - test('successfully gets secret with SecretRequest (proto message)', () => { + test("getSecrets throws SecretsBatchError for malformed batched response envelope", () => { + const helpers = createRuntimeHelpersMock({ + getSecrets: mock(() => undefined), + awaitSecrets: mock(() => + create(AwaitSecretsResponseSchema, { + responses: {}, + }), + ), + }); + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + expect(() => + runtime + .getSecrets([ + { id: "secret-a", namespace: "ns" }, + { id: "secret-b", namespace: "ns" }, + ]) + .result(), + ).toThrow(SecretsBatchError); + }); + + test("successfully gets secret with SecretRequest (proto message)", () => { const secretRequest = create(SecretRequestSchema, { - id: 'my-secret', - namespace: 'test-ns', - }) + id: "my-secret", + namespace: "test-ns", + }); const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - expect(request.callbackId).toEqual(1) - expect(request.requests.length).toEqual(1) + expect(request.callbackId).toEqual(1); + expect(request.requests.length).toEqual(1); }), awaitSecrets: mock((request) => { - expect(request.ids.length).toEqual(1) - expect(request.ids[0]).toEqual(1) + expect(request.ids.length).toEqual(1); + expect(request.ids[0]).toEqual(1); return create(AwaitSecretsResponseSchema, { responses: { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { response: { - case: 'secret', + case: "secret", value: { - id: 'my-secret', - namespace: 'test-ns', - owner: 'test-owner', - value: 'secret-value-123', + id: "my-secret", + namespace: "test-ns", + owner: "test-owner", + value: "secret-value-123", }, }, }), ], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const result = runtime.getSecret(secretRequest).result() - expect(result.id).toEqual('my-secret') - expect(result.namespace).toEqual('test-ns') - expect(result.value).toEqual('secret-value-123') - }) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const result = runtime.getSecret(secretRequest).result(); + expect(result.id).toEqual("my-secret"); + expect(result.namespace).toEqual("test-ns"); + expect(result.value).toEqual("secret-value-123"); + }); - test('successfully gets secret with SecretRequestJson (plain JSON)', () => { - const secretRequestJson = { id: 'another-secret', namespace: 'another-ns' } + test("successfully gets secret with SecretRequestJson (plain JSON)", () => { + const secretRequestJson = { id: "another-secret", namespace: "another-ns" }; const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - expect(request.callbackId).toEqual(1) - expect(request.requests.length).toEqual(1) + expect(request.callbackId).toEqual(1); + expect(request.requests.length).toEqual(1); }), awaitSecrets: mock((request) => { - expect(request.ids.length).toEqual(1) - expect(request.ids[0]).toEqual(1) + expect(request.ids.length).toEqual(1); + expect(request.ids[0]).toEqual(1); return create(AwaitSecretsResponseSchema, { responses: { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { response: { - case: 'secret', + case: "secret", value: { - id: 'another-secret', - namespace: 'another-ns', - owner: 'another-owner', - value: 'value-456', + id: "another-secret", + namespace: "another-ns", + owner: "another-owner", + value: "value-456", }, }, }), ], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - const result = runtime.getSecret(secretRequestJson).result() - expect(result.id).toEqual('another-secret') - expect(result.namespace).toEqual('another-ns') - expect(result.value).toEqual('value-456') - }) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const result = runtime.getSecret(secretRequestJson).result(); + expect(result.id).toEqual("another-secret"); + expect(result.namespace).toEqual("another-ns"); + expect(result.value).toEqual("value-456"); + }); - test('getSecrets throws → wrapped as SecretsError', () => { + test("getSecrets throws → wrapped as SecretsError", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) + id: "test-secret", + namespace: "test-ns", + }); const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => { - throw new Error('vault: signer unreachable') + throw new Error("vault: signer unreachable"); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, 'vault: signer unreachable'), - ) - }) + new SecretsError(secretRequest, "vault: signer unreachable"), + ); + }); - test('awaitSecrets throws → wrapped as SecretsError', () => { + test("awaitSecrets throws → wrapped as SecretsError", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) + id: "test-secret", + namespace: "test-ns", + }); const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), awaitSecrets: mock(() => { - throw new Error('vault: timeout fetching secret') + throw new Error("vault: timeout fetching secret"); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, 'vault: timeout fetching secret'), - ) - }) + new SecretsError(secretRequest, "vault: timeout fetching secret"), + ); + }); - test('awaitSecrets returns no response for callback ID', () => { + test("awaitSecrets returns no response for callback ID", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) + id: "test-secret", + namespace: "test-ns", + }); const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), awaitSecrets: mock(() => { return create(AwaitSecretsResponseSchema, { responses: {}, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, 'no response'), - ) - }) + new SecretsError(secretRequest, "no response"), + ); + }); - test('awaitSecrets returns invalid number of responses', () => { + test("awaitSecrets returns invalid number of responses", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) + id: "test-secret", + namespace: "test-ns", + }); const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -514,27 +690,27 @@ describe('test getSecret', () => { responses: [], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, 'invalid value returned from host'), - ) - }) + new SecretsError(secretRequest, "invalid value returned from host"), + ); + }); - test('awaitSecrets returns too many responses', () => { + test("awaitSecrets returns too many responses", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) + id: "test-secret", + namespace: "test-ns", + }); const secretValue = { - id: 'secret1', - namespace: 'test-ns', - owner: 'test-owner', - value: 'value1', - } + id: "secret1", + namespace: "test-ns", + owner: "test-owner", + value: "value1", + }; const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -544,30 +720,30 @@ describe('test getSecret', () => { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: 'secret', value: secretValue }, + response: { case: "secret", value: secretValue }, }), create(SecretResponseSchema, { - response: { case: 'secret', value: secretValue }, + response: { case: "secret", value: secretValue }, }), ], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, 'invalid value returned from host'), - ) - }) + new SecretsError(secretRequest, "invalid value returned from host"), + ); + }); - test('awaitSecrets returns error response', () => { + test("awaitSecrets returns error response", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) - const errorMessage = 'secret not found' + id: "test-secret", + namespace: "test-ns", + }); + const errorMessage = "secret not found"; const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -577,26 +753,26 @@ describe('test getSecret', () => { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: 'error', value: { error: errorMessage } }, + response: { case: "error", value: { error: errorMessage } }, }), ], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( new SecretsError(secretRequest, errorMessage), - ) - }) + ); + }); - test('awaitSecrets returns unknown response case', () => { + test("awaitSecrets returns unknown response case", () => { const secretRequest = create(SecretRequestSchema, { - id: 'test-secret', - namespace: 'test-ns', - }) + id: "test-secret", + namespace: "test-ns", + }); const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -611,504 +787,582 @@ describe('test getSecret', () => { ], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, 'cannot unmarshal returned value from host'), - ) - }) - - test('getSecret increments callback ID correctly', () => { - const callbackIds: number[] = [] + new SecretsError( + secretRequest, + "cannot unmarshal returned value from host", + ), + ); + }); + + test("getSecret increments callback ID correctly", () => { + const callbackIds: number[] = []; const secretValue = { - id: 'secret', - namespace: 'test-ns', - owner: 'test-owner', - value: 'value', - } + id: "secret", + namespace: "test-ns", + owner: "test-owner", + value: "value", + }; const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - callbackIds.push(request.callbackId) + callbackIds.push(request.callbackId); }), awaitSecrets: mock((request) => { - const id = request.ids[0] + const id = request.ids[0]; return create(AwaitSecretsResponseSchema, { responses: { [id]: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: 'secret', value: secretValue }, + response: { case: "secret", value: secretValue }, }), ], }), }, - }) + }); }), - }) + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - runtime.getSecret({ id: 'secret1', namespace: 'ns1' }).result() - runtime.getSecret({ id: 'secret2', namespace: 'ns2' }).result() - runtime.getSecret({ id: 'secret3', namespace: 'ns3' }).result() + runtime.getSecret({ id: "secret1", namespace: "ns1" }).result(); + runtime.getSecret({ id: "secret2", namespace: "ns2" }).result(); + runtime.getSecret({ id: "secret3", namespace: "ns3" }).result(); - expect(callbackIds).toEqual([1, 2, 3]) - }) + expect(callbackIds).toEqual([1, 2, 3]); + }); - test('getSecret in node mode throws DonModeError', () => { - const helpers = createRuntimeHelpersMock() + test("getSecret in node mode throws DonModeError", () => { + const helpers = createRuntimeHelpersMock(); ConsensusCapability.prototype.simple = mock(() => { - return { result: () => Value.from(0).proto() } - }) + return { result: () => Value.from(0).proto() }; + }); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - let capturedError: Error | undefined + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + let capturedError: Error | undefined; runtime.runInNodeMode((_nodeRuntime: NodeRuntime) => { // Try to call getSecret from within node mode (should fail) try { - runtime.getSecret({ id: 'test', namespace: 'test-ns' }).result() + runtime.getSecret({ id: "test", namespace: "test-ns" }).result(); } catch (e) { - capturedError = e as Error + capturedError = e as Error; } - return 0 - }, consensusMedianAggregation())() - - expect(capturedError).toBeDefined() - expect(capturedError).toBeInstanceOf(DonModeError) - }) -}) - -describe('test run in node mode', () => { - test('successful consensus', () => { - const anyObservation = 10 - const anyMedian = 11 - const modes: Mode[] = [] + return 0; + }, consensusMedianAggregation())(); + + expect(capturedError).toBeDefined(); + expect(capturedError).toBeInstanceOf(DonModeError); + }); +}); + +describe("test run in node mode", () => { + test("successful consensus", () => { + const anyObservation = 10; + const anyMedian = 11; + const modes: Mode[] = []; const helpers = createRuntimeHelpersMock({ switchModes: mock((mode: Mode) => { - modes.push(mode) + modes.push(mode); }), - }) + }); ConsensusCapability.prototype.simple = mock( - (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { - expect(modes).toEqual([Mode.DON, Mode.NODE, Mode.DON]) - expect(inputs.default).toBeUndefined() + ( + _: Runtime, + inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, + ) => { + expect(modes).toEqual([Mode.DON, Mode.NODE, Mode.DON]); + expect(inputs.default).toBeUndefined(); const consensusDescriptor = create(ConsensusDescriptorSchema, { descriptor: { - case: 'fieldsMap', + case: "fieldsMap", value: create(FieldsMapSchema, { fields: { outputThing: create(ConsensusDescriptorSchema, { descriptor: { - case: 'aggregation', + case: "aggregation", value: AggregationType.MEDIAN, }, }), }, }), }, - }) - expect(inputs.descriptors).toEqual(consensusDescriptor) - expect((inputs as { $typeName?: string }).$typeName).not.toBeUndefined() - const inputsProto = inputs as SimpleConsensusInputs - expect(inputsProto.observation.case).toEqual('value') + }); + expect(inputs.descriptors).toEqual(consensusDescriptor); + expect( + (inputs as { $typeName?: string }).$typeName, + ).not.toBeUndefined(); + const inputsProto = inputs as SimpleConsensusInputs; + expect(inputsProto.observation.case).toEqual("value"); expect( Value.wrap(inputsProto.observation.value as ProtoValue).unwrapToType({ factory: () => create(NodeOutputsSchema), }).outputThing, - ).toEqual(anyObservation) + ).toEqual(anyObservation); return { - result: () => Value.from(create(NodeOutputsSchema, { outputThing: anyMedian })).proto(), - } + result: () => + Value.from( + create(NodeOutputsSchema, { outputThing: anyMedian }), + ).proto(), + }; }, - ) + ); // Create a mock that handles both overloads properly - const performActionMock = function (this: NodeActionCapability, ...args: unknown[]): unknown { + const performActionMock = function ( + this: NodeActionCapability, + ...args: unknown[] + ): unknown { // Check if this is the sugar syntax overload (has function parameter) - if (typeof args[0] === 'function') { + if (typeof args[0] === "function") { // This test doesn't expect sugar syntax to be used - throw new Error('Sugar syntax should not be used in this test') + throw new Error("Sugar syntax should not be used in this test"); } // Otherwise, this is the basic call overload - const [_, __] = args as [NodeRuntime, NodeInputs | NodeInputsJson] - expect(modes).toEqual([Mode.DON, Mode.NODE]) + const [_, __] = args as [ + NodeRuntime, + NodeInputs | NodeInputsJson, + ]; + expect(modes).toEqual([Mode.DON, Mode.NODE]); return { - result: () => create(NodeOutputsSchema, { outputThing: anyObservation }), - } - } + result: () => + create(NodeOutputsSchema, { outputThing: anyObservation }), + }; + }; // Apply the mock with proper typing // biome-ignore lint/suspicious/noExplicitAny: Mock assignment requires any due to overloaded function signature - ;(NodeActionCapability.prototype as any).performAction = mock(performActionMock) + (NodeActionCapability.prototype as any).performAction = + mock(performActionMock); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); const result = runtime .runInNodeMode( (nodeRuntime: NodeRuntime) => { - const capability = new NodeActionCapability() + const capability = new NodeActionCapability(); return capability - .performAction(nodeRuntime, create(NodeInputsSchema, { inputThing: true })) - .result() + .performAction( + nodeRuntime, + create(NodeInputsSchema, { inputThing: true }), + ) + .result(); }, ConsensusAggregationByFields({ outputThing: median }), )() - .result() + .result(); - expect(result.outputThing).toEqual(anyMedian) - }) + expect(result.outputThing).toEqual(anyMedian); + }); - test('failed consensus', () => { - const anyError = 'error' + test("failed consensus", () => { + const anyError = "error"; const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), - }) + }); ConsensusCapability.prototype.simple = mock( - (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { - expect(inputs.default).toBeUndefined() + ( + _: Runtime, + inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, + ) => { + expect(inputs.default).toBeUndefined(); expect(inputs.descriptors).toEqual( create(ConsensusDescriptorSchema, { - descriptor: { case: 'aggregation', value: AggregationType.MEDIAN }, + descriptor: { case: "aggregation", value: AggregationType.MEDIAN }, }), - ) - expect((inputs as { $typeName?: string }).$typeName).not.toBeUndefined() - const inputsProto = inputs as SimpleConsensusInputs - expect(inputsProto.observation.case).toEqual('error') - expect(inputsProto.observation.value).toEqual(anyError) + ); + expect( + (inputs as { $typeName?: string }).$typeName, + ).not.toBeUndefined(); + const inputsProto = inputs as SimpleConsensusInputs; + expect(inputsProto.observation.case).toEqual("error"); + expect(inputsProto.observation.value).toEqual(anyError); return { result: () => { - throw new Error(anyError) + throw new Error(anyError); }, - } + }; }, - ) + ); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); const result = runtime.runInNodeMode((_: NodeRuntime) => { - throw new Error(anyError) - }, consensusMedianAggregation())() - expect(() => result.result()).toThrow(new Error(anyError)) - }) - - test('primitive consensus with unused default returns observation value', () => { - const observationValue = 99 - const defaultValue = 100 + throw new Error(anyError); + }, consensusMedianAggregation())(); + 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) + ( + _: 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 runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); const result = runtime .runInNodeMode( (_: NodeRuntime) => observationValue, consensusMedianAggregation().withDefault(defaultValue), )() - .result() + .result(); - expect(result).toEqual(observationValue) - }) + expect(result).toEqual(observationValue); + }); - test('primitive consensus with used default returns default when function errors', () => { - const defaultVal = 100 - const anyError = 'error' + 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) + ( + _: 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 runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); const result = runtime .runInNodeMode((_: NodeRuntime) => { - throw new Error(anyError) + throw new Error(anyError); }, consensusMedianAggregation().withDefault(defaultVal))() - .result() + .result(); - expect(result).toEqual(defaultVal) - }) + expect(result).toEqual(defaultVal); + }); - test('node runtime in don mode fails', () => { + test("node runtime in don mode fails", () => { const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), call: mock((_: CapabilityRequest) => { - expect(false).toBe(true) - return false + expect(false).toBe(true); + return false; }), - }) + }); ConsensusCapability.prototype.simple = mock( - (_: Runtime, __: SimpleConsensusInputs | SimpleConsensusInputsJson) => { - return { result: () => Value.from(0).proto() } + ( + _: Runtime, + __: SimpleConsensusInputs | SimpleConsensusInputsJson, + ) => { + return { result: () => Value.from(0).proto() }; }, - ) + ); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - var nrt: NodeRuntime | undefined + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + var nrt: NodeRuntime | undefined; runtime.runInNodeMode((nodeRuntime: NodeRuntime) => { - nrt = nodeRuntime - return 0 - }, consensusMedianAggregation())() + nrt = nodeRuntime; + return 0; + }, consensusMedianAggregation())(); - const capability = new NodeActionCapability() - expect(nrt).toBeDefined() + const capability = new NodeActionCapability(); + expect(nrt).toBeDefined(); expect(() => capability - .performAction(nrt as NodeRuntime, create(NodeInputsSchema, { inputThing: true })) + .performAction( + nrt as NodeRuntime, + create(NodeInputsSchema, { inputThing: true }), + ) .result(), - ).toThrow(new NodeModeError()) - }) + ).toThrow(new NodeModeError()); + }); - test('don runtime in node mode fails', () => { + test("don runtime in node mode fails", () => { const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), - }) + }); ConsensusCapability.prototype.simple = mock( - (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { - expect(inputs.default).toBeUndefined() + ( + _: Runtime, + inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, + ) => { + expect(inputs.default).toBeUndefined(); expect(inputs.descriptors).toEqual( create(ConsensusDescriptorSchema, { - descriptor: { case: 'aggregation', value: AggregationType.MEDIAN }, + descriptor: { case: "aggregation", value: AggregationType.MEDIAN }, }), - ) - expect((inputs as { $typeName?: string }).$typeName).not.toBeUndefined() - const inputsProto = inputs as SimpleConsensusInputs - expect(inputsProto.observation.case).toEqual('error') - expect(inputsProto.observation.value).toEqual(new DonModeError().message) + ); + expect( + (inputs as { $typeName?: string }).$typeName, + ).not.toBeUndefined(); + const inputsProto = inputs as SimpleConsensusInputs; + expect(inputsProto.observation.case).toEqual("error"); + expect(inputsProto.observation.value).toEqual( + new DonModeError().message, + ); return { result: () => { - throw new DonModeError() + throw new DonModeError(); }, - } + }; }, - ) + ); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); const result = runtime.runInNodeMode((_: NodeRuntime) => { - const capability = new BasicActionCapability() - capability.performAction(runtime, create(InputsSchema, { inputThing: true })).result() - return 0 - }, consensusMedianAggregation())() - expect(() => result.result()).toThrow(new DonModeError()) - }) - - test('multiple runInNodeMode calls have unique callback IDs', () => { - const callbackIds: number[] = [] + const capability = new BasicActionCapability(); + capability + .performAction(runtime, create(InputsSchema, { inputThing: true })) + .result(); + return 0; + }, consensusMedianAggregation())(); + expect(() => result.result()).toThrow(new DonModeError()); + }); + + test("multiple runInNodeMode calls have unique callback IDs", () => { + const callbackIds: number[] = []; const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), call: mock((request: CapabilityRequest) => { - callbackIds.push(request.callbackId) - return true + callbackIds.push(request.callbackId); + return true; }), await: mock((request: AwaitCapabilitiesRequest) => { - const id = request.ids[0] + const id = request.ids[0]; return create(AwaitCapabilitiesResponseSchema, { responses: { [id]: create(CapabilityResponseSchema, { response: { - case: 'payload', - value: anyPack(NodeOutputsSchema, create(NodeOutputsSchema, { outputThing: 42 })), + case: "payload", + value: anyPack( + NodeOutputsSchema, + create(NodeOutputsSchema, { outputThing: 42 }), + ), }, }), }, - }) + }); }), - }) + }); ConsensusCapability.prototype.simple = mock( - (_: Runtime, __: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + ( + _: Runtime, + __: SimpleConsensusInputs | SimpleConsensusInputsJson, + ) => { return { - result: () => Value.from(create(NodeOutputsSchema, { outputThing: 42 })).proto(), - } + result: () => + Value.from(create(NodeOutputsSchema, { outputThing: 42 })).proto(), + }; }, - ) + ); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); // First runInNodeMode call with capability inside const call1 = runtime.runInNodeMode( (nodeRuntime: NodeRuntime) => { - const capability = new NodeActionCapability() + const capability = new NodeActionCapability(); return capability - .performAction(nodeRuntime, create(NodeInputsSchema, { inputThing: true })) - .result() + .performAction( + nodeRuntime, + create(NodeInputsSchema, { inputThing: true }), + ) + .result(); }, ConsensusAggregationByFields({ outputThing: median }), - ) + ); - call1().result() + call1().result(); // Second runInNodeMode call with capability inside const call2 = runtime.runInNodeMode( (nodeRuntime: NodeRuntime) => { - const capability = new NodeActionCapability() + const capability = new NodeActionCapability(); return capability - .performAction(nodeRuntime, create(NodeInputsSchema, { inputThing: false })) - .result() + .performAction( + nodeRuntime, + create(NodeInputsSchema, { inputThing: false }), + ) + .result(); }, ConsensusAggregationByFields({ outputThing: median }), - ) + ); - call2().result() + call2().result(); // Verify that we have two distinct callback IDs - expect(callbackIds.length).toEqual(2) - expect(callbackIds[0]).toEqual(-1) // First node mode call - expect(callbackIds[1]).toEqual(-2) // Second node mode call + expect(callbackIds.length).toEqual(2); + expect(callbackIds[0]).toEqual(-1); // First node mode call + expect(callbackIds[1]).toEqual(-2); // Second node mode call // Ensure they are different (no reuse/collision) - expect(callbackIds[0]).not.toEqual(callbackIds[1]) - }) + expect(callbackIds[0]).not.toEqual(callbackIds[1]); + }); - test('clears ignored fields from default and response values', () => { + test("clears ignored fields from default and response values", () => { type NestedStruct = { - nestedIncluded: string - nestedIgnored: string - } + nestedIncluded: string; + nestedIgnored: string; + }; type TestStruct = { - includedField: string - ignoredField: string - nested: NestedStruct - } + includedField: string; + ignoredField: string; + nested: NestedStruct; + }; const defaultVal: TestStruct = { - includedField: 'default_included', - ignoredField: 'default_ignored', + includedField: "default_included", + ignoredField: "default_ignored", nested: { - nestedIncluded: 'default_nested_included', - nestedIgnored: 'default_nested_ignored', + nestedIncluded: "default_nested_included", + nestedIgnored: "default_nested_ignored", }, - } + }; const responseVal: TestStruct = { - includedField: 'response_included', - ignoredField: 'response_ignored', + includedField: "response_included", + ignoredField: "response_ignored", nested: { - nestedIncluded: 'response_nested_included', - nestedIgnored: 'response_nested_ignored', + nestedIncluded: "response_nested_included", + nestedIgnored: "response_nested_ignored", }, - } + }; const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), - }) + }); ConsensusCapability.prototype.simple = mock( - (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { - const inputsProto = inputs as SimpleConsensusInputs - if (inputsProto.observation.case === 'value') { + ( + _: Runtime, + inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, + ) => { + const inputsProto = inputs as SimpleConsensusInputs; + if (inputsProto.observation.case === "value") { const unwrapped = Value.wrap( inputsProto.observation.value as ProtoValue, - ).unwrap() as TestStruct - expect(unwrapped.includedField).toEqual('response_included') - expect(unwrapped.ignoredField).toBeUndefined() - expect(unwrapped.nested.nestedIncluded).toEqual('response_nested_included') - expect(unwrapped.nested.nestedIgnored).toBeUndefined() + ).unwrap() as TestStruct; + expect(unwrapped.includedField).toEqual("response_included"); + expect(unwrapped.ignoredField).toBeUndefined(); + expect(unwrapped.nested.nestedIncluded).toEqual( + "response_nested_included", + ); + expect(unwrapped.nested.nestedIgnored).toBeUndefined(); return { result: () => inputsProto.observation.value as ProtoValue, - } + }; } if (inputsProto.default) { - const unwrapped = Value.wrap(inputsProto.default as ProtoValue).unwrap() as TestStruct - expect(unwrapped.includedField).toEqual('default_included') - expect(unwrapped.ignoredField).toBeUndefined() - expect(unwrapped.nested.nestedIncluded).toEqual('default_nested_included') - expect(unwrapped.nested.nestedIgnored).toBeUndefined() + const unwrapped = Value.wrap( + inputsProto.default as ProtoValue, + ).unwrap() as TestStruct; + expect(unwrapped.includedField).toEqual("default_included"); + expect(unwrapped.ignoredField).toBeUndefined(); + expect(unwrapped.nested.nestedIncluded).toEqual( + "default_nested_included", + ); + expect(unwrapped.nested.nestedIgnored).toBeUndefined(); return { result: () => inputsProto.default as ProtoValue, - } + }; } - if (inputsProto.observation.case === 'error') { + if (inputsProto.observation.case === "error") { return { result: () => { - throw new Error(inputsProto.observation.value as string) + throw new Error(inputsProto.observation.value as string); }, - } + }; } return { result: () => { - throw new Error('unexpected case') + throw new Error("unexpected case"); }, - } + }; }, - ) + ); - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); const nestedAggregation = ConsensusAggregationByFields({ nestedIncluded: identical, nestedIgnored: ignore, - }) + }); const result = runtime .runInNodeMode( (_nodeRuntime: NodeRuntime) => { - return responseVal + return responseVal; }, ConsensusAggregationByFields({ includedField: identical, ignoredField: ignore, nested: () => - new ConsensusFieldAggregation(nestedAggregation.descriptor), + new ConsensusFieldAggregation( + nestedAggregation.descriptor, + ), }).withDefault(defaultVal), )() - .result() + .result(); - expect(result.includedField).toEqual('response_included') - expect(result.ignoredField).toBeUndefined() - expect(result.nested.nestedIncluded).toEqual('response_nested_included') - expect(result.nested.nestedIgnored).toBeUndefined() + expect(result.includedField).toEqual("response_included"); + expect(result.ignoredField).toBeUndefined(); + expect(result.nested.nestedIncluded).toEqual("response_nested_included"); + expect(result.nested.nestedIgnored).toBeUndefined(); const result2 = runtime .runInNodeMode( (_nodeRuntime: NodeRuntime) => { - throw new Error('error') + throw new Error("error"); }, ConsensusAggregationByFields({ includedField: identical, ignoredField: ignore, nested: () => - new ConsensusFieldAggregation(nestedAggregation.descriptor), + new ConsensusFieldAggregation( + nestedAggregation.descriptor, + ), }).withDefault(defaultVal), )() - .result() - - expect(result2.includedField).toEqual('default_included') - expect(result2.ignoredField).toBeUndefined() - expect(result2.nested.nestedIncluded).toEqual('default_nested_included') - expect(result2.nested.nestedIgnored).toBeUndefined() - }) -}) + .result(); + + expect(result2.includedField).toEqual("default_included"); + expect(result2.ignoredField).toBeUndefined(); + expect(result2.nested.nestedIncluded).toEqual("default_nested_included"); + expect(result2.nested.nestedIgnored).toBeUndefined(); + }); +}); diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts index 6e016899..0b5690fd 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts @@ -1,6 +1,6 @@ -import { create, type Message } from '@bufbuild/protobuf' -import type { GenMessage } from '@bufbuild/protobuf/codegenv2' -import { type Any, anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' +import { create, type Message } from "@bufbuild/protobuf"; +import type { GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { type Any, anyPack, anyUnpack } from "@bufbuild/protobuf/wkt"; import { type AwaitCapabilitiesRequest, AwaitCapabilitiesRequestSchema, @@ -18,10 +18,11 @@ import { type SecretRequest, type SecretRequestJson, SecretRequestSchema, + type SecretResponse, SimpleConsensusInputsSchema, -} from '@cre/generated/sdk/v1alpha/sdk_pb' -import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' -import { ConsensusCapability } from '@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen' +} from "@cre/generated/sdk/v1alpha/sdk_pb"; +import type { Value as ProtoValue } from "@cre/generated/values/v1/values_pb"; +import { ConsensusCapability } from "@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen"; import type { BaseRuntime, CallCapabilityParams, @@ -29,17 +30,22 @@ import type { ReportRequest, ReportRequestJson, Runtime, -} from '@cre/sdk' -import type { Report } from '@cre/sdk/report' +} from "@cre/sdk"; +import type { Report } from "@cre/sdk/report"; import { type ConsensusAggregation, type CreSerializable, type PrimitiveTypes, type UnwrapOptions, Value, -} from '@cre/sdk/utils' -import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' -import { DonModeError, NodeModeError, SecretsError } from '../errors' +} from "@cre/sdk/utils"; +import { CapabilityError } from "@cre/sdk/utils/capabilities/capability-error"; +import { + DonModeError, + NodeModeError, + SecretsBatchError, + SecretsError, +} from "../errors"; /** * Base implementation shared by DON and Node runtimes. @@ -55,7 +61,7 @@ export class BaseRuntimeImpl implements BaseRuntime { * - Set in DON mode when code tries to use NodeRuntime * - Set in Node mode when code tries to use Runtime */ - public modeError?: Error + public modeError?: Error; constructor( public config: C, @@ -80,22 +86,22 @@ export class BaseRuntimeImpl implements BaseRuntime { if (this.modeError) { return { result: () => { - throw this.modeError + throw this.modeError; }, - } + }; } // Allocate unique callback ID for this request - const callbackId = this.allocateCallbackId() + const callbackId = this.allocateCallbackId(); // Send request to WASM host - const anyPayload = anyPack(inputSchema, payload) + const anyPayload = anyPack(inputSchema, payload); const req = create(CapabilityRequestSchema, { id: capabilityId, method, payload: anyPayload, callbackId, - }) + }); if (!this.helpers.call(req)) { return { @@ -107,16 +113,21 @@ export class BaseRuntimeImpl implements BaseRuntime { method, capabilityId, }, - ) + ); }, - } + }; } // Return lazy result - await and unwrap when .result() is called return { result: () => - this.awaitAndUnwrapCapabilityResponse(callbackId, capabilityId, method, outputSchema), - } + this.awaitAndUnwrapCapabilityResponse( + callbackId, + capabilityId, + method, + outputSchema, + ), + }; } /** @@ -124,13 +135,13 @@ export class BaseRuntimeImpl implements BaseRuntime { * DON mode increments, Node mode decrements (prevents collisions). */ private allocateCallbackId(): number { - const callbackId = this.nextCallId + const callbackId = this.nextCallId; if (this.mode === Mode.DON) { - this.nextCallId++ + this.nextCallId++; } else { - this.nextCallId-- + this.nextCallId--; } - return callbackId + return callbackId; } /** @@ -144,9 +155,12 @@ export class BaseRuntimeImpl implements BaseRuntime { ): O { const awaitRequest = create(AwaitCapabilitiesRequestSchema, { ids: [callbackId], - }) - const awaitResponse = this.helpers.await(awaitRequest, this.maxResponseSize) - const capabilityResponse = awaitResponse.responses[callbackId] + }); + const awaitResponse = this.helpers.await( + awaitRequest, + this.maxResponseSize, + ); + const capabilityResponse = awaitResponse.responses[callbackId]; if (!capabilityResponse) { throw new CapabilityError( @@ -156,14 +170,14 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ) + ); } - const response = capabilityResponse.response + const response = capabilityResponse.response; switch (response.case) { - case 'payload': { + case "payload": { try { - return anyUnpack(response.value as Any, outputSchema) as O + return anyUnpack(response.value as Any, outputSchema) as O; } catch { throw new CapabilityError( `Failed to deserialize response payload for capability '${capabilityId}' method '${method}': the response could not be unpacked into the expected output schema`, @@ -172,10 +186,10 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ) + ); } } - case 'error': + case "error": throw new CapabilityError( `Capability '${capabilityId}' method '${method}' returned an error: ${response.value}`, { @@ -183,7 +197,7 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ) + ); default: throw new CapabilityError( `Unexpected response type '${response.case}' for capability '${capabilityId}' method '${method}': expected 'payload' or 'error'`, @@ -192,21 +206,21 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ) + ); } } getNextCallId(): number { - return this.nextCallId + return this.nextCallId; } now(): Date { // date is already in milliseconds - return new Date(this.helpers.now()) + return new Date(this.helpers.now()); } log(message: string): void { - this.helpers.log(message) + this.helpers.log(message); } } @@ -218,12 +232,20 @@ export class BaseRuntimeImpl implements BaseRuntime { * Useful in situation where you already expect non-determinism (e.g., inherently variable HTTP responses). * Switching from Node Mode back to DON mode requires workflow authors to handle consensus themselves. */ -export class NodeRuntimeImpl extends BaseRuntimeImpl implements NodeRuntime { - _isNodeRuntime: true = true +export class NodeRuntimeImpl + extends BaseRuntimeImpl + implements NodeRuntime +{ + _isNodeRuntime: true = true; - constructor(config: C, nextCallId: number, helpers: RuntimeHelpers, maxResponseSize: bigint) { - helpers.switchModes(Mode.NODE) - super(config, nextCallId, helpers, maxResponseSize, Mode.NODE) + constructor( + config: C, + nextCallId: number, + helpers: RuntimeHelpers, + maxResponseSize: bigint, + ) { + helpers.switchModes(Mode.NODE); + super(config, nextCallId, helpers, maxResponseSize, Mode.NODE); } } @@ -232,11 +254,16 @@ export class NodeRuntimeImpl extends BaseRuntimeImpl implements NodeRuntim * You ask the network to execute something, and CRE handles the underlying complexity to ensure you get back one final, secure, and trustworthy result. */ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { - private nextNodeCallId: number = -1 + private nextNodeCallId: number = -1; - constructor(config: C, nextCallId: number, helpers: RuntimeHelpers, maxResponseSize: bigint) { - helpers.switchModes(Mode.DON) - super(config, nextCallId, helpers, maxResponseSize, Mode.DON) + constructor( + config: C, + nextCallId: number, + helpers: RuntimeHelpers, + maxResponseSize: bigint, + ) { + helpers.switchModes(Mode.DON); + super(config, nextCallId, helpers, maxResponseSize, Mode.DON); } /** @@ -253,35 +280,41 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { runInNodeMode( fn: (nodeRuntime: NodeRuntime, ...args: TArgs) => TOutput, consensusAggregation: ConsensusAggregation, - unwrapOptions?: TOutput extends PrimitiveTypes ? never : UnwrapOptions, + unwrapOptions?: TOutput extends PrimitiveTypes + ? never + : UnwrapOptions, ): (...args: TArgs) => { result: () => TOutput } { return (...args: TArgs): { result: () => TOutput } => { // Step 1: Create node runtime and prevent DON operations - this.modeError = new DonModeError() + this.modeError = new DonModeError(); const nodeRuntime = new NodeRuntimeImpl( this.config, this.nextNodeCallId, this.helpers, this.maxResponseSize, - ) + ); // Step 2: Prepare consensus input with config - const consensusInput = this.prepareConsensusInput(consensusAggregation) + const consensusInput = this.prepareConsensusInput(consensusAggregation); // Step 3: Execute node function and capture result/error try { - const observation = fn(nodeRuntime, ...args) - this.captureObservation(consensusInput, observation, consensusAggregation.descriptor) + const observation = fn(nodeRuntime, ...args); + this.captureObservation( + consensusInput, + observation, + consensusAggregation.descriptor, + ); } catch (e: unknown) { - this.captureError(consensusInput, e) + this.captureError(consensusInput, e); } finally { // Step 4: Always restore DON mode - this.restoreDonMode(nodeRuntime) + this.restoreDonMode(nodeRuntime); } // Step 5: Run consensus and return lazy result - return this.runConsensusAndWrap(consensusInput, unwrapOptions) - } + return this.runConsensusAndWrap(consensusInput, unwrapOptions); + }; } private prepareConsensusInput( @@ -289,18 +322,18 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { ) { const consensusInput = create(SimpleConsensusInputsSchema, { descriptors: consensusAggregation.descriptor, - }) + }); if (consensusAggregation.defaultValue) { // Safe cast: ConsensusAggregation implies T extends CreSerializable const defaultValue = Value.from( consensusAggregation.defaultValue as CreSerializable, - ).proto() - clearIgnoredFields(defaultValue, consensusAggregation.descriptor) - consensusInput.default = defaultValue + ).proto(); + clearIgnoredFields(defaultValue, consensusAggregation.descriptor); + consensusInput.default = defaultValue; } - return consensusInput + return consensusInput; } private captureObservation( @@ -309,117 +342,173 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { descriptor: ConsensusDescriptor, ) { // Safe cast: ConsensusAggregation implies T extends CreSerializable - const observationValue = Value.from(observation as CreSerializable).proto() - clearIgnoredFields(observationValue, descriptor) + const observationValue = Value.from( + observation as CreSerializable, + ).proto(); + clearIgnoredFields(observationValue, descriptor); consensusInput.observation = { - case: 'value', + case: "value", value: observationValue, - } + }; } private captureError(consensusInput: any, e: unknown) { consensusInput.observation = { - case: 'error', + case: "error", value: (e instanceof Error && e.message) || String(e), - } + }; } private restoreDonMode(nodeRuntime: NodeRuntimeImpl) { - this.modeError = undefined - this.nextNodeCallId = nodeRuntime.nextCallId - nodeRuntime.modeError = new NodeModeError() - this.helpers.switchModes(Mode.DON) + this.modeError = undefined; + this.nextNodeCallId = nodeRuntime.nextCallId; + nodeRuntime.modeError = new NodeModeError(); + this.helpers.switchModes(Mode.DON); } private runConsensusAndWrap( consensusInput: any, - unwrapOptions?: TOutput extends PrimitiveTypes ? never : UnwrapOptions, + unwrapOptions?: TOutput extends PrimitiveTypes + ? never + : UnwrapOptions, ): { result: () => TOutput } { - const consensus = new ConsensusCapability() - const call = consensus.simple(this, consensusInput) + const consensus = new ConsensusCapability(); + const call = consensus.simple(this, consensusInput); return { result: () => { - const result = call.result() - const wrappedValue = Value.wrap(result) + const result = call.result(); + const wrappedValue = Value.wrap(result); return unwrapOptions ? wrappedValue.unwrapToType(unwrapOptions) - : (wrappedValue.unwrap() as TOutput) + : (wrappedValue.unwrap() as TOutput); }, - } + }; } - getSecret(request: SecretRequest | SecretRequestJson): { - result: () => Secret + getSecrets(requests: Array): { + result: () => SecretResponse[]; } { // Enforce mode restrictions if (this.modeError) { return { result: () => { - throw this.modeError + throw this.modeError; }, - } + }; } - // Normalize request (accept both protobuf and JSON formats) - const secretRequest = (request as unknown as { $typeName?: string }).$typeName - ? (request as SecretRequest) - : create(SecretRequestSchema, request) + // Normalize requests (accept both protobuf and JSON formats) + const normalizedRequests = requests.map((request) => + (request as unknown as { $typeName?: string }).$typeName + ? (request as SecretRequest) + : create(SecretRequestSchema, request), + ); + if (normalizedRequests.length === 0) { + return { + result: () => [], + }; + } // Allocate callback ID and send request - const id = this.nextCallId - this.nextCallId++ + const id = this.nextCallId; + this.nextCallId++; const secretsReq = create(GetSecretsRequestSchema, { callbackId: id, - requests: [secretRequest], - }) + requests: normalizedRequests, + }); try { - this.helpers.getSecrets(secretsReq, this.maxResponseSize) + this.helpers.getSecrets(secretsReq, this.maxResponseSize); } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = err instanceof Error ? err.message : String(err); return { result: () => { - throw new SecretsError(secretRequest, message) + throw new SecretsBatchError(normalizedRequests, message); }, - } + }; } // Return lazy result return { - result: () => this.awaitAndUnwrapSecret(id, secretRequest), - } + result: () => this.awaitAndUnwrapSecrets(id, normalizedRequests), + }; + } + + getSecret(request: SecretRequest | SecretRequestJson): { + result: () => Secret; + } { + const secretRequest = (request as unknown as { $typeName?: string }) + .$typeName + ? (request as SecretRequest) + : create(SecretRequestSchema, request); + + const getSecretsCall = this.getSecrets([secretRequest]); + return { + result: () => { + let responseList: SecretResponse[]; + try { + responseList = getSecretsCall.result(); + } catch (err) { + if (err instanceof SecretsBatchError) { + throw new SecretsError(secretRequest, err.error); + } + throw err; + } + + return this.unwrapSingleSecretResult(responseList, secretRequest); + }, + }; } - private awaitAndUnwrapSecret(id: number, secretRequest: SecretRequest): Secret { - const awaitRequest = create(AwaitSecretsRequestSchema, { ids: [id] }) - let awaitResponse: AwaitSecretsResponse + private awaitAndUnwrapSecrets( + id: number, + requests: SecretRequest[], + ): SecretResponse[] { + const awaitRequest = create(AwaitSecretsRequestSchema, { ids: [id] }); + let awaitResponse: AwaitSecretsResponse; try { - awaitResponse = this.helpers.awaitSecrets(awaitRequest, this.maxResponseSize) + awaitResponse = this.helpers.awaitSecrets( + awaitRequest, + this.maxResponseSize, + ); } catch (err) { - const message = err instanceof Error ? err.message : String(err) - throw new SecretsError(secretRequest, message) + const message = err instanceof Error ? err.message : String(err); + throw new SecretsBatchError(requests, message); } - const secretsResponse = awaitResponse.responses[id] + const secretsResponse = awaitResponse.responses[id]; if (!secretsResponse) { - throw new SecretsError(secretRequest, 'no response') + throw new SecretsBatchError(requests, "no response"); } - const responses = secretsResponse.responses - if (responses.length !== 1) { - throw new SecretsError(secretRequest, 'invalid value returned from host') + if (secretsResponse.responses.length !== requests.length) { + throw new SecretsBatchError(requests, "invalid value returned from host"); } - const response = responses[0].response + return secretsResponse.responses; + } + + private unwrapSingleSecretResult( + responseList: SecretResponse[], + request: SecretRequest, + ): Secret { + if (responseList.length !== 1) { + throw new SecretsError(request, "invalid value returned from host"); + } + + const response = responseList[0].response; switch (response.case) { - case 'secret': - return response.value - case 'error': - throw new SecretsError(secretRequest, response.value.error) + case "secret": + return response.value; + case "error": + throw new SecretsError(request, response.value.error); default: - throw new SecretsError(secretRequest, 'cannot unmarshal returned value from host') + throw new SecretsError( + request, + "cannot unmarshal returned value from host", + ); } } @@ -427,11 +516,11 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { * Generates a report via consensus mechanism. */ report(input: ReportRequest | ReportRequestJson): { result: () => Report } { - const consensus = new ConsensusCapability() - const call = consensus.report(this, input) + const consensus = new ConsensusCapability(); + const call = consensus.report(this, input); return { result: () => call.result(), - } + }; } } @@ -441,57 +530,68 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { */ export interface RuntimeHelpers { /** Initiates a capability call. Returns false if capability not found. */ - call(request: CapabilityRequest): boolean + call(request: CapabilityRequest): boolean; /** Awaits capability responses. Blocks until responses are ready. */ - await(request: AwaitCapabilitiesRequest, maxResponseSize: bigint): AwaitCapabilitiesResponse + await( + request: AwaitCapabilitiesRequest, + maxResponseSize: bigint, + ): AwaitCapabilitiesResponse; /** Requests secrets from host. Throws if host rejects the request. */ - getSecrets(request: GetSecretsRequest, maxResponseSize: bigint): void + getSecrets(request: GetSecretsRequest, maxResponseSize: bigint): void; /** Awaits secret responses. Blocks until secrets are ready. */ - awaitSecrets(request: AwaitSecretsRequest, maxResponseSize: bigint): AwaitSecretsResponse + awaitSecrets( + request: AwaitSecretsRequest, + maxResponseSize: bigint, + ): AwaitSecretsResponse; /** Switches execution mode (DON vs Node). Affects available operations. */ - switchModes(mode: Mode): void + switchModes(mode: Mode): void; /** Returns current time in milliseconds since Unix epoch. */ - now(): number + now(): number; /** Logs a message to the host environment. */ - log(message: string): void + log(message: string): void; } -function clearIgnoredFields(value: ProtoValue, descriptor: ConsensusDescriptor): void { +function clearIgnoredFields( + value: ProtoValue, + descriptor: ConsensusDescriptor, +): void { if (!descriptor || !value) { - return + return; } const fieldsMap = - descriptor.descriptor?.case === 'fieldsMap' ? descriptor.descriptor.value : undefined + descriptor.descriptor?.case === "fieldsMap" + ? descriptor.descriptor.value + : undefined; if (!fieldsMap) { - return + return; } - if (value.value?.case === 'mapValue') { - const mapValue = value.value.value + if (value.value?.case === "mapValue") { + const mapValue = value.value.value; if (!mapValue || !mapValue.fields) { - return + return; } for (const [key, val] of Object.entries(mapValue.fields)) { - const nestedDescriptor = fieldsMap.fields[key] + const nestedDescriptor = fieldsMap.fields[key]; if (!nestedDescriptor) { - delete mapValue.fields[key] - continue + delete mapValue.fields[key]; + continue; } const nestedFieldsMap = - nestedDescriptor.descriptor?.case === 'fieldsMap' + nestedDescriptor.descriptor?.case === "fieldsMap" ? nestedDescriptor.descriptor.value - : undefined - if (nestedFieldsMap && val.value?.case === 'mapValue') { - clearIgnoredFields(val, nestedDescriptor) + : undefined; + if (nestedFieldsMap && val.value?.case === "mapValue") { + clearIgnoredFields(val, nestedDescriptor); } } } diff --git a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts index c1d2b281..d567bd48 100644 --- a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts +++ b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts @@ -3,14 +3,14 @@ * 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 { 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, @@ -20,239 +20,274 @@ import { RESPONSE_BUFFER_TOO_SMALL, registerTestCapability, test, -} from './test-runtime' +} from "./test-runtime"; -describe('Registry (via test)', () => { - test('get returns undefined for unregistered id', async () => { - expect(getTestCapabilityHandler('missing')).toBeUndefined() - }) +describe("Registry (via test)", () => { + test("get returns undefined for unregistered id", async () => { + expect(getTestCapabilityHandler("missing")).toBeUndefined(); + }); - test('register and get return handler', async () => { + 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) - }) + 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: '' }, - })) + 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: '' }, + registerTestCapability("dup", () => ({ + response: { case: "error" as const, value: "" }, })), - ).toThrow('capability already exists: dup') - }) -}) + ).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']) - }) +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("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("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(/not found/) - }) + 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(/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() + 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) + }); + 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) - }) + }); + 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' + 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) - }) + 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]) + 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) - }) + 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) + 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 + 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') - }) + 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 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() + 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) - }) + throw new Error("fail"); + }, consensusMedianAggregation().withDefault(100))(); + expect(p.result()).toBe(100); + }); - test('default Simple handler: observation error without default throws', () => { - const rt = newTestRuntime() + 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') - }) + 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') + 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', + 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') + }); + 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 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("helper getSecrets: batched call returns mixed secret/error responses", () => { + const secrets = new Map>(); + secrets.set("ns1", new Map([["id1", "val1"]])); + const rt = newTestRuntime(secrets); - test('newTestRuntime uses options.maxResponseSize', () => { - const rt = newTestRuntime(null, { maxResponseSize: 1 }) - const payload = new Uint8Array(new ArrayBuffer(2)) - payload.set([1, 2]) + const responses = rt + .getSecrets([ + { id: "id1", namespace: "ns1" }, + { id: "missing", namespace: "ns1" }, + ]) + .result(); + + expect(responses.length).toBe(2); + expect(responses[0].response.case).toBe("secret"); + expect(responses[1].response.case).toBe("error"); + }); + + 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) - }) + 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([]) - }) -}) + 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') +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', + id: "cap-a", + method: "M", payload: create(AnySchema, { value: new Uint8Array(0) }), }).response, ).toEqual({ - case: 'error', - value: 'a', - }) - }) + 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 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() - }) + 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 () => { + bunTest("cleanup: after test finishes, store is undefined", async () => { await __testOnlyRunWithRegistry(async () => { - expect(__testOnlyRegistryStore()).toBeDefined() - }) - expect(__testOnlyRegistryStore()).toBeUndefined() - }) + expect(__testOnlyRegistryStore()).toBeDefined(); + }); + expect(__testOnlyRegistryStore()).toBeUndefined(); + }); - bunTest('failure path: cleanup happens when test body throws', async () => { + bunTest("failure path: cleanup happens when test body throws", async () => { await expect( __testOnlyRunWithRegistry(async () => { - expect(__testOnlyRegistryStore()).toBeDefined() - throw new Error('intentional failure') + expect(__testOnlyRegistryStore()).toBeDefined(); + throw new Error("intentional failure"); }), - ).rejects.toThrow('intentional failure') - expect(__testOnlyRegistryStore()).toBeUndefined() - }) -}) + ).rejects.toThrow("intentional failure"); + expect(__testOnlyRegistryStore()).toBeUndefined(); + }); +}); diff --git a/packages/cre-sdk/src/sdk/wasm/runner.test.ts b/packages/cre-sdk/src/sdk/wasm/runner.test.ts index 7359b2dc..6c47b1c4 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.test.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.test.ts @@ -1,10 +1,10 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test' -import { create, fromBinary, toBinary } from '@bufbuild/protobuf' -import { anyPack, anyUnpack, EmptySchema } from '@bufbuild/protobuf/wkt' +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { anyPack, anyUnpack, EmptySchema } from "@bufbuild/protobuf/wkt"; import { ConfigSchema, OutputsSchema, -} from '@cre/generated/capabilities/internal/basictrigger/v1/basic_trigger_pb' +} from "@cre/generated/capabilities/internal/basictrigger/v1/basic_trigger_pb"; import { AwaitSecretsRequestSchema, AwaitSecretsResponseSchema, @@ -19,305 +19,351 @@ import { type Trigger, TriggerSchema, type TriggerSubscriptionRequest, -} from '@cre/generated/sdk/v1alpha/sdk_pb' -import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' -import { BasicCapability as BasicTriggerCapability } from '@cre/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen' -import { cre } from '@cre/sdk/cre' -import { Value } from '../utils' -import type { SecretsProvider } from '../workflow' -import { Runner } from './runner' +} from "@cre/generated/sdk/v1alpha/sdk_pb"; +import type { Value as ProtoValue } from "@cre/generated/values/v1/values_pb"; +import { BasicCapability as BasicTriggerCapability } from "@cre/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen"; +import { cre } from "@cre/sdk/cre"; +import { Value } from "../utils"; +import type { SecretsProvider } from "../workflow"; +import { Runner } from "./runner"; -const anyConfig = Buffer.from('config') -const anyMaxResponseSize = 2048n -const basicTrigger = new BasicTriggerCapability() -const capID = BasicTriggerCapability.CAPABILITY_ID +const anyConfig = Buffer.from("config"); +const anyMaxResponseSize = 2048n; +const basicTrigger = new BasicTriggerCapability(); +const capID = BasicTriggerCapability.CAPABILITY_ID; const subscribeRequest = create(ExecuteRequestSchema, { - request: { case: 'subscribe', value: create(EmptySchema) }, + request: { case: "subscribe", value: create(EmptySchema) }, maxResponseSize: anyMaxResponseSize, config: anyConfig, -}) +}); const anyExecuteRequest = create(ExecuteRequestSchema, { request: { - case: 'trigger', + case: "trigger", value: create(TriggerSchema, { id: 0n, - payload: anyPack(OutputsSchema, create(OutputsSchema, { coolOutput: 'hi' })), + payload: anyPack( + OutputsSchema, + create(OutputsSchema, { coolOutput: "hi" }), + ), }), }, maxResponseSize: anyMaxResponseSize, config: anyConfig, -}) +}); type TestRunnerBindings = { - versionV2: () => void - sendResponse: (data: Uint8Array) => number - getWasiArgs: () => string - getSecrets: (data: Uint8Array | Uint8Array, maxresponse: number) => any + versionV2: () => void; + sendResponse: (data: Uint8Array) => number; + getWasiArgs: () => string; + getSecrets: ( + data: Uint8Array | Uint8Array, + maxresponse: number, + ) => any; awaitSecrets: ( data: Uint8Array | Uint8Array, maxresponse: number, - ) => Uint8Array | Uint8Array -} + ) => Uint8Array | Uint8Array; +}; const mockHostBindings: TestRunnerBindings = { sendResponse: mock(() => { - return 0 + return 0; }), versionV2: mock(() => {}), getWasiArgs: mock(() => { - throw new Error('override for tests') + throw new Error("override for tests"); }), getSecrets: mock((data, maxResponseSize) => { - throw new Error('override for tests') + throw new Error("override for tests"); }), awaitSecrets: mock((data, maxResponseSize) => { - throw new Error('override for tests') + throw new Error("override for tests"); }), -} +}; const proxyHostBindings = { sendResponse: (data: Uint8Array) => { - return mockHostBindings.sendResponse(data) + return mockHostBindings.sendResponse(data); }, versionV2: () => { - return mockHostBindings.versionV2() + return mockHostBindings.versionV2(); }, getWasiArgs: () => { - return mockHostBindings.getWasiArgs() + return mockHostBindings.getWasiArgs(); }, switchModes: mock(() => {}), log: (message: string) => { - throw new Error('log called unexpectedly in test') + throw new Error("log called unexpectedly in test"); }, callCapability: (data: Uint8Array) => { - throw new Error('callCapability called unexpectedly in test') + throw new Error("callCapability called unexpectedly in test"); }, awaitCapabilities: (data: Uint8Array, id: number) => { - throw new Error('awaitCapabilities called unexpectedly in test') + throw new Error("awaitCapabilities called unexpectedly in test"); }, getSecrets: (data: Uint8Array, id: number) => { - return mockHostBindings.getSecrets(data, id) + return mockHostBindings.getSecrets(data, id); }, awaitSecrets: (data: Uint8Array, id: number) => { - return mockHostBindings.awaitSecrets(data, id) + return mockHostBindings.awaitSecrets(data, id); }, now: () => { - throw new Error('now called unexpectedly in test') + throw new Error("now called unexpectedly in test"); }, -} +}; -Object.assign(globalThis, proxyHostBindings) +Object.assign(globalThis, proxyHostBindings); afterEach(() => { - mock.restore() -}) + mock.restore(); +}); -describe('runner', () => { - describe('run', () => { - test('gathers subscriptions', async () => { - var sentResponse: ExecutionResult | null = null +describe("runner", () => { + describe("run", () => { + test("gathers subscriptions", async () => { + var sentResponse: ExecutionResult | null = null; mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input) - return 0 - }) - const runner = await getTestRunner(subscribeRequest) + sentResponse = fromBinary(ExecutionResultSchema, input); + return 0; + }); + const runner = await getTestRunner(subscribeRequest); await runner.run(async (_: string, secretsProvider: SecretsProvider) => { return [ - cre.handler(basicTrigger.trigger({ name: 'foo', number: 10 }), () => { - throw new Error('Must not be called during registration to tiggers') + cre.handler(basicTrigger.trigger({ name: "foo", number: 10 }), () => { + throw new Error( + "Must not be called during registration to tiggers", + ); }), - ] - }) - expect(sentResponse).toBeDefined() - expect(sentResponse!.result.case).toBe('triggerSubscriptions') - const responseValue = sentResponse!.result.value! as TriggerSubscriptionRequest - expect(responseValue.subscriptions.length).toBe(1) - expect(responseValue.subscriptions[0].id).toBe(capID) - expect(responseValue.subscriptions[0].method).toBe('Trigger') - expect(responseValue.subscriptions[0].payload).toBeDefined() - const actualConfig = anyUnpack(responseValue.subscriptions[0].payload!, ConfigSchema)! - expect(actualConfig.name).toBe('foo') - expect(actualConfig.number).toBe(10) - }) + ]; + }); + expect(sentResponse).toBeDefined(); + expect(sentResponse!.result.case).toBe("triggerSubscriptions"); + const responseValue = sentResponse!.result + .value! as TriggerSubscriptionRequest; + expect(responseValue.subscriptions.length).toBe(1); + expect(responseValue.subscriptions[0].id).toBe(capID); + expect(responseValue.subscriptions[0].method).toBe("Trigger"); + expect(responseValue.subscriptions[0].payload).toBeDefined(); + const actualConfig = anyUnpack( + responseValue.subscriptions[0].payload!, + ConfigSchema, + )!; + expect(actualConfig.name).toBe("foo"); + expect(actualConfig.number).toBe(10); + }); - test('executes workflow', async () => { - var sentResponse: ExecutionResult | null = null + test("executes workflow", async () => { + var sentResponse: ExecutionResult | null = null; mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input) - return 0 - }) - const runner = await getTestRunner(anyExecuteRequest) + sentResponse = fromBinary(ExecutionResultSchema, input); + return 0; + }); + const runner = await getTestRunner(anyExecuteRequest); await runner.run(async (_: string, secretsProvider: SecretsProvider) => { return [ - cre.handler(basicTrigger.trigger({ name: 'foo', number: 10 }), (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()) - expect(trigger.coolOutput).toBe('hi') - return 10 - }), - ] - }) - expect(sentResponse).toBeDefined() - expect(sentResponse!.result.case).toBe('value') + cre.handler( + basicTrigger.trigger({ name: "foo", number: 10 }), + (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()); + expect(trigger.coolOutput).toBe("hi"); + return 10; + }, + ), + ]; + }); + expect(sentResponse).toBeDefined(); + expect(sentResponse!.result.case).toBe("value"); expect( Value.wrap(sentResponse!.result.value as ProtoValue).unwrapToType({ instance: 10, }), - ).toBe(10) - }) - }) + ).toBe(10); + }); + }); - test('executes subscribe error', async () => { - var sentResponse: ExecutionResult | null = null - const anyError = 'error' + test("executes subscribe error", async () => { + var sentResponse: ExecutionResult | null = null; + const anyError = "error"; mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input) - expect(sentResponse!.result.case).toBe('error') - expect(sentResponse!.result.value).toBe(anyError) - return 0 - }) - const runner = await getTestRunner(subscribeRequest) + sentResponse = fromBinary(ExecutionResultSchema, input); + expect(sentResponse!.result.case).toBe("error"); + expect(sentResponse!.result.value).toBe(anyError); + return 0; + }); + const runner = await getTestRunner(subscribeRequest); await runner.run((_: string, secretsProvider: SecretsProvider) => { - throw new Error(anyError) - }) - }) + throw new Error(anyError); + }); + }); - test('executes subscribe resolve error', async () => { - var sentResponse: ExecutionResult | null = null - const anyError = 'error' + test("executes subscribe resolve error", async () => { + var sentResponse: ExecutionResult | null = null; + const anyError = "error"; mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input) - expect(sentResponse!.result.case).toBe('error') - expect(sentResponse!.result.value).toBe(anyError) - return 0 - }) - const runner = await getTestRunner(subscribeRequest) + sentResponse = fromBinary(ExecutionResultSchema, input); + expect(sentResponse!.result.case).toBe("error"); + expect(sentResponse!.result.value).toBe(anyError); + return 0; + }); + const runner = await getTestRunner(subscribeRequest); await runner.run(async (_: string, secretsProvider: SecretsProvider) => { - return Promise.reject(new Error(anyError)) - }) - }) + return Promise.reject(new Error(anyError)); + }); + }); - test('executes trigger error', async () => { - var sentResponse: ExecutionResult | null = null - const anyError = 'error' + test("executes trigger error", async () => { + var sentResponse: ExecutionResult | null = null; + const anyError = "error"; mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input) - expect(sentResponse!.result.case).toBe('error') - expect(sentResponse!.result.value).toBe(anyError) - return 0 - }) - const runner = await getTestRunner(anyExecuteRequest) + sentResponse = fromBinary(ExecutionResultSchema, input); + expect(sentResponse!.result.case).toBe("error"); + expect(sentResponse!.result.value).toBe(anyError); + return 0; + }); + const runner = await getTestRunner(anyExecuteRequest); await runner.run(async (_: string, secretsProvider: SecretsProvider) => { - throw new Error(anyError) - }) - }) + throw new Error(anyError); + }); + }); - test('executes workflow with multiple triggers', async () => { - var sentResponse: ExecutionResult | null = null + test("executes workflow with multiple triggers", async () => { + var sentResponse: ExecutionResult | null = null; mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input) - return 0 - }) - const testRequest = structuredClone(anyExecuteRequest) - const trigger = testRequest.request.value as Trigger - trigger.id = 1n + sentResponse = fromBinary(ExecutionResultSchema, input); + return 0; + }); + const testRequest = structuredClone(anyExecuteRequest); + const trigger = testRequest.request.value as Trigger; + trigger.id = 1n; - const runner = await getTestRunner(testRequest) + const runner = await getTestRunner(testRequest); await runner.run(async (_: string, secretsProvider: SecretsProvider) => { return [ - cre.handler(basicTrigger.trigger({ name: 'foo', number: 10 }), (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()) - expect(trigger.coolOutput).toBe('hi') - return 10 - }), - cre.handler(basicTrigger.trigger({ name: 'bar', number: 20 }), (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()) - expect(trigger.coolOutput).toBe('hi') - return 20 - }), - cre.handler(basicTrigger.trigger({ name: 'baz', number: 30 }), (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()) - expect(trigger.coolOutput).toBe('hi') - return 30 - }), - ] - }) - expect(sentResponse).toBeDefined() - expect(sentResponse!.result.case).toBe('value') + cre.handler( + basicTrigger.trigger({ name: "foo", number: 10 }), + (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()); + expect(trigger.coolOutput).toBe("hi"); + return 10; + }, + ), + cre.handler( + basicTrigger.trigger({ name: "bar", number: 20 }), + (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()); + expect(trigger.coolOutput).toBe("hi"); + return 20; + }, + ), + cre.handler( + basicTrigger.trigger({ name: "baz", number: 30 }), + (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()); + expect(trigger.coolOutput).toBe("hi"); + return 30; + }, + ), + ]; + }); + expect(sentResponse).toBeDefined(); + expect(sentResponse!.result.case).toBe("value"); expect( Value.wrap(sentResponse!.result.value as ProtoValue).unwrapToType({ instance: 10, }), - ).toBe(20) - }) + ).toBe(20); + }); - test('get secrets passes max response size', async () => { + test("get secrets passes max response size", async () => { const anySecretResponse = create(SecretResponseSchema, { response: { - case: 'secret', + case: "secret", value: create(SecretSchema, { - id: 'Bar', - namespace: 'Foo', - owner: 'Baz', - value: 'Qux', + id: "Bar", + namespace: "Foo", + owner: "Baz", + value: "Qux", }), }, - }) + }); const anySecretsResponse = create(SecretResponsesSchema, { responses: [anySecretResponse], - }) + }); mockHostBindings.getSecrets = (data, maxResponseSize) => { - const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data - const secretsRequest = fromBinary(GetSecretsRequestSchema, dataBytes) - expect(secretsRequest.requests.length).toBe(1) - expect(secretsRequest.requests[0].namespace).toBe('Foo') - expect(secretsRequest.requests[0].id).toBe('Bar') - expect(secretsRequest.callbackId).toBe(0) - expect(maxResponseSize).toBe(Number(anyMaxResponseSize)) - return 0 - } + const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data; + const secretsRequest = fromBinary(GetSecretsRequestSchema, dataBytes); + expect(secretsRequest.requests.length).toBe(1); + expect(secretsRequest.requests[0].namespace).toBe("Foo"); + expect(secretsRequest.requests[0].id).toBe("Bar"); + expect(secretsRequest.callbackId).toBe(0); + expect(maxResponseSize).toBe(Number(anyMaxResponseSize)); + return 0; + }; mockHostBindings.awaitSecrets = (data, maxResponseSize) => { - const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data - const awaitSecretsRequest = fromBinary(AwaitSecretsRequestSchema, dataBytes) - expect(awaitSecretsRequest.ids.length).toBe(1) - expect(awaitSecretsRequest.ids[0]).toBe(0) - expect(maxResponseSize).toBe(Number(anyMaxResponseSize)) + const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data; + const awaitSecretsRequest = fromBinary( + AwaitSecretsRequestSchema, + dataBytes, + ); + expect(awaitSecretsRequest.ids.length).toBe(1); + expect(awaitSecretsRequest.ids[0]).toBe(0); + expect(maxResponseSize).toBe(Number(anyMaxResponseSize)); // Create the proper AwaitSecretsResponse with a map const awaitSecretsResponse = create(AwaitSecretsResponseSchema, { responses: { 0: anySecretsResponse, }, - }) - return toBinary(AwaitSecretsResponseSchema, awaitSecretsResponse) - } + }); + return toBinary(AwaitSecretsResponseSchema, awaitSecretsResponse); + }; - const dr = getTestRunner(subscribeRequest) - await (await dr).run(async (_: string, secretsProvider: SecretsProvider) => { - const secret = await secretsProvider.getSecret({ namespace: 'Foo', id: 'Bar' }).result() - expect(secret.namespace).toBe('Foo') - expect(secret.id).toBe('Bar') - expect(secret.owner).toBe('Baz') - expect(secret.value).toBe('Qux') - return [cre.handler(basicTrigger.trigger({}), () => 10)] - }) - expect(true).toBe(true) - }) -}) + const dr = getTestRunner(subscribeRequest); + await (await dr).run( + async (_: string, secretsProvider: SecretsProvider) => { + const batched = await secretsProvider + .getSecrets([{ namespace: "Foo", id: "Bar" }]) + .result(); + expect(batched.length).toBe(1); + expect(batched[0].response.case).toBe("secret"); + if (batched[0].response.case === "secret") { + expect(batched[0].response.value.namespace).toBe("Foo"); + expect(batched[0].response.value.id).toBe("Bar"); + expect(batched[0].response.value.owner).toBe("Baz"); + expect(batched[0].response.value.value).toBe("Qux"); + } + + // Keep compatibility coverage for single-secret API. + const single = await secretsProvider + .getSecret({ namespace: "Foo", id: "Bar" }) + .result(); + expect(single.namespace).toBe("Foo"); + expect(single.id).toBe("Bar"); + expect(single.owner).toBe("Baz"); + expect(single.value).toBe("Qux"); + return [cre.handler(basicTrigger.trigger({}), () => 10)]; + }, + ); + expect(true).toBe(true); + }); +}); function getTestRunner(request: ExecuteRequest): Promise> { - const serialized = toBinary(ExecuteRequestSchema, request) - const encoded = Buffer.from(serialized).toString('base64') + const serialized = toBinary(ExecuteRequestSchema, request); + const encoded = Buffer.from(serialized).toString("base64"); // Update the mock to return the specific request - mockHostBindings.getWasiArgs = mock(() => JSON.stringify(['program', encoded])) + mockHostBindings.getWasiArgs = mock(() => + JSON.stringify(["program", encoded]), + ); return Runner.newRunner({ configParser: (b) => { - const stringConfig = Buffer.from(b).toString() - expect(stringConfig).toBe(anyConfig.toString()) - return stringConfig + const stringConfig = Buffer.from(b).toString(); + expect(stringConfig).toBe(anyConfig.toString()); + return stringConfig; }, - }) + }); } diff --git a/packages/cre-sdk/src/sdk/wasm/runner.ts b/packages/cre-sdk/src/sdk/wasm/runner.ts index 35bec786..985b83e7 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.ts @@ -1,16 +1,16 @@ -import { create, fromBinary, toBinary } from '@bufbuild/protobuf' +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { type ExecuteRequest, ExecuteRequestSchema, type ExecutionResult, ExecutionResultSchema, TriggerSubscriptionRequestSchema, -} from '@cre/generated/sdk/v1alpha/sdk_pb' -import { type ConfigHandlerParams, configHandler } from '@cre/sdk/utils/config' -import type { SecretsProvider, Workflow } from '@cre/sdk/workflow' -import { Value } from '../utils' -import { hostBindings } from './host-bindings' -import { Runtime } from './runtime' +} from "@cre/generated/sdk/v1alpha/sdk_pb"; +import { type ConfigHandlerParams, configHandler } from "@cre/sdk/utils/config"; +import type { SecretsProvider, Workflow } from "@cre/sdk/workflow"; +import { Value } from "../utils"; +import { hostBindings } from "./host-bindings"; +import { Runtime } from "./runtime"; export class Runner { private constructor( @@ -21,21 +21,24 @@ export class Runner { static async newRunner( configHandlerParams?: ConfigHandlerParams, ): Promise> { - hostBindings.versionV2() - const request = Runner.getRequest() - const config = await configHandler(request, configHandlerParams) - return new Runner(config, request) + hostBindings.versionV2(); + const request = Runner.getRequest(); + const config = await configHandler( + request, + configHandlerParams, + ); + return new Runner(config, request); } private static getRequest(): ExecuteRequest { - const argsString = hostBindings.getWasiArgs() - let args + const argsString = hostBindings.getWasiArgs(); + let args: any; try { - args = JSON.parse(argsString) + args = JSON.parse(argsString); } catch (e) { throw new Error( - 'Invalid request: could not parse WASI arguments as JSON. Ensure the WASM runtime is passing valid arguments to the workflow', - ) + "Invalid request: could not parse WASI arguments as JSON. Ensure the WASM runtime is passing valid arguments to the workflow", + ); } // SDK expects exactly 2 args: @@ -44,13 +47,13 @@ export class Runner { if (args.length !== 2) { throw new Error( `Invalid request: expected exactly 2 WASI arguments (script name and base64-encoded request payload), but received ${args.length}`, - ) + ); } - const base64Request = args[1] + const base64Request = args[1]; - const bytes = Buffer.from(base64Request, 'base64') - return fromBinary(ExecuteRequestSchema, bytes) + const bytes = Buffer.from(base64Request, "base64"); + return fromBinary(ExecuteRequestSchema, bytes); } async run( @@ -59,35 +62,36 @@ export class Runner { secretsProvider: SecretsProvider, ) => Promise> | Workflow, ) { - const runtime = new Runtime(this.config, 0, this.request.maxResponseSize) + const runtime = new Runtime(this.config, 0, this.request.maxResponseSize); - let result: Promise | ExecutionResult + let result: Promise | ExecutionResult; try { const workflow = await initFn(this.config, { + getSecrets: runtime.getSecrets.bind(runtime), getSecret: runtime.getSecret.bind(runtime), - }) + }); switch (this.request.request.case) { - case 'subscribe': - result = this.handleSubscribePhase(this.request, workflow) - break - case 'trigger': - result = this.handleExecutionPhase(this.request, workflow, runtime) - break + case "subscribe": + result = this.handleSubscribePhase(this.request, workflow); + break; + case "trigger": + result = this.handleExecutionPhase(this.request, workflow, runtime); + break; default: throw new Error( `Unknown request type '${this.request.request.case}': expected 'subscribe' or 'trigger'. This may indicate a version mismatch between the SDK and the CRE runtime`, - ) + ); } } catch (e) { - const err = e instanceof Error ? e.message : String(e) + const err = e instanceof Error ? e.message : String(e); result = create(ExecutionResultSchema, { - result: { case: 'error', value: err }, - }) + result: { case: "error", value: err }, + }); } - const awaitedResult = await result! - hostBindings.sendResponse(toBinary(ExecutionResultSchema, awaitedResult)) + const awaitedResult = await result!; + hostBindings.sendResponse(toBinary(ExecutionResultSchema, awaitedResult)); } async handleExecutionPhase( @@ -95,37 +99,37 @@ export class Runner { workflow: Workflow, runtime: Runtime, ): Promise { - if (req.request.case !== 'trigger') { + if (req.request.case !== "trigger") { throw new Error( `cannot handle non-trigger request as a trigger: received request type '${req.request.case}' in handleExecutionPhase. This is an internal SDK error`, - ) + ); } - const triggerMsg = req.request.value + const triggerMsg = req.request.value; // We're about to cast bigint to number, so we need to check if it's safe - const id = BigInt(triggerMsg.id) + const id = BigInt(triggerMsg.id); if (id > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error( `Trigger ID ${id} exceeds JavaScript safe integer range (Number.MAX_SAFE_INTEGER = ${Number.MAX_SAFE_INTEGER}). This trigger ID cannot be safely represented as a number`, - ) + ); } - const index = Number(triggerMsg.id) + const index = Number(triggerMsg.id); if (Number.isFinite(index) && index >= 0 && index < workflow.length) { - const entry = workflow[index] - const schema = entry.trigger.outputSchema() + const entry = workflow[index]; + const schema = entry.trigger.outputSchema(); if (!triggerMsg.payload) { return create(ExecutionResultSchema, { result: { - case: 'error', + case: "error", value: `trigger payload is missing for handler at index ${index} (trigger ID ${triggerMsg.id}). The trigger event must include a payload`, }, - }) + }); } - const payloadAny = triggerMsg.payload + const payloadAny = triggerMsg.payload; /** * Note: do not hardcode method name; routing by id is authoritative. @@ -134,39 +138,42 @@ export class Runner { * * @see https://github.com/smartcontractkit/cre-sdk-go/blob/5a41d81e3e072008484e85dc96d746401aafcba2/cre/wasm/runner.go#L81 * */ - const decoded = fromBinary(schema, payloadAny.value) - const adapted = entry.trigger.adapt(decoded) + const decoded = fromBinary(schema, payloadAny.value); + const adapted = entry.trigger.adapt(decoded); try { - const result = await entry.fn(runtime, adapted) - const wrapped = Value.wrap(result) + const result = await entry.fn(runtime, adapted); + const wrapped = Value.wrap(result); return create(ExecutionResultSchema, { - result: { case: 'value', value: wrapped.proto() }, - }) + result: { case: "value", value: wrapped.proto() }, + }); } catch (e) { - const err = e instanceof Error ? e.message : String(e) + const err = e instanceof Error ? e.message : String(e); return create(ExecutionResultSchema, { - result: { case: 'error', value: err }, - }) + result: { case: "error", value: err }, + }); } } return create(ExecutionResultSchema, { result: { - case: 'error', + case: "error", value: `trigger not found: no workflow handler registered at index ${index} (trigger ID ${triggerMsg.id}). The workflow has ${workflow.length} handler(s) registered. Verify the trigger subscription matches a registered handler`, }, - }) + }); } - handleSubscribePhase(req: ExecuteRequest, workflow: Workflow): ExecutionResult { - if (req.request.case !== 'subscribe') { + handleSubscribePhase( + req: ExecuteRequest, + workflow: Workflow, + ): ExecutionResult { + if (req.request.case !== "subscribe") { return create(ExecutionResultSchema, { result: { - case: 'error', + case: "error", value: `subscribe request expected but received '${req.request.case}' in handleSubscribePhase. This is an internal SDK error`, }, - }) + }); } // Build TriggerSubscriptionRequest from the workflow entries @@ -174,14 +181,14 @@ export class Runner { id: entry.trigger.capabilityId(), method: entry.trigger.method(), payload: entry.trigger.configAsAny(), - })) + })); const subscriptionRequest = create(TriggerSubscriptionRequestSchema, { subscriptions, - }) + }); return create(ExecutionResultSchema, { - result: { case: 'triggerSubscriptions', value: subscriptionRequest }, - }) + result: { case: "triggerSubscriptions", value: subscriptionRequest }, + }); } } diff --git a/packages/cre-sdk/src/sdk/workflow.ts b/packages/cre-sdk/src/sdk/workflow.ts index f033a784..8b4b969b 100644 --- a/packages/cre-sdk/src/sdk/workflow.ts +++ b/packages/cre-sdk/src/sdk/workflow.ts @@ -1,19 +1,18 @@ -import type { Message } from '@bufbuild/protobuf' +import type { Message } from "@bufbuild/protobuf"; import type { - CapabilityResponse, Secret, SecretRequest, SecretRequestJson, -} from '@cre/generated/sdk/v1alpha/sdk_pb' -import { type Runtime } from '@cre/sdk/runtime' -import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' -import type { SecretsError } from './errors' -import type { CreSerializable } from './utils' + SecretResponse, +} from "@cre/generated/sdk/v1alpha/sdk_pb"; +import type { Runtime } from "@cre/sdk/runtime"; +import type { Trigger } from "@cre/sdk/utils/triggers/trigger-interface"; +import type { CreSerializable } from "./utils"; export type HandlerFn = ( runtime: Runtime, triggerOutput: TTriggerOutput, -) => Promise> | CreSerializable +) => Promise> | CreSerializable; export interface HandlerEntry< TConfig, @@ -21,11 +20,13 @@ export interface HandlerEntry< TTriggerOutput, TResult, > { - trigger: Trigger - fn: HandlerFn + trigger: Trigger; + fn: HandlerFn; } -export type Workflow = ReadonlyArray> +export type Workflow = ReadonlyArray< + HandlerEntry +>; export const handler = < TRawTriggerOutput extends Message, @@ -38,10 +39,13 @@ export const handler = < ): HandlerEntry => ({ trigger, fn, -}) +}); export type SecretsProvider = { + getSecrets(requests: Array): { + result: () => SecretResponse[]; + }; getSecret(request: SecretRequest | SecretRequestJson): { - result: () => Secret - } -} + result: () => Secret; + }; +}; From 17dbd609f09c4aa455e9bf1f79781124ab862ebe Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Tue, 5 May 2026 15:47:29 -0400 Subject: [PATCH 2/2] Fixed linting --- packages/cre-sdk-examples/package.json | 2 +- .../src/workflows/secrets/index.ts | 73 +- .../cre-sdk/src/sdk/impl/runtime-impl.test.ts | 1294 ++++++++--------- packages/cre-sdk/src/sdk/impl/runtime-impl.ts | 350 ++--- .../src/sdk/testutils/test-runtime.test.ts | 416 +++--- packages/cre-sdk/src/sdk/wasm/runner.test.ts | 445 +++--- packages/cre-sdk/src/sdk/wasm/runner.ts | 136 +- packages/cre-sdk/src/sdk/workflow.ts | 32 +- 8 files changed, 1260 insertions(+), 1488 deletions(-) diff --git a/packages/cre-sdk-examples/package.json b/packages/cre-sdk-examples/package.json index 3f38557c..6b954aad 100644 --- a/packages/cre-sdk-examples/package.json +++ b/packages/cre-sdk-examples/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@bufbuild/protobuf": "2.6.3", - "@chainlink/cre-sdk": "1.7.0-alpha.1", + "@chainlink/cre-sdk": "workspace:*", "viem": "2.34.0", "zod": "3.25.76" }, diff --git a/packages/cre-sdk-examples/src/workflows/secrets/index.ts b/packages/cre-sdk-examples/src/workflows/secrets/index.ts index b00c0efa..e6602f82 100644 --- a/packages/cre-sdk-examples/src/workflows/secrets/index.ts +++ b/packages/cre-sdk-examples/src/workflows/secrets/index.ts @@ -8,14 +8,14 @@ import { ok, Runner, type Runtime, -} from "@chainlink/cre-sdk"; -import { z } from "zod"; +} from '@chainlink/cre-sdk' +import { z } from 'zod' const configSchema = z.object({ url: z.string(), -}); +}) -type Config = z.infer; +type Config = z.infer const responseSchema = z.object({ name: z.string(), @@ -34,9 +34,9 @@ const responseSchema = z.object({ created: z.string().datetime(), edited: z.string().datetime(), url: z.string(), -}); +}) -type StarWarsCharacter = z.infer; +type StarWarsCharacter = z.infer const fetchStarWarsCharacter = ( sendRequester: HTTPSendRequester, @@ -44,65 +44,60 @@ const fetchStarWarsCharacter = ( url: string, characterId: string, ): StarWarsCharacter => { - url = config.url.replace("{characterId}", characterId); - const response = sendRequester.sendRequest({ url, method: "GET" }).result(); + url = config.url.replace('{characterId}', characterId) + const response = sendRequester.sendRequest({ url, method: 'GET' }).result() // Check if the response is successful using the helper function if (!ok(response)) { - throw new Error(`HTTP request failed with status: ${response.statusCode}`); + throw new Error(`HTTP request failed with status: ${response.statusCode}`) } - const character = responseSchema.parse(json(response)); + const character = responseSchema.parse(json(response)) - return character; -}; + return character +} const onHTTPTrigger = async (runtime: Runtime) => { - const httpCapability = new HTTPClient(); + const httpCapability = new HTTPClient() // Fetch a single secret - const secretUrlValue = runtime.getSecret({ id: "SECRET_URL" }).result().value; + const secretUrlValue = runtime.getSecret({ id: 'SECRET_URL' }).result().value // Fetch multiple secrets - const secretsToFetch = [ - { id: "CHARACTER_ID1" }, - { id: "CHARACTER_ID2" }, - { id: "CHARACTER_ID3" }, - ]; - const secretResponses = runtime.getSecrets(secretsToFetch).result(); + const secretsToFetch = [{ id: 'CHARACTER_ID1' }, { id: 'CHARACTER_ID2' }, { id: 'CHARACTER_ID3' }] + const secretResponses = runtime.getSecrets(secretsToFetch).result() const characterIds = secretResponses.flatMap((response) => - response.response.case === "secret" && response.response.value?.id + response.response.case === 'secret' && response.response.value?.id ? [response.response.value.value] : [], - ); + ) if (characterIds.length === 0) { - throw new Error("No character ID secrets available"); + throw new Error('No character ID secrets available') } // choose a random character id // Math.random() is safe to use in the workflow - const characterId = - characterIds[Math.floor(Math.random() * characterIds.length)]; + const characterId = characterIds[Math.floor(Math.random() * characterIds.length)] const result: StarWarsCharacter = httpCapability - .sendRequest( - runtime, - fetchStarWarsCharacter, - consensusIdenticalAggregation(), - )(runtime.config, secretUrlValue, characterId) - .result(); - - return result; -}; + .sendRequest(runtime, fetchStarWarsCharacter, consensusIdenticalAggregation())( + runtime.config, + secretUrlValue, + characterId, + ) + .result() + + return result +} const initWorkflow = () => { - const httpTrigger = new HTTPCapability(); + const httpTrigger = new HTTPCapability() - return [handler(httpTrigger.trigger({}), onHTTPTrigger)]; -}; + return [handler(httpTrigger.trigger({}), onHTTPTrigger)] +} export async function main() { const runner = await Runner.newRunner({ configSchema, - }); - await runner.run(initWorkflow); + }) + await runner.run(initWorkflow) } 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 45b29132..9e6384cb 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.test.ts @@ -1,21 +1,21 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; -import { create } from "@bufbuild/protobuf"; -import { type Any, anyPack, anyUnpack } from "@bufbuild/protobuf/wkt"; +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { create } from '@bufbuild/protobuf' +import { type Any, anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' import { InputSchema, OutputSchema, -} from "@cre/generated/capabilities/internal/actionandtrigger/v1/action_and_trigger_pb"; +} from '@cre/generated/capabilities/internal/actionandtrigger/v1/action_and_trigger_pb' import { InputsSchema, OutputsSchema, -} from "@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb"; +} from '@cre/generated/capabilities/internal/basicaction/v1/basic_action_pb' import { type NodeInputs, type NodeInputsJson, NodeInputsSchema, type NodeOutputs, NodeOutputsSchema, -} from "@cre/generated/capabilities/internal/nodeaction/v1/node_action_pb"; +} from '@cre/generated/capabilities/internal/nodeaction/v1/node_action_pb' import { AggregationType, type AwaitCapabilitiesRequest, @@ -31,13 +31,13 @@ import { SecretResponsesSchema, type SimpleConsensusInputs, type SimpleConsensusInputsJson, -} from "@cre/generated/sdk/v1alpha/sdk_pb"; -import type { Value as ProtoValue } from "@cre/generated/values/v1/values_pb"; -import { BasicCapability } from "@cre/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen"; -import { BasicActionCapability } from "@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen"; -import { ConsensusCapability } from "@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen"; -import { BasicActionCapability as NodeActionCapability } from "@cre/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen"; -import type { NodeRuntime, Runtime } from "@cre/sdk/cre"; +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' +import { BasicCapability } from '@cre/generated-sdk/capabilities/internal/actionandtrigger/v1/basic_sdk_gen' +import { BasicActionCapability } from '@cre/generated-sdk/capabilities/internal/basicaction/v1/basicaction_sdk_gen' +import { ConsensusCapability } from '@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen' +import { BasicActionCapability as NodeActionCapability } from '@cre/generated-sdk/capabilities/internal/nodeaction/v1/basicaction_sdk_gen' +import type { NodeRuntime, Runtime } from '@cre/sdk/cre' import { ConsensusAggregationByFields, ConsensusFieldAggregation, @@ -46,159 +46,138 @@ import { ignore, median, Value, -} from "@cre/sdk/utils"; -import { CapabilityError } from "@cre/sdk/utils/capabilities/capability-error"; -import { - DonModeError, - NodeModeError, - SecretsBatchError, - SecretsError, -} from "../errors"; -import { RESPONSE_BUFFER_TOO_SMALL } from "../testutils/test-runtime"; -import { type RuntimeHelpers, RuntimeImpl } from "./runtime-impl"; +} from '@cre/sdk/utils' +import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' +import { DonModeError, NodeModeError, SecretsBatchError, 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 -function createRuntimeHelpersMock( - overrides: Partial = {}, -): RuntimeHelpers { +function createRuntimeHelpersMock(overrides: Partial = {}): RuntimeHelpers { // Create default implementation that throws errors for all methods const defaultMock: RuntimeHelpers = { call: mock(() => { - throw new Error("Method not implemented: call"); + throw new Error('Method not implemented: call') }), await: mock(() => { - throw new Error("Method not implemented: await"); + throw new Error('Method not implemented: await') }), getSecrets: mock(() => { - throw new Error("Method not implemented: getSecrets"); + throw new Error('Method not implemented: getSecrets') }), awaitSecrets: mock(() => { - throw new Error("Method not implemented: awaitSecrets"); + throw new Error('Method not implemented: awaitSecrets') }), // switchModes is used in every test, most will ignore it, so it's safe to default to a no-op. switchModes: mock(() => {}), now: mock(() => { - throw new Error("Method not implemented: now"); + throw new Error('Method not implemented: now') }), log: mock(() => {}), - }; + } // Return a merged object with overrides taking precedence - return { ...defaultMock, ...overrides }; + return { ...defaultMock, ...overrides } } -const anyMaxSize = 1024n * 1024n; +const anyMaxSize = 1024n * 1024n // Store original prototypes for manual restoration -const originalConsensusSimple = ConsensusCapability.prototype.simple; -const originalNodeActionPerformAction = - NodeActionCapability.prototype.performAction; +const originalConsensusSimple = ConsensusCapability.prototype.simple +const originalNodeActionPerformAction = NodeActionCapability.prototype.performAction afterEach(() => { // Restore all mocks after each test - mock.restore(); + mock.restore() // Manually restore prototype methods - ConsensusCapability.prototype.simple = originalConsensusSimple; - NodeActionCapability.prototype.performAction = - originalNodeActionPerformAction; -}); - -describe("test runtime", () => { - describe("test call capability", () => { - test("allows awaiting multiple capability results in different order than calls", () => { - const anyResult1 = "ok1"; - const anyResult2 = "ok2"; - var expectedCall = 1; - var expectedAwait = 2; - - const input1 = create(InputsSchema, { inputThing: true }); - const input2 = create(InputSchema, { name: "input" }); + ConsensusCapability.prototype.simple = originalConsensusSimple + NodeActionCapability.prototype.performAction = originalNodeActionPerformAction +}) + +describe('test runtime', () => { + describe('test call capability', () => { + test('allows awaiting multiple capability results in different order than calls', () => { + const anyResult1 = 'ok1' + const anyResult2 = 'ok2' + var expectedCall = 1 + var expectedAwait = 2 + + const input1 = create(InputsSchema, { inputThing: true }) + const input2 = create(InputSchema, { name: 'input' }) const helpers = createRuntimeHelpersMock({ call: mock((request: CapabilityRequest) => { switch (request.callbackId) { case 1: - expect(expectedCall).toEqual(1); - expectedCall++; - expect(request.id).toEqual(BasicActionCapability.CAPABILITY_ID); - expect(request.method).toEqual("PerformAction"); - expect(anyUnpack(request.payload as Any, InputsSchema)).toEqual( - input1, - ); - return true; + expect(expectedCall).toEqual(1) + expectedCall++ + expect(request.id).toEqual(BasicActionCapability.CAPABILITY_ID) + expect(request.method).toEqual('PerformAction') + expect(anyUnpack(request.payload as Any, InputsSchema)).toEqual(input1) + return true case 2: - expect(expectedCall).toEqual(2); - expectedCall++; - expect(request.id).toEqual(BasicCapability.CAPABILITY_ID); - expect(request.method).toEqual("Action"); - expect(anyUnpack(request.payload as Any, InputSchema)).toEqual( - input2, - ); - return true; + expect(expectedCall).toEqual(2) + expectedCall++ + expect(request.id).toEqual(BasicCapability.CAPABILITY_ID) + expect(request.method).toEqual('Action') + expect(anyUnpack(request.payload as Any, InputSchema)).toEqual(input2) + return true default: - throw new Error( - `Unexpected call with callbackId: ${request.callbackId}`, - ); + throw new Error(`Unexpected call with callbackId: ${request.callbackId}`) } }), await: mock((request: AwaitCapabilitiesRequest) => { - expect(request.ids.length).toEqual(1); - var payload: Any; - const id = request.ids[0]; + expect(request.ids.length).toEqual(1) + var payload: Any + const id = request.ids[0] switch (id) { case 1: - expect(1).toEqual(expectedAwait); - expectedAwait--; - payload = anyPack( - OutputsSchema, - create(OutputsSchema, { adaptedThing: anyResult1 }), - ); - break; + expect(1).toEqual(expectedAwait) + expectedAwait-- + payload = anyPack(OutputsSchema, create(OutputsSchema, { adaptedThing: anyResult1 })) + break case 2: - expect(2).toEqual(expectedAwait); - expectedAwait--; - payload = anyPack( - OutputSchema, - create(OutputSchema, { welcome: anyResult2 }), - ); - break; + expect(2).toEqual(expectedAwait) + expectedAwait-- + payload = anyPack(OutputSchema, create(OutputSchema, { welcome: anyResult2 })) + break default: - throw new Error(`Unexpected await with id: ${request.ids[0]}`); + throw new Error(`Unexpected await with id: ${request.ids[0]}`) } return create(AwaitCapabilitiesResponseSchema, { responses: { [id]: create(CapabilityResponseSchema, { - response: { case: "payload", value: payload }, + response: { case: 'payload', value: payload }, }), }, - }); + }) }), - }); - - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); - const call1 = workflowAction1.performAction(runtime, input1); - const workflowAction2 = new BasicCapability(); - const call2 = workflowAction2.action(runtime, input2); - const result2 = call2.result(); - expect(result2.welcome).toEqual(anyResult2); - const result1 = call1.result(); - expect(result1.adaptedThing).toEqual(anyResult1); - }); - - test("call capability errors", () => { + }) + + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const workflowAction1 = new BasicActionCapability() + const call1 = workflowAction1.performAction(runtime, input1) + const workflowAction2 = new BasicCapability() + const call2 = workflowAction2.action(runtime, input2) + const result2 = call2.result() + expect(result2.welcome).toEqual(anyResult2) + const result1 = call1.result() + expect(result1.adaptedThing).toEqual(anyResult1) + }) + + test('call capability errors', () => { const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return false; + return false }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); + 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( @@ -206,36 +185,36 @@ describe("test runtime", () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: "PerformAction", + method: 'PerformAction', }, ), - ); - }); + ) + }) - test("capability errors are returned to the caller", () => { - const anyError = "error"; + test('capability errors are returned to the caller', () => { + const anyError = 'error' const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return true; + return true }), await: mock((request: AwaitCapabilitiesRequest) => { - expect(request.ids.length).toEqual(1); + expect(request.ids.length).toEqual(1) return create(AwaitCapabilitiesResponseSchema, { responses: { [request.ids[0]]: create(CapabilityResponseSchema, { - response: { case: "error", value: anyError }, + response: { case: 'error', value: anyError }, }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); + 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( @@ -243,55 +222,55 @@ describe("test runtime", () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: "PerformAction", + method: 'PerformAction', }, ), - ); - }); + ) + }) - test("await errors", () => { - const anyError = "error"; + test('await errors', () => { + const anyError = 'error' const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return true; + return true }), await: mock((_: AwaitCapabilitiesRequest) => { - throw new Error(anyError); + throw new Error(anyError) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); + 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(anyError, { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: "PerformAction", + method: 'PerformAction', }), - ); - }); + ) + }) - test("await missing response", () => { + test('await missing response', () => { const helpers = createRuntimeHelpersMock({ call: mock((_: CapabilityRequest) => { - return true; + return true }), await: mock((_: AwaitCapabilitiesRequest) => { - return create(AwaitCapabilitiesResponseSchema, { responses: {} }); + return create(AwaitCapabilitiesResponseSchema, { responses: {} }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); + 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( @@ -299,61 +278,58 @@ describe("test runtime", () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: "PerformAction", + method: 'PerformAction', }, ), - ); - }); + ) + }) - test("await throws RESPONSE_BUFFER_TOO_SMALL when response exceeds max size", () => { + 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); + throw new Error(RESPONSE_BUFFER_TOO_SMALL) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); + 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); - }); + expect(() => call1.result()).toThrow(RESPONSE_BUFFER_TOO_SMALL) + }) - test("await returns unparsable payload throws CapabilityError", () => { + 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 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]; + const id = request.ids[0] return create(AwaitCapabilitiesResponseSchema, { responses: { [id]: create(CapabilityResponseSchema, { - response: { case: "payload", value: corruptPayload as Any }, + response: { case: 'payload', value: corruptPayload as Any }, }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const workflowAction1 = new BasicActionCapability(); + 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( @@ -361,91 +337,91 @@ describe("test runtime", () => { { callbackId: 1, capabilityId: BasicActionCapability.CAPABILITY_ID, - method: "PerformAction", + method: 'PerformAction', }, ), - ); - }); - }); -}); + ) + }) + }) +}) -describe("test now converts to date", () => { - test("now converts to date", () => { +describe('test now converts to date', () => { + test('now converts to date', () => { const helpers = createRuntimeHelpersMock({ now: mock(() => 1716153600000), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const now = runtime.now(); - expect(now).toEqual(new Date(1716153600000)); - }); -}); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const now = runtime.now() + expect(now).toEqual(new Date(1716153600000)) + }) +}) -describe("test getSecret", () => { - test("getSecrets returns ordered batched responses", () => { +describe('test getSecret', () => { + test('getSecrets returns ordered batched responses', () => { const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - expect(request.callbackId).toEqual(1); - expect(request.requests.length).toEqual(2); - expect(request.requests[0].id).toEqual("secret-1"); - expect(request.requests[1].id).toEqual("secret-2"); + expect(request.callbackId).toEqual(1) + expect(request.requests.length).toEqual(2) + expect(request.requests[0].id).toEqual('secret-1') + expect(request.requests[1].id).toEqual('secret-2') }), awaitSecrets: mock((request) => { - expect(request.ids.length).toEqual(1); - expect(request.ids[0]).toEqual(1); + expect(request.ids.length).toEqual(1) + expect(request.ids[0]).toEqual(1) return create(AwaitSecretsResponseSchema, { responses: { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { response: { - case: "secret", + case: 'secret', value: { - id: "secret-1", - namespace: "ns", - owner: "owner-1", - value: "value-1", + id: 'secret-1', + namespace: 'ns', + owner: 'owner-1', + value: 'value-1', }, }, }), create(SecretResponseSchema, { response: { - case: "secret", + case: 'secret', value: { - id: "secret-2", - namespace: "ns", - owner: "owner-2", - value: "value-2", + id: 'secret-2', + namespace: 'ns', + owner: 'owner-2', + value: 'value-2', }, }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const responses = runtime .getSecrets([ - { id: "secret-1", namespace: "ns" }, - { id: "secret-2", namespace: "ns" }, + { id: 'secret-1', namespace: 'ns' }, + { id: 'secret-2', namespace: 'ns' }, ]) - .result(); + .result() - expect(responses.length).toEqual(2); - expect(responses[0].response.case).toEqual("secret"); - expect(responses[1].response.case).toEqual("secret"); - if (responses[0].response.case === "secret") { - expect(responses[0].response.value.id).toEqual("secret-1"); + expect(responses.length).toEqual(2) + expect(responses[0].response.case).toEqual('secret') + expect(responses[1].response.case).toEqual('secret') + if (responses[0].response.case === 'secret') { + expect(responses[0].response.value.id).toEqual('secret-1') } - if (responses[1].response.case === "secret") { - expect(responses[1].response.value.id).toEqual("secret-2"); + if (responses[1].response.case === 'secret') { + expect(responses[1].response.value.id).toEqual('secret-2') } - }); + }) - test("getSecrets returns mixed success and error responses without throwing", () => { + test('getSecrets returns mixed success and error responses without throwing', () => { const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), awaitSecrets: mock(() => { @@ -455,65 +431,65 @@ describe("test getSecret", () => { responses: [ create(SecretResponseSchema, { response: { - case: "secret", + case: 'secret', value: { - id: "ok-secret", - namespace: "ns", - owner: "owner", - value: "ok-value", + id: 'ok-secret', + namespace: 'ns', + owner: 'owner', + value: 'ok-value', }, }, }), create(SecretResponseSchema, { response: { - case: "error", + case: 'error', value: { - id: "missing-secret", - namespace: "ns", - owner: "owner", - error: "secret not found", + id: 'missing-secret', + namespace: 'ns', + owner: 'owner', + error: 'secret not found', }, }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const responses = runtime .getSecrets([ - { id: "ok-secret", namespace: "ns" }, - { id: "missing-secret", namespace: "ns" }, + { id: 'ok-secret', namespace: 'ns' }, + { id: 'missing-secret', namespace: 'ns' }, ]) - .result(); + .result() - expect(responses.length).toEqual(2); - expect(responses[0].response.case).toEqual("secret"); - expect(responses[1].response.case).toEqual("error"); - }); + expect(responses.length).toEqual(2) + expect(responses[0].response.case).toEqual('secret') + expect(responses[1].response.case).toEqual('error') + }) - test("getSecrets throws SecretsBatchError when host getSecrets call fails", () => { + test('getSecrets throws SecretsBatchError when host getSecrets call fails', () => { const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => { - throw new Error("vault: signer unreachable"); + throw new Error('vault: signer unreachable') }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime .getSecrets([ - { id: "secret-a", namespace: "ns" }, - { id: "secret-b", namespace: "ns" }, + { id: 'secret-a', namespace: 'ns' }, + { id: 'secret-b', namespace: 'ns' }, ]) .result(), - ).toThrow(SecretsBatchError); - }); + ).toThrow(SecretsBatchError) + }) - test("getSecrets throws SecretsBatchError for malformed batched response envelope", () => { + test('getSecrets throws SecretsBatchError for malformed batched response envelope', () => { const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), awaitSecrets: mock(() => @@ -521,165 +497,165 @@ describe("test getSecret", () => { responses: {}, }), ), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime .getSecrets([ - { id: "secret-a", namespace: "ns" }, - { id: "secret-b", namespace: "ns" }, + { id: 'secret-a', namespace: 'ns' }, + { id: 'secret-b', namespace: 'ns' }, ]) .result(), - ).toThrow(SecretsBatchError); - }); + ).toThrow(SecretsBatchError) + }) - test("successfully gets secret with SecretRequest (proto message)", () => { + test('successfully gets secret with SecretRequest (proto message)', () => { const secretRequest = create(SecretRequestSchema, { - id: "my-secret", - namespace: "test-ns", - }); + id: 'my-secret', + namespace: 'test-ns', + }) const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - expect(request.callbackId).toEqual(1); - expect(request.requests.length).toEqual(1); + expect(request.callbackId).toEqual(1) + expect(request.requests.length).toEqual(1) }), awaitSecrets: mock((request) => { - expect(request.ids.length).toEqual(1); - expect(request.ids[0]).toEqual(1); + expect(request.ids.length).toEqual(1) + expect(request.ids[0]).toEqual(1) return create(AwaitSecretsResponseSchema, { responses: { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { response: { - case: "secret", + case: 'secret', value: { - id: "my-secret", - namespace: "test-ns", - owner: "test-owner", - value: "secret-value-123", + id: 'my-secret', + namespace: 'test-ns', + owner: 'test-owner', + value: 'secret-value-123', }, }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const result = runtime.getSecret(secretRequest).result(); - expect(result.id).toEqual("my-secret"); - expect(result.namespace).toEqual("test-ns"); - expect(result.value).toEqual("secret-value-123"); - }); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const result = runtime.getSecret(secretRequest).result() + expect(result.id).toEqual('my-secret') + expect(result.namespace).toEqual('test-ns') + expect(result.value).toEqual('secret-value-123') + }) - test("successfully gets secret with SecretRequestJson (plain JSON)", () => { - const secretRequestJson = { id: "another-secret", namespace: "another-ns" }; + test('successfully gets secret with SecretRequestJson (plain JSON)', () => { + const secretRequestJson = { id: 'another-secret', namespace: 'another-ns' } const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - expect(request.callbackId).toEqual(1); - expect(request.requests.length).toEqual(1); + expect(request.callbackId).toEqual(1) + expect(request.requests.length).toEqual(1) }), awaitSecrets: mock((request) => { - expect(request.ids.length).toEqual(1); - expect(request.ids[0]).toEqual(1); + expect(request.ids.length).toEqual(1) + expect(request.ids[0]).toEqual(1) return create(AwaitSecretsResponseSchema, { responses: { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { response: { - case: "secret", + case: 'secret', value: { - id: "another-secret", - namespace: "another-ns", - owner: "another-owner", - value: "value-456", + id: 'another-secret', + namespace: 'another-ns', + owner: 'another-owner', + value: 'value-456', }, }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - const result = runtime.getSecret(secretRequestJson).result(); - expect(result.id).toEqual("another-secret"); - expect(result.namespace).toEqual("another-ns"); - expect(result.value).toEqual("value-456"); - }); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + const result = runtime.getSecret(secretRequestJson).result() + expect(result.id).toEqual('another-secret') + expect(result.namespace).toEqual('another-ns') + expect(result.value).toEqual('value-456') + }) - test("getSecrets throws → wrapped as SecretsError", () => { + test('getSecrets throws → wrapped as SecretsError', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); + id: 'test-secret', + namespace: 'test-ns', + }) const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => { - throw new Error("vault: signer unreachable"); + throw new Error('vault: signer unreachable') }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, "vault: signer unreachable"), - ); - }); + new SecretsError(secretRequest, 'vault: signer unreachable'), + ) + }) - test("awaitSecrets throws → wrapped as SecretsError", () => { + test('awaitSecrets throws → wrapped as SecretsError', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); + id: 'test-secret', + namespace: 'test-ns', + }) const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), awaitSecrets: mock(() => { - throw new Error("vault: timeout fetching secret"); + throw new Error('vault: timeout fetching secret') }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, "vault: timeout fetching secret"), - ); - }); + new SecretsError(secretRequest, 'vault: timeout fetching secret'), + ) + }) - test("awaitSecrets returns no response for callback ID", () => { + test('awaitSecrets returns no response for callback ID', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); + id: 'test-secret', + namespace: 'test-ns', + }) const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), awaitSecrets: mock(() => { return create(AwaitSecretsResponseSchema, { responses: {}, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, "no response"), - ); - }); + new SecretsError(secretRequest, 'no response'), + ) + }) - test("awaitSecrets returns invalid number of responses", () => { + test('awaitSecrets returns invalid number of responses', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); + id: 'test-secret', + namespace: 'test-ns', + }) const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -690,27 +666,27 @@ describe("test getSecret", () => { responses: [], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, "invalid value returned from host"), - ); - }); + new SecretsError(secretRequest, 'invalid value returned from host'), + ) + }) - test("awaitSecrets returns too many responses", () => { + test('awaitSecrets returns too many responses', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); + id: 'test-secret', + namespace: 'test-ns', + }) const secretValue = { - id: "secret1", - namespace: "test-ns", - owner: "test-owner", - value: "value1", - }; + id: 'secret1', + namespace: 'test-ns', + owner: 'test-owner', + value: 'value1', + } const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -720,30 +696,30 @@ describe("test getSecret", () => { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: "secret", value: secretValue }, + response: { case: 'secret', value: secretValue }, }), create(SecretResponseSchema, { - response: { case: "secret", value: secretValue }, + response: { case: 'secret', value: secretValue }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError(secretRequest, "invalid value returned from host"), - ); - }); + new SecretsError(secretRequest, 'invalid value returned from host'), + ) + }) - test("awaitSecrets returns error response", () => { + test('awaitSecrets returns error response', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); - const errorMessage = "secret not found"; + id: 'test-secret', + namespace: 'test-ns', + }) + const errorMessage = 'secret not found' const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -753,26 +729,26 @@ describe("test getSecret", () => { 1: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: "error", value: { error: errorMessage } }, + response: { case: 'error', value: { error: errorMessage } }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( new SecretsError(secretRequest, errorMessage), - ); - }); + ) + }) - test("awaitSecrets returns unknown response case", () => { + test('awaitSecrets returns unknown response case', () => { const secretRequest = create(SecretRequestSchema, { - id: "test-secret", - namespace: "test-ns", - }); + id: 'test-secret', + namespace: 'test-ns', + }) const helpers = createRuntimeHelpersMock({ getSecrets: mock(() => undefined), @@ -787,582 +763,504 @@ describe("test getSecret", () => { ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) expect(() => runtime.getSecret(secretRequest).result()).toThrow( - new SecretsError( - secretRequest, - "cannot unmarshal returned value from host", - ), - ); - }); + new SecretsError(secretRequest, 'cannot unmarshal returned value from host'), + ) + }) - test("getSecret increments callback ID correctly", () => { - const callbackIds: number[] = []; + test('getSecret increments callback ID correctly', () => { + const callbackIds: number[] = [] const secretValue = { - id: "secret", - namespace: "test-ns", - owner: "test-owner", - value: "value", - }; + id: 'secret', + namespace: 'test-ns', + owner: 'test-owner', + value: 'value', + } const helpers = createRuntimeHelpersMock({ getSecrets: mock((request) => { - callbackIds.push(request.callbackId); + callbackIds.push(request.callbackId) }), awaitSecrets: mock((request) => { - const id = request.ids[0]; + const id = request.ids[0] return create(AwaitSecretsResponseSchema, { responses: { [id]: create(SecretResponsesSchema, { responses: [ create(SecretResponseSchema, { - response: { case: "secret", value: secretValue }, + response: { case: 'secret', value: secretValue }, }), ], }), }, - }); + }) }), - }); + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) - runtime.getSecret({ id: "secret1", namespace: "ns1" }).result(); - runtime.getSecret({ id: "secret2", namespace: "ns2" }).result(); - runtime.getSecret({ id: "secret3", namespace: "ns3" }).result(); + runtime.getSecret({ id: 'secret1', namespace: 'ns1' }).result() + runtime.getSecret({ id: 'secret2', namespace: 'ns2' }).result() + runtime.getSecret({ id: 'secret3', namespace: 'ns3' }).result() - expect(callbackIds).toEqual([1, 2, 3]); - }); + expect(callbackIds).toEqual([1, 2, 3]) + }) - test("getSecret in node mode throws DonModeError", () => { - const helpers = createRuntimeHelpersMock(); + test('getSecret in node mode throws DonModeError', () => { + const helpers = createRuntimeHelpersMock() ConsensusCapability.prototype.simple = mock(() => { - return { result: () => Value.from(0).proto() }; - }); + return { result: () => Value.from(0).proto() } + }) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - let capturedError: Error | undefined; + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + let capturedError: Error | undefined runtime.runInNodeMode((_nodeRuntime: NodeRuntime) => { // Try to call getSecret from within node mode (should fail) try { - runtime.getSecret({ id: "test", namespace: "test-ns" }).result(); + runtime.getSecret({ id: 'test', namespace: 'test-ns' }).result() } catch (e) { - capturedError = e as Error; + capturedError = e as Error } - return 0; - }, consensusMedianAggregation())(); - - expect(capturedError).toBeDefined(); - expect(capturedError).toBeInstanceOf(DonModeError); - }); -}); - -describe("test run in node mode", () => { - test("successful consensus", () => { - const anyObservation = 10; - const anyMedian = 11; - const modes: Mode[] = []; + return 0 + }, consensusMedianAggregation())() + + expect(capturedError).toBeDefined() + expect(capturedError).toBeInstanceOf(DonModeError) + }) +}) + +describe('test run in node mode', () => { + test('successful consensus', () => { + const anyObservation = 10 + const anyMedian = 11 + const modes: Mode[] = [] const helpers = createRuntimeHelpersMock({ switchModes: mock((mode: Mode) => { - modes.push(mode); + modes.push(mode) }), - }); + }) ConsensusCapability.prototype.simple = mock( - ( - _: Runtime, - inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, - ) => { - expect(modes).toEqual([Mode.DON, Mode.NODE, Mode.DON]); - expect(inputs.default).toBeUndefined(); + (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + expect(modes).toEqual([Mode.DON, Mode.NODE, Mode.DON]) + expect(inputs.default).toBeUndefined() const consensusDescriptor = create(ConsensusDescriptorSchema, { descriptor: { - case: "fieldsMap", + case: 'fieldsMap', value: create(FieldsMapSchema, { fields: { outputThing: create(ConsensusDescriptorSchema, { descriptor: { - case: "aggregation", + case: 'aggregation', value: AggregationType.MEDIAN, }, }), }, }), }, - }); - expect(inputs.descriptors).toEqual(consensusDescriptor); - expect( - (inputs as { $typeName?: string }).$typeName, - ).not.toBeUndefined(); - const inputsProto = inputs as SimpleConsensusInputs; - expect(inputsProto.observation.case).toEqual("value"); + }) + expect(inputs.descriptors).toEqual(consensusDescriptor) + expect((inputs as { $typeName?: string }).$typeName).not.toBeUndefined() + const inputsProto = inputs as SimpleConsensusInputs + expect(inputsProto.observation.case).toEqual('value') expect( Value.wrap(inputsProto.observation.value as ProtoValue).unwrapToType({ factory: () => create(NodeOutputsSchema), }).outputThing, - ).toEqual(anyObservation); + ).toEqual(anyObservation) return { - result: () => - Value.from( - create(NodeOutputsSchema, { outputThing: anyMedian }), - ).proto(), - }; + result: () => Value.from(create(NodeOutputsSchema, { outputThing: anyMedian })).proto(), + } }, - ); + ) // Create a mock that handles both overloads properly - const performActionMock = function ( - this: NodeActionCapability, - ...args: unknown[] - ): unknown { + const performActionMock = function (this: NodeActionCapability, ...args: unknown[]): unknown { // Check if this is the sugar syntax overload (has function parameter) - if (typeof args[0] === "function") { + if (typeof args[0] === 'function') { // This test doesn't expect sugar syntax to be used - throw new Error("Sugar syntax should not be used in this test"); + throw new Error('Sugar syntax should not be used in this test') } // Otherwise, this is the basic call overload - const [_, __] = args as [ - NodeRuntime, - NodeInputs | NodeInputsJson, - ]; - expect(modes).toEqual([Mode.DON, Mode.NODE]); + const [_, __] = args as [NodeRuntime, NodeInputs | NodeInputsJson] + expect(modes).toEqual([Mode.DON, Mode.NODE]) return { - result: () => - create(NodeOutputsSchema, { outputThing: anyObservation }), - }; - }; + result: () => create(NodeOutputsSchema, { outputThing: anyObservation }), + } + } // Apply the mock with proper typing // biome-ignore lint/suspicious/noExplicitAny: Mock assignment requires any due to overloaded function signature - (NodeActionCapability.prototype as any).performAction = - mock(performActionMock); + ;(NodeActionCapability.prototype as any).performAction = mock(performActionMock) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const result = runtime .runInNodeMode( (nodeRuntime: NodeRuntime) => { - const capability = new NodeActionCapability(); + const capability = new NodeActionCapability() return capability - .performAction( - nodeRuntime, - create(NodeInputsSchema, { inputThing: true }), - ) - .result(); + .performAction(nodeRuntime, create(NodeInputsSchema, { inputThing: true })) + .result() }, ConsensusAggregationByFields({ outputThing: median }), )() - .result(); + .result() - expect(result.outputThing).toEqual(anyMedian); - }); + expect(result.outputThing).toEqual(anyMedian) + }) - test("failed consensus", () => { - const anyError = "error"; + test('failed consensus', () => { + const anyError = 'error' const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), - }); + }) ConsensusCapability.prototype.simple = mock( - ( - _: Runtime, - inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, - ) => { - expect(inputs.default).toBeUndefined(); + (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + expect(inputs.default).toBeUndefined() expect(inputs.descriptors).toEqual( create(ConsensusDescriptorSchema, { - descriptor: { case: "aggregation", value: AggregationType.MEDIAN }, + descriptor: { case: 'aggregation', value: AggregationType.MEDIAN }, }), - ); - expect( - (inputs as { $typeName?: string }).$typeName, - ).not.toBeUndefined(); - const inputsProto = inputs as SimpleConsensusInputs; - expect(inputsProto.observation.case).toEqual("error"); - expect(inputsProto.observation.value).toEqual(anyError); + ) + expect((inputs as { $typeName?: string }).$typeName).not.toBeUndefined() + const inputsProto = inputs as SimpleConsensusInputs + expect(inputsProto.observation.case).toEqual('error') + expect(inputsProto.observation.value).toEqual(anyError) return { result: () => { - throw new Error(anyError); + throw new Error(anyError) }, - }; + } }, - ); + ) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const result = runtime.runInNodeMode((_: NodeRuntime) => { - throw new Error(anyError); - }, consensusMedianAggregation())(); - expect(() => result.result()).toThrow(new Error(anyError)); - }); - - test("primitive consensus with unused default returns observation value", () => { - const observationValue = 99; - const defaultValue = 100; + throw new Error(anyError) + }, consensusMedianAggregation())() + 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, - ); + (_: 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 runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const result = runtime .runInNodeMode( (_: NodeRuntime) => observationValue, consensusMedianAggregation().withDefault(defaultValue), )() - .result(); + .result() - expect(result).toEqual(observationValue); - }); + expect(result).toEqual(observationValue) + }) - test("primitive consensus with used default returns default when function errors", () => { - const defaultVal = 100; - const anyError = "error"; + 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, - ); + (_: 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 runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const result = runtime .runInNodeMode((_: NodeRuntime) => { - throw new Error(anyError); + throw new Error(anyError) }, consensusMedianAggregation().withDefault(defaultVal))() - .result(); + .result() - expect(result).toEqual(defaultVal); - }); + expect(result).toEqual(defaultVal) + }) - test("node runtime in don mode fails", () => { + test('node runtime in don mode fails', () => { const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), call: mock((_: CapabilityRequest) => { - expect(false).toBe(true); - return false; + expect(false).toBe(true) + return false }), - }); + }) ConsensusCapability.prototype.simple = mock( - ( - _: Runtime, - __: SimpleConsensusInputs | SimpleConsensusInputsJson, - ) => { - return { result: () => Value.from(0).proto() }; + (_: Runtime, __: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + return { result: () => Value.from(0).proto() } }, - ); + ) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); - var nrt: NodeRuntime | undefined; + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) + var nrt: NodeRuntime | undefined runtime.runInNodeMode((nodeRuntime: NodeRuntime) => { - nrt = nodeRuntime; - return 0; - }, consensusMedianAggregation())(); + nrt = nodeRuntime + return 0 + }, consensusMedianAggregation())() - const capability = new NodeActionCapability(); - expect(nrt).toBeDefined(); + const capability = new NodeActionCapability() + expect(nrt).toBeDefined() expect(() => capability - .performAction( - nrt as NodeRuntime, - create(NodeInputsSchema, { inputThing: true }), - ) + .performAction(nrt as NodeRuntime, create(NodeInputsSchema, { inputThing: true })) .result(), - ).toThrow(new NodeModeError()); - }); + ).toThrow(new NodeModeError()) + }) - test("don runtime in node mode fails", () => { + test('don runtime in node mode fails', () => { const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), - }); + }) ConsensusCapability.prototype.simple = mock( - ( - _: Runtime, - inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, - ) => { - expect(inputs.default).toBeUndefined(); + (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + expect(inputs.default).toBeUndefined() expect(inputs.descriptors).toEqual( create(ConsensusDescriptorSchema, { - descriptor: { case: "aggregation", value: AggregationType.MEDIAN }, + descriptor: { case: 'aggregation', value: AggregationType.MEDIAN }, }), - ); - expect( - (inputs as { $typeName?: string }).$typeName, - ).not.toBeUndefined(); - const inputsProto = inputs as SimpleConsensusInputs; - expect(inputsProto.observation.case).toEqual("error"); - expect(inputsProto.observation.value).toEqual( - new DonModeError().message, - ); + ) + expect((inputs as { $typeName?: string }).$typeName).not.toBeUndefined() + const inputsProto = inputs as SimpleConsensusInputs + expect(inputsProto.observation.case).toEqual('error') + expect(inputsProto.observation.value).toEqual(new DonModeError().message) return { result: () => { - throw new DonModeError(); + throw new DonModeError() }, - }; + } }, - ); + ) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const result = runtime.runInNodeMode((_: NodeRuntime) => { - const capability = new BasicActionCapability(); - capability - .performAction(runtime, create(InputsSchema, { inputThing: true })) - .result(); - return 0; - }, consensusMedianAggregation())(); - expect(() => result.result()).toThrow(new DonModeError()); - }); - - test("multiple runInNodeMode calls have unique callback IDs", () => { - const callbackIds: number[] = []; + const capability = new BasicActionCapability() + capability.performAction(runtime, create(InputsSchema, { inputThing: true })).result() + return 0 + }, consensusMedianAggregation())() + expect(() => result.result()).toThrow(new DonModeError()) + }) + + test('multiple runInNodeMode calls have unique callback IDs', () => { + const callbackIds: number[] = [] const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), call: mock((request: CapabilityRequest) => { - callbackIds.push(request.callbackId); - return true; + callbackIds.push(request.callbackId) + return true }), await: mock((request: AwaitCapabilitiesRequest) => { - const id = request.ids[0]; + const id = request.ids[0] return create(AwaitCapabilitiesResponseSchema, { responses: { [id]: create(CapabilityResponseSchema, { response: { - case: "payload", - value: anyPack( - NodeOutputsSchema, - create(NodeOutputsSchema, { outputThing: 42 }), - ), + case: 'payload', + value: anyPack(NodeOutputsSchema, create(NodeOutputsSchema, { outputThing: 42 })), }, }), }, - }); + }) }), - }); + }) ConsensusCapability.prototype.simple = mock( - ( - _: Runtime, - __: SimpleConsensusInputs | SimpleConsensusInputsJson, - ) => { + (_: Runtime, __: SimpleConsensusInputs | SimpleConsensusInputsJson) => { return { - result: () => - Value.from(create(NodeOutputsSchema, { outputThing: 42 })).proto(), - }; + result: () => Value.from(create(NodeOutputsSchema, { outputThing: 42 })).proto(), + } }, - ); + ) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) // First runInNodeMode call with capability inside const call1 = runtime.runInNodeMode( (nodeRuntime: NodeRuntime) => { - const capability = new NodeActionCapability(); + const capability = new NodeActionCapability() return capability - .performAction( - nodeRuntime, - create(NodeInputsSchema, { inputThing: true }), - ) - .result(); + .performAction(nodeRuntime, create(NodeInputsSchema, { inputThing: true })) + .result() }, ConsensusAggregationByFields({ outputThing: median }), - ); + ) - call1().result(); + call1().result() // Second runInNodeMode call with capability inside const call2 = runtime.runInNodeMode( (nodeRuntime: NodeRuntime) => { - const capability = new NodeActionCapability(); + const capability = new NodeActionCapability() return capability - .performAction( - nodeRuntime, - create(NodeInputsSchema, { inputThing: false }), - ) - .result(); + .performAction(nodeRuntime, create(NodeInputsSchema, { inputThing: false })) + .result() }, ConsensusAggregationByFields({ outputThing: median }), - ); + ) - call2().result(); + call2().result() // Verify that we have two distinct callback IDs - expect(callbackIds.length).toEqual(2); - expect(callbackIds[0]).toEqual(-1); // First node mode call - expect(callbackIds[1]).toEqual(-2); // Second node mode call + expect(callbackIds.length).toEqual(2) + expect(callbackIds[0]).toEqual(-1) // First node mode call + expect(callbackIds[1]).toEqual(-2) // Second node mode call // Ensure they are different (no reuse/collision) - expect(callbackIds[0]).not.toEqual(callbackIds[1]); - }); + expect(callbackIds[0]).not.toEqual(callbackIds[1]) + }) - test("clears ignored fields from default and response values", () => { + test('clears ignored fields from default and response values', () => { type NestedStruct = { - nestedIncluded: string; - nestedIgnored: string; - }; + nestedIncluded: string + nestedIgnored: string + } type TestStruct = { - includedField: string; - ignoredField: string; - nested: NestedStruct; - }; + includedField: string + ignoredField: string + nested: NestedStruct + } const defaultVal: TestStruct = { - includedField: "default_included", - ignoredField: "default_ignored", + includedField: 'default_included', + ignoredField: 'default_ignored', nested: { - nestedIncluded: "default_nested_included", - nestedIgnored: "default_nested_ignored", + nestedIncluded: 'default_nested_included', + nestedIgnored: 'default_nested_ignored', }, - }; + } const responseVal: TestStruct = { - includedField: "response_included", - ignoredField: "response_ignored", + includedField: 'response_included', + ignoredField: 'response_ignored', nested: { - nestedIncluded: "response_nested_included", - nestedIgnored: "response_nested_ignored", + nestedIncluded: 'response_nested_included', + nestedIgnored: 'response_nested_ignored', }, - }; + } const helpers = createRuntimeHelpersMock({ switchModes: mock((_: Mode) => {}), - }); + }) ConsensusCapability.prototype.simple = mock( - ( - _: Runtime, - inputs: SimpleConsensusInputs | SimpleConsensusInputsJson, - ) => { - const inputsProto = inputs as SimpleConsensusInputs; - if (inputsProto.observation.case === "value") { + (_: Runtime, inputs: SimpleConsensusInputs | SimpleConsensusInputsJson) => { + const inputsProto = inputs as SimpleConsensusInputs + if (inputsProto.observation.case === 'value') { const unwrapped = Value.wrap( inputsProto.observation.value as ProtoValue, - ).unwrap() as TestStruct; - expect(unwrapped.includedField).toEqual("response_included"); - expect(unwrapped.ignoredField).toBeUndefined(); - expect(unwrapped.nested.nestedIncluded).toEqual( - "response_nested_included", - ); - expect(unwrapped.nested.nestedIgnored).toBeUndefined(); + ).unwrap() as TestStruct + expect(unwrapped.includedField).toEqual('response_included') + expect(unwrapped.ignoredField).toBeUndefined() + expect(unwrapped.nested.nestedIncluded).toEqual('response_nested_included') + expect(unwrapped.nested.nestedIgnored).toBeUndefined() return { result: () => inputsProto.observation.value as ProtoValue, - }; + } } if (inputsProto.default) { - const unwrapped = Value.wrap( - inputsProto.default as ProtoValue, - ).unwrap() as TestStruct; - expect(unwrapped.includedField).toEqual("default_included"); - expect(unwrapped.ignoredField).toBeUndefined(); - expect(unwrapped.nested.nestedIncluded).toEqual( - "default_nested_included", - ); - expect(unwrapped.nested.nestedIgnored).toBeUndefined(); + const unwrapped = Value.wrap(inputsProto.default as ProtoValue).unwrap() as TestStruct + expect(unwrapped.includedField).toEqual('default_included') + expect(unwrapped.ignoredField).toBeUndefined() + expect(unwrapped.nested.nestedIncluded).toEqual('default_nested_included') + expect(unwrapped.nested.nestedIgnored).toBeUndefined() return { result: () => inputsProto.default as ProtoValue, - }; + } } - if (inputsProto.observation.case === "error") { + if (inputsProto.observation.case === 'error') { return { result: () => { - throw new Error(inputsProto.observation.value as string); + throw new Error(inputsProto.observation.value as string) }, - }; + } } return { result: () => { - throw new Error("unexpected case"); + throw new Error('unexpected case') }, - }; + } }, - ); + ) - const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize); + const runtime = new RuntimeImpl({}, 1, helpers, anyMaxSize) const nestedAggregation = ConsensusAggregationByFields({ nestedIncluded: identical, nestedIgnored: ignore, - }); + }) const result = runtime .runInNodeMode( (_nodeRuntime: NodeRuntime) => { - return responseVal; + return responseVal }, ConsensusAggregationByFields({ includedField: identical, ignoredField: ignore, nested: () => - new ConsensusFieldAggregation( - nestedAggregation.descriptor, - ), + new ConsensusFieldAggregation(nestedAggregation.descriptor), }).withDefault(defaultVal), )() - .result(); + .result() - expect(result.includedField).toEqual("response_included"); - expect(result.ignoredField).toBeUndefined(); - expect(result.nested.nestedIncluded).toEqual("response_nested_included"); - expect(result.nested.nestedIgnored).toBeUndefined(); + expect(result.includedField).toEqual('response_included') + expect(result.ignoredField).toBeUndefined() + expect(result.nested.nestedIncluded).toEqual('response_nested_included') + expect(result.nested.nestedIgnored).toBeUndefined() const result2 = runtime .runInNodeMode( (_nodeRuntime: NodeRuntime) => { - throw new Error("error"); + throw new Error('error') }, ConsensusAggregationByFields({ includedField: identical, ignoredField: ignore, nested: () => - new ConsensusFieldAggregation( - nestedAggregation.descriptor, - ), + new ConsensusFieldAggregation(nestedAggregation.descriptor), }).withDefault(defaultVal), )() - .result(); - - expect(result2.includedField).toEqual("default_included"); - expect(result2.ignoredField).toBeUndefined(); - expect(result2.nested.nestedIncluded).toEqual("default_nested_included"); - expect(result2.nested.nestedIgnored).toBeUndefined(); - }); -}); + .result() + + expect(result2.includedField).toEqual('default_included') + expect(result2.ignoredField).toBeUndefined() + expect(result2.nested.nestedIncluded).toEqual('default_nested_included') + expect(result2.nested.nestedIgnored).toBeUndefined() + }) +}) diff --git a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts index 0b5690fd..6b5db062 100644 --- a/packages/cre-sdk/src/sdk/impl/runtime-impl.ts +++ b/packages/cre-sdk/src/sdk/impl/runtime-impl.ts @@ -1,6 +1,6 @@ -import { create, type Message } from "@bufbuild/protobuf"; -import type { GenMessage } from "@bufbuild/protobuf/codegenv2"; -import { type Any, anyPack, anyUnpack } from "@bufbuild/protobuf/wkt"; +import { create, type Message } from '@bufbuild/protobuf' +import type { GenMessage } from '@bufbuild/protobuf/codegenv2' +import { type Any, anyPack, anyUnpack } from '@bufbuild/protobuf/wkt' import { type AwaitCapabilitiesRequest, AwaitCapabilitiesRequestSchema, @@ -20,9 +20,9 @@ import { SecretRequestSchema, type SecretResponse, SimpleConsensusInputsSchema, -} from "@cre/generated/sdk/v1alpha/sdk_pb"; -import type { Value as ProtoValue } from "@cre/generated/values/v1/values_pb"; -import { ConsensusCapability } from "@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen"; +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' +import { ConsensusCapability } from '@cre/generated-sdk/capabilities/internal/consensus/v1alpha/consensus_sdk_gen' import type { BaseRuntime, CallCapabilityParams, @@ -30,22 +30,17 @@ import type { ReportRequest, ReportRequestJson, Runtime, -} from "@cre/sdk"; -import type { Report } from "@cre/sdk/report"; +} from '@cre/sdk' +import type { Report } from '@cre/sdk/report' import { type ConsensusAggregation, type CreSerializable, type PrimitiveTypes, type UnwrapOptions, Value, -} from "@cre/sdk/utils"; -import { CapabilityError } from "@cre/sdk/utils/capabilities/capability-error"; -import { - DonModeError, - NodeModeError, - SecretsBatchError, - SecretsError, -} from "../errors"; +} from '@cre/sdk/utils' +import { CapabilityError } from '@cre/sdk/utils/capabilities/capability-error' +import { DonModeError, NodeModeError, SecretsBatchError, SecretsError } from '../errors' /** * Base implementation shared by DON and Node runtimes. @@ -61,7 +56,7 @@ export class BaseRuntimeImpl implements BaseRuntime { * - Set in DON mode when code tries to use NodeRuntime * - Set in Node mode when code tries to use Runtime */ - public modeError?: Error; + public modeError?: Error constructor( public config: C, @@ -86,22 +81,22 @@ export class BaseRuntimeImpl implements BaseRuntime { if (this.modeError) { return { result: () => { - throw this.modeError; + throw this.modeError }, - }; + } } // Allocate unique callback ID for this request - const callbackId = this.allocateCallbackId(); + const callbackId = this.allocateCallbackId() // Send request to WASM host - const anyPayload = anyPack(inputSchema, payload); + const anyPayload = anyPack(inputSchema, payload) const req = create(CapabilityRequestSchema, { id: capabilityId, method, payload: anyPayload, callbackId, - }); + }) if (!this.helpers.call(req)) { return { @@ -113,21 +108,16 @@ export class BaseRuntimeImpl implements BaseRuntime { method, capabilityId, }, - ); + ) }, - }; + } } // Return lazy result - await and unwrap when .result() is called return { result: () => - this.awaitAndUnwrapCapabilityResponse( - callbackId, - capabilityId, - method, - outputSchema, - ), - }; + this.awaitAndUnwrapCapabilityResponse(callbackId, capabilityId, method, outputSchema), + } } /** @@ -135,13 +125,13 @@ export class BaseRuntimeImpl implements BaseRuntime { * DON mode increments, Node mode decrements (prevents collisions). */ private allocateCallbackId(): number { - const callbackId = this.nextCallId; + const callbackId = this.nextCallId if (this.mode === Mode.DON) { - this.nextCallId++; + this.nextCallId++ } else { - this.nextCallId--; + this.nextCallId-- } - return callbackId; + return callbackId } /** @@ -155,12 +145,9 @@ export class BaseRuntimeImpl implements BaseRuntime { ): O { const awaitRequest = create(AwaitCapabilitiesRequestSchema, { ids: [callbackId], - }); - const awaitResponse = this.helpers.await( - awaitRequest, - this.maxResponseSize, - ); - const capabilityResponse = awaitResponse.responses[callbackId]; + }) + const awaitResponse = this.helpers.await(awaitRequest, this.maxResponseSize) + const capabilityResponse = awaitResponse.responses[callbackId] if (!capabilityResponse) { throw new CapabilityError( @@ -170,14 +157,14 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ); + ) } - const response = capabilityResponse.response; + const response = capabilityResponse.response switch (response.case) { - case "payload": { + case 'payload': { try { - return anyUnpack(response.value as Any, outputSchema) as O; + return anyUnpack(response.value as Any, outputSchema) as O } catch { throw new CapabilityError( `Failed to deserialize response payload for capability '${capabilityId}' method '${method}': the response could not be unpacked into the expected output schema`, @@ -186,10 +173,10 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ); + ) } } - case "error": + case 'error': throw new CapabilityError( `Capability '${capabilityId}' method '${method}' returned an error: ${response.value}`, { @@ -197,7 +184,7 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ); + ) default: throw new CapabilityError( `Unexpected response type '${response.case}' for capability '${capabilityId}' method '${method}': expected 'payload' or 'error'`, @@ -206,21 +193,21 @@ export class BaseRuntimeImpl implements BaseRuntime { method, callbackId, }, - ); + ) } } getNextCallId(): number { - return this.nextCallId; + return this.nextCallId } now(): Date { // date is already in milliseconds - return new Date(this.helpers.now()); + return new Date(this.helpers.now()) } log(message: string): void { - this.helpers.log(message); + this.helpers.log(message) } } @@ -232,20 +219,12 @@ export class BaseRuntimeImpl implements BaseRuntime { * Useful in situation where you already expect non-determinism (e.g., inherently variable HTTP responses). * Switching from Node Mode back to DON mode requires workflow authors to handle consensus themselves. */ -export class NodeRuntimeImpl - extends BaseRuntimeImpl - implements NodeRuntime -{ - _isNodeRuntime: true = true; +export class NodeRuntimeImpl extends BaseRuntimeImpl implements NodeRuntime { + _isNodeRuntime: true = true - constructor( - config: C, - nextCallId: number, - helpers: RuntimeHelpers, - maxResponseSize: bigint, - ) { - helpers.switchModes(Mode.NODE); - super(config, nextCallId, helpers, maxResponseSize, Mode.NODE); + constructor(config: C, nextCallId: number, helpers: RuntimeHelpers, maxResponseSize: bigint) { + helpers.switchModes(Mode.NODE) + super(config, nextCallId, helpers, maxResponseSize, Mode.NODE) } } @@ -254,16 +233,11 @@ export class NodeRuntimeImpl * You ask the network to execute something, and CRE handles the underlying complexity to ensure you get back one final, secure, and trustworthy result. */ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { - private nextNodeCallId: number = -1; + private nextNodeCallId: number = -1 - constructor( - config: C, - nextCallId: number, - helpers: RuntimeHelpers, - maxResponseSize: bigint, - ) { - helpers.switchModes(Mode.DON); - super(config, nextCallId, helpers, maxResponseSize, Mode.DON); + constructor(config: C, nextCallId: number, helpers: RuntimeHelpers, maxResponseSize: bigint) { + helpers.switchModes(Mode.DON) + super(config, nextCallId, helpers, maxResponseSize, Mode.DON) } /** @@ -280,41 +254,35 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { runInNodeMode( fn: (nodeRuntime: NodeRuntime, ...args: TArgs) => TOutput, consensusAggregation: ConsensusAggregation, - unwrapOptions?: TOutput extends PrimitiveTypes - ? never - : UnwrapOptions, + unwrapOptions?: TOutput extends PrimitiveTypes ? never : UnwrapOptions, ): (...args: TArgs) => { result: () => TOutput } { return (...args: TArgs): { result: () => TOutput } => { // Step 1: Create node runtime and prevent DON operations - this.modeError = new DonModeError(); + this.modeError = new DonModeError() const nodeRuntime = new NodeRuntimeImpl( this.config, this.nextNodeCallId, this.helpers, this.maxResponseSize, - ); + ) // Step 2: Prepare consensus input with config - const consensusInput = this.prepareConsensusInput(consensusAggregation); + const consensusInput = this.prepareConsensusInput(consensusAggregation) // Step 3: Execute node function and capture result/error try { - const observation = fn(nodeRuntime, ...args); - this.captureObservation( - consensusInput, - observation, - consensusAggregation.descriptor, - ); + const observation = fn(nodeRuntime, ...args) + this.captureObservation(consensusInput, observation, consensusAggregation.descriptor) } catch (e: unknown) { - this.captureError(consensusInput, e); + this.captureError(consensusInput, e) } finally { // Step 4: Always restore DON mode - this.restoreDonMode(nodeRuntime); + this.restoreDonMode(nodeRuntime) } // Step 5: Run consensus and return lazy result - return this.runConsensusAndWrap(consensusInput, unwrapOptions); - }; + return this.runConsensusAndWrap(consensusInput, unwrapOptions) + } } private prepareConsensusInput( @@ -322,18 +290,18 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { ) { const consensusInput = create(SimpleConsensusInputsSchema, { descriptors: consensusAggregation.descriptor, - }); + }) if (consensusAggregation.defaultValue) { // Safe cast: ConsensusAggregation implies T extends CreSerializable const defaultValue = Value.from( consensusAggregation.defaultValue as CreSerializable, - ).proto(); - clearIgnoredFields(defaultValue, consensusAggregation.descriptor); - consensusInput.default = defaultValue; + ).proto() + clearIgnoredFields(defaultValue, consensusAggregation.descriptor) + consensusInput.default = defaultValue } - return consensusInput; + return consensusInput } private captureObservation( @@ -342,61 +310,57 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { descriptor: ConsensusDescriptor, ) { // Safe cast: ConsensusAggregation implies T extends CreSerializable - const observationValue = Value.from( - observation as CreSerializable, - ).proto(); - clearIgnoredFields(observationValue, descriptor); + const observationValue = Value.from(observation as CreSerializable).proto() + clearIgnoredFields(observationValue, descriptor) consensusInput.observation = { - case: "value", + case: 'value', value: observationValue, - }; + } } private captureError(consensusInput: any, e: unknown) { consensusInput.observation = { - case: "error", + case: 'error', value: (e instanceof Error && e.message) || String(e), - }; + } } private restoreDonMode(nodeRuntime: NodeRuntimeImpl) { - this.modeError = undefined; - this.nextNodeCallId = nodeRuntime.nextCallId; - nodeRuntime.modeError = new NodeModeError(); - this.helpers.switchModes(Mode.DON); + this.modeError = undefined + this.nextNodeCallId = nodeRuntime.nextCallId + nodeRuntime.modeError = new NodeModeError() + this.helpers.switchModes(Mode.DON) } private runConsensusAndWrap( consensusInput: any, - unwrapOptions?: TOutput extends PrimitiveTypes - ? never - : UnwrapOptions, + unwrapOptions?: TOutput extends PrimitiveTypes ? never : UnwrapOptions, ): { result: () => TOutput } { - const consensus = new ConsensusCapability(); - const call = consensus.simple(this, consensusInput); + const consensus = new ConsensusCapability() + const call = consensus.simple(this, consensusInput) return { result: () => { - const result = call.result(); - const wrappedValue = Value.wrap(result); + const result = call.result() + const wrappedValue = Value.wrap(result) return unwrapOptions ? wrappedValue.unwrapToType(unwrapOptions) - : (wrappedValue.unwrap() as TOutput); + : (wrappedValue.unwrap() as TOutput) }, - }; + } } getSecrets(requests: Array): { - result: () => SecretResponse[]; + result: () => SecretResponse[] } { // Enforce mode restrictions if (this.modeError) { return { result: () => { - throw this.modeError; + throw this.modeError }, - }; + } } // Normalize requests (accept both protobuf and JSON formats) @@ -404,111 +368,98 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { (request as unknown as { $typeName?: string }).$typeName ? (request as SecretRequest) : create(SecretRequestSchema, request), - ); + ) if (normalizedRequests.length === 0) { return { result: () => [], - }; + } } // Allocate callback ID and send request - const id = this.nextCallId; - this.nextCallId++; + const id = this.nextCallId + this.nextCallId++ const secretsReq = create(GetSecretsRequestSchema, { callbackId: id, requests: normalizedRequests, - }); + }) try { - this.helpers.getSecrets(secretsReq, this.maxResponseSize); + this.helpers.getSecrets(secretsReq, this.maxResponseSize) } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = err instanceof Error ? err.message : String(err) return { result: () => { - throw new SecretsBatchError(normalizedRequests, message); + throw new SecretsBatchError(normalizedRequests, message) }, - }; + } } // Return lazy result return { result: () => this.awaitAndUnwrapSecrets(id, normalizedRequests), - }; + } } getSecret(request: SecretRequest | SecretRequestJson): { - result: () => Secret; + result: () => Secret } { - const secretRequest = (request as unknown as { $typeName?: string }) - .$typeName + const secretRequest = (request as unknown as { $typeName?: string }).$typeName ? (request as SecretRequest) - : create(SecretRequestSchema, request); + : create(SecretRequestSchema, request) - const getSecretsCall = this.getSecrets([secretRequest]); + const getSecretsCall = this.getSecrets([secretRequest]) return { result: () => { - let responseList: SecretResponse[]; + let responseList: SecretResponse[] try { - responseList = getSecretsCall.result(); + responseList = getSecretsCall.result() } catch (err) { if (err instanceof SecretsBatchError) { - throw new SecretsError(secretRequest, err.error); + throw new SecretsError(secretRequest, err.error) } - throw err; + throw err } - return this.unwrapSingleSecretResult(responseList, secretRequest); + return this.unwrapSingleSecretResult(responseList, secretRequest) }, - }; + } } - private awaitAndUnwrapSecrets( - id: number, - requests: SecretRequest[], - ): SecretResponse[] { - const awaitRequest = create(AwaitSecretsRequestSchema, { ids: [id] }); - let awaitResponse: AwaitSecretsResponse; + private awaitAndUnwrapSecrets(id: number, requests: SecretRequest[]): SecretResponse[] { + const awaitRequest = create(AwaitSecretsRequestSchema, { ids: [id] }) + let awaitResponse: AwaitSecretsResponse try { - awaitResponse = this.helpers.awaitSecrets( - awaitRequest, - this.maxResponseSize, - ); + awaitResponse = this.helpers.awaitSecrets(awaitRequest, this.maxResponseSize) } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new SecretsBatchError(requests, message); + const message = err instanceof Error ? err.message : String(err) + throw new SecretsBatchError(requests, message) } - const secretsResponse = awaitResponse.responses[id]; + const secretsResponse = awaitResponse.responses[id] if (!secretsResponse) { - throw new SecretsBatchError(requests, "no response"); + throw new SecretsBatchError(requests, 'no response') } if (secretsResponse.responses.length !== requests.length) { - throw new SecretsBatchError(requests, "invalid value returned from host"); + throw new SecretsBatchError(requests, 'invalid value returned from host') } - return secretsResponse.responses; + return secretsResponse.responses } - private unwrapSingleSecretResult( - responseList: SecretResponse[], - request: SecretRequest, - ): Secret { + private unwrapSingleSecretResult(responseList: SecretResponse[], request: SecretRequest): Secret { if (responseList.length !== 1) { - throw new SecretsError(request, "invalid value returned from host"); + throw new SecretsError(request, 'invalid value returned from host') } - const response = responseList[0].response; + const response = responseList[0].response switch (response.case) { - case "secret": - return response.value; - case "error": - throw new SecretsError(request, response.value.error); + case 'secret': + return response.value + case 'error': + throw new SecretsError(request, response.value.error) default: - throw new SecretsError( - request, - "cannot unmarshal returned value from host", - ); + throw new SecretsError(request, 'cannot unmarshal returned value from host') } } @@ -516,11 +467,11 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { * Generates a report via consensus mechanism. */ report(input: ReportRequest | ReportRequestJson): { result: () => Report } { - const consensus = new ConsensusCapability(); - const call = consensus.report(this, input); + const consensus = new ConsensusCapability() + const call = consensus.report(this, input) return { result: () => call.result(), - }; + } } } @@ -530,68 +481,57 @@ export class RuntimeImpl extends BaseRuntimeImpl implements Runtime { */ export interface RuntimeHelpers { /** Initiates a capability call. Returns false if capability not found. */ - call(request: CapabilityRequest): boolean; + call(request: CapabilityRequest): boolean /** Awaits capability responses. Blocks until responses are ready. */ - await( - request: AwaitCapabilitiesRequest, - maxResponseSize: bigint, - ): AwaitCapabilitiesResponse; + await(request: AwaitCapabilitiesRequest, maxResponseSize: bigint): AwaitCapabilitiesResponse /** Requests secrets from host. Throws if host rejects the request. */ - getSecrets(request: GetSecretsRequest, maxResponseSize: bigint): void; + getSecrets(request: GetSecretsRequest, maxResponseSize: bigint): void /** Awaits secret responses. Blocks until secrets are ready. */ - awaitSecrets( - request: AwaitSecretsRequest, - maxResponseSize: bigint, - ): AwaitSecretsResponse; + awaitSecrets(request: AwaitSecretsRequest, maxResponseSize: bigint): AwaitSecretsResponse /** Switches execution mode (DON vs Node). Affects available operations. */ - switchModes(mode: Mode): void; + switchModes(mode: Mode): void /** Returns current time in milliseconds since Unix epoch. */ - now(): number; + now(): number /** Logs a message to the host environment. */ - log(message: string): void; + log(message: string): void } -function clearIgnoredFields( - value: ProtoValue, - descriptor: ConsensusDescriptor, -): void { +function clearIgnoredFields(value: ProtoValue, descriptor: ConsensusDescriptor): void { if (!descriptor || !value) { - return; + return } const fieldsMap = - descriptor.descriptor?.case === "fieldsMap" - ? descriptor.descriptor.value - : undefined; + descriptor.descriptor?.case === 'fieldsMap' ? descriptor.descriptor.value : undefined if (!fieldsMap) { - return; + return } - if (value.value?.case === "mapValue") { - const mapValue = value.value.value; + if (value.value?.case === 'mapValue') { + const mapValue = value.value.value if (!mapValue || !mapValue.fields) { - return; + return } for (const [key, val] of Object.entries(mapValue.fields)) { - const nestedDescriptor = fieldsMap.fields[key]; + const nestedDescriptor = fieldsMap.fields[key] if (!nestedDescriptor) { - delete mapValue.fields[key]; - continue; + delete mapValue.fields[key] + continue } const nestedFieldsMap = - nestedDescriptor.descriptor?.case === "fieldsMap" + nestedDescriptor.descriptor?.case === 'fieldsMap' ? nestedDescriptor.descriptor.value - : undefined; - if (nestedFieldsMap && val.value?.case === "mapValue") { - clearIgnoredFields(val, nestedDescriptor); + : undefined + if (nestedFieldsMap && val.value?.case === 'mapValue') { + clearIgnoredFields(val, nestedDescriptor) } } } diff --git a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts index d567bd48..25beb109 100644 --- a/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts +++ b/packages/cre-sdk/src/sdk/testutils/test-runtime.test.ts @@ -3,14 +3,14 @@ * 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 { 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, @@ -20,274 +20,256 @@ import { RESPONSE_BUFFER_TOO_SMALL, registerTestCapability, test, -} from "./test-runtime"; +} from './test-runtime' -describe("Registry (via test)", () => { - test("get returns undefined for unregistered id", async () => { - expect(getTestCapabilityHandler("missing")).toBeUndefined(); - }); +describe('Registry (via test)', () => { + test('get returns undefined for unregistered id', async () => { + expect(getTestCapabilityHandler('missing')).toBeUndefined() + }) - test("register and get return handler", async () => { + 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); - }); + 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: "" }, - })); + 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: "" }, + registerTestCapability('dup', () => ({ + response: { case: 'error' as const, value: '' }, })), - ).toThrow("capability already exists: dup"); - }); -}); + ).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"]); - }); +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('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('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(/not found/); - }); + 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(/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(); + 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); + }) + 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); - }); + }) + 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"; + 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); - }); + 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]); + 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); - }); + 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); + 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; + 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", - ); - }); + 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 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(); + 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); - }); + throw new Error('fail') + }, consensusMedianAggregation().withDefault(100))() + expect(p.result()).toBe(100) + }) - test("default Simple handler: observation error without default throws", () => { - const rt = newTestRuntime(); + 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"); - }); + 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"); + 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", + 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"); + }) + 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 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: batched call returns mixed secret/error responses", () => { - const secrets = new Map>(); - secrets.set("ns1", new Map([["id1", "val1"]])); - const rt = newTestRuntime(secrets); + test('helper getSecrets: batched call returns mixed secret/error responses', () => { + const secrets = new Map>() + secrets.set('ns1', new Map([['id1', 'val1']])) + const rt = newTestRuntime(secrets) const responses = rt .getSecrets([ - { id: "id1", namespace: "ns1" }, - { id: "missing", namespace: "ns1" }, + { id: 'id1', namespace: 'ns1' }, + { id: 'missing', namespace: 'ns1' }, ]) - .result(); + .result() - expect(responses.length).toBe(2); - expect(responses[0].response.case).toBe("secret"); - expect(responses[1].response.case).toBe("error"); - }); + expect(responses.length).toBe(2) + expect(responses[0].response.case).toBe('secret') + expect(responses[1].response.case).toBe('error') + }) - test("helper getSecrets: secret not found returns error response", () => { - const rt = newTestRuntime(); - expect(() => - rt.getSecret({ id: "missing", namespace: "ns" }).result(), - ).toThrow(SecretsError); - }); + 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]); + 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); - }); + 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([]); - }); -}); + 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"); +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", + id: 'cap-a', + method: 'M', payload: create(AnySchema, { value: new Uint8Array(0) }), }).response, ).toEqual({ - case: "error", - value: "a", - }); - }); + 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 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(); - }); + 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 () => { + bunTest('cleanup: after test finishes, store is undefined', async () => { await __testOnlyRunWithRegistry(async () => { - expect(__testOnlyRegistryStore()).toBeDefined(); - }); - expect(__testOnlyRegistryStore()).toBeUndefined(); - }); + expect(__testOnlyRegistryStore()).toBeDefined() + }) + expect(__testOnlyRegistryStore()).toBeUndefined() + }) - bunTest("failure path: cleanup happens when test body throws", async () => { + bunTest('failure path: cleanup happens when test body throws', async () => { await expect( __testOnlyRunWithRegistry(async () => { - expect(__testOnlyRegistryStore()).toBeDefined(); - throw new Error("intentional failure"); + expect(__testOnlyRegistryStore()).toBeDefined() + throw new Error('intentional failure') }), - ).rejects.toThrow("intentional failure"); - expect(__testOnlyRegistryStore()).toBeUndefined(); - }); -}); + ).rejects.toThrow('intentional failure') + expect(__testOnlyRegistryStore()).toBeUndefined() + }) +}) diff --git a/packages/cre-sdk/src/sdk/wasm/runner.test.ts b/packages/cre-sdk/src/sdk/wasm/runner.test.ts index 6c47b1c4..e1ea7ba3 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.test.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.test.ts @@ -1,10 +1,10 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { anyPack, anyUnpack, EmptySchema } from "@bufbuild/protobuf/wkt"; +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { create, fromBinary, toBinary } from '@bufbuild/protobuf' +import { anyPack, anyUnpack, EmptySchema } from '@bufbuild/protobuf/wkt' import { ConfigSchema, OutputsSchema, -} from "@cre/generated/capabilities/internal/basictrigger/v1/basic_trigger_pb"; +} from '@cre/generated/capabilities/internal/basictrigger/v1/basic_trigger_pb' import { AwaitSecretsRequestSchema, AwaitSecretsResponseSchema, @@ -19,351 +19,316 @@ import { type Trigger, TriggerSchema, type TriggerSubscriptionRequest, -} from "@cre/generated/sdk/v1alpha/sdk_pb"; -import type { Value as ProtoValue } from "@cre/generated/values/v1/values_pb"; -import { BasicCapability as BasicTriggerCapability } from "@cre/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen"; -import { cre } from "@cre/sdk/cre"; -import { Value } from "../utils"; -import type { SecretsProvider } from "../workflow"; -import { Runner } from "./runner"; +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import type { Value as ProtoValue } from '@cre/generated/values/v1/values_pb' +import { BasicCapability as BasicTriggerCapability } from '@cre/generated-sdk/capabilities/internal/basictrigger/v1/basic_sdk_gen' +import { cre } from '@cre/sdk/cre' +import { Value } from '../utils' +import type { SecretsProvider } from '../workflow' +import { Runner } from './runner' -const anyConfig = Buffer.from("config"); -const anyMaxResponseSize = 2048n; -const basicTrigger = new BasicTriggerCapability(); -const capID = BasicTriggerCapability.CAPABILITY_ID; +const anyConfig = Buffer.from('config') +const anyMaxResponseSize = 2048n +const basicTrigger = new BasicTriggerCapability() +const capID = BasicTriggerCapability.CAPABILITY_ID const subscribeRequest = create(ExecuteRequestSchema, { - request: { case: "subscribe", value: create(EmptySchema) }, + request: { case: 'subscribe', value: create(EmptySchema) }, maxResponseSize: anyMaxResponseSize, config: anyConfig, -}); +}) const anyExecuteRequest = create(ExecuteRequestSchema, { request: { - case: "trigger", + case: 'trigger', value: create(TriggerSchema, { id: 0n, - payload: anyPack( - OutputsSchema, - create(OutputsSchema, { coolOutput: "hi" }), - ), + payload: anyPack(OutputsSchema, create(OutputsSchema, { coolOutput: 'hi' })), }), }, maxResponseSize: anyMaxResponseSize, config: anyConfig, -}); +}) type TestRunnerBindings = { - versionV2: () => void; - sendResponse: (data: Uint8Array) => number; - getWasiArgs: () => string; - getSecrets: ( - data: Uint8Array | Uint8Array, - maxresponse: number, - ) => any; + versionV2: () => void + sendResponse: (data: Uint8Array) => number + getWasiArgs: () => string + getSecrets: (data: Uint8Array | Uint8Array, maxresponse: number) => any awaitSecrets: ( data: Uint8Array | Uint8Array, maxresponse: number, - ) => Uint8Array | Uint8Array; -}; + ) => Uint8Array | Uint8Array +} const mockHostBindings: TestRunnerBindings = { sendResponse: mock(() => { - return 0; + return 0 }), versionV2: mock(() => {}), getWasiArgs: mock(() => { - throw new Error("override for tests"); + throw new Error('override for tests') }), getSecrets: mock((data, maxResponseSize) => { - throw new Error("override for tests"); + throw new Error('override for tests') }), awaitSecrets: mock((data, maxResponseSize) => { - throw new Error("override for tests"); + throw new Error('override for tests') }), -}; +} const proxyHostBindings = { sendResponse: (data: Uint8Array) => { - return mockHostBindings.sendResponse(data); + return mockHostBindings.sendResponse(data) }, versionV2: () => { - return mockHostBindings.versionV2(); + return mockHostBindings.versionV2() }, getWasiArgs: () => { - return mockHostBindings.getWasiArgs(); + return mockHostBindings.getWasiArgs() }, switchModes: mock(() => {}), log: (message: string) => { - throw new Error("log called unexpectedly in test"); + throw new Error('log called unexpectedly in test') }, callCapability: (data: Uint8Array) => { - throw new Error("callCapability called unexpectedly in test"); + throw new Error('callCapability called unexpectedly in test') }, awaitCapabilities: (data: Uint8Array, id: number) => { - throw new Error("awaitCapabilities called unexpectedly in test"); + throw new Error('awaitCapabilities called unexpectedly in test') }, getSecrets: (data: Uint8Array, id: number) => { - return mockHostBindings.getSecrets(data, id); + return mockHostBindings.getSecrets(data, id) }, awaitSecrets: (data: Uint8Array, id: number) => { - return mockHostBindings.awaitSecrets(data, id); + return mockHostBindings.awaitSecrets(data, id) }, now: () => { - throw new Error("now called unexpectedly in test"); + throw new Error('now called unexpectedly in test') }, -}; +} -Object.assign(globalThis, proxyHostBindings); +Object.assign(globalThis, proxyHostBindings) afterEach(() => { - mock.restore(); -}); + mock.restore() +}) -describe("runner", () => { - describe("run", () => { - test("gathers subscriptions", async () => { - var sentResponse: ExecutionResult | null = null; +describe('runner', () => { + describe('run', () => { + test('gathers subscriptions', async () => { + var sentResponse: ExecutionResult | null = null mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input); - return 0; - }); - const runner = await getTestRunner(subscribeRequest); + sentResponse = fromBinary(ExecutionResultSchema, input) + return 0 + }) + const runner = await getTestRunner(subscribeRequest) await runner.run(async (_: string, secretsProvider: SecretsProvider) => { return [ - cre.handler(basicTrigger.trigger({ name: "foo", number: 10 }), () => { - throw new Error( - "Must not be called during registration to tiggers", - ); + cre.handler(basicTrigger.trigger({ name: 'foo', number: 10 }), () => { + throw new Error('Must not be called during registration to tiggers') }), - ]; - }); - expect(sentResponse).toBeDefined(); - expect(sentResponse!.result.case).toBe("triggerSubscriptions"); - const responseValue = sentResponse!.result - .value! as TriggerSubscriptionRequest; - expect(responseValue.subscriptions.length).toBe(1); - expect(responseValue.subscriptions[0].id).toBe(capID); - expect(responseValue.subscriptions[0].method).toBe("Trigger"); - expect(responseValue.subscriptions[0].payload).toBeDefined(); - const actualConfig = anyUnpack( - responseValue.subscriptions[0].payload!, - ConfigSchema, - )!; - expect(actualConfig.name).toBe("foo"); - expect(actualConfig.number).toBe(10); - }); + ] + }) + expect(sentResponse).toBeDefined() + expect(sentResponse!.result.case).toBe('triggerSubscriptions') + const responseValue = sentResponse!.result.value! as TriggerSubscriptionRequest + expect(responseValue.subscriptions.length).toBe(1) + expect(responseValue.subscriptions[0].id).toBe(capID) + expect(responseValue.subscriptions[0].method).toBe('Trigger') + expect(responseValue.subscriptions[0].payload).toBeDefined() + const actualConfig = anyUnpack(responseValue.subscriptions[0].payload!, ConfigSchema)! + expect(actualConfig.name).toBe('foo') + expect(actualConfig.number).toBe(10) + }) - test("executes workflow", async () => { - var sentResponse: ExecutionResult | null = null; + test('executes workflow', async () => { + var sentResponse: ExecutionResult | null = null mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input); - return 0; - }); - const runner = await getTestRunner(anyExecuteRequest); + sentResponse = fromBinary(ExecutionResultSchema, input) + return 0 + }) + const runner = await getTestRunner(anyExecuteRequest) await runner.run(async (_: string, secretsProvider: SecretsProvider) => { return [ - cre.handler( - basicTrigger.trigger({ name: "foo", number: 10 }), - (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()); - expect(trigger.coolOutput).toBe("hi"); - return 10; - }, - ), - ]; - }); - expect(sentResponse).toBeDefined(); - expect(sentResponse!.result.case).toBe("value"); + cre.handler(basicTrigger.trigger({ name: 'foo', number: 10 }), (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()) + expect(trigger.coolOutput).toBe('hi') + return 10 + }), + ] + }) + expect(sentResponse).toBeDefined() + expect(sentResponse!.result.case).toBe('value') expect( Value.wrap(sentResponse!.result.value as ProtoValue).unwrapToType({ instance: 10, }), - ).toBe(10); - }); - }); + ).toBe(10) + }) + }) - test("executes subscribe error", async () => { - var sentResponse: ExecutionResult | null = null; - const anyError = "error"; + test('executes subscribe error', async () => { + var sentResponse: ExecutionResult | null = null + const anyError = 'error' mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input); - expect(sentResponse!.result.case).toBe("error"); - expect(sentResponse!.result.value).toBe(anyError); - return 0; - }); - const runner = await getTestRunner(subscribeRequest); + sentResponse = fromBinary(ExecutionResultSchema, input) + expect(sentResponse!.result.case).toBe('error') + expect(sentResponse!.result.value).toBe(anyError) + return 0 + }) + const runner = await getTestRunner(subscribeRequest) await runner.run((_: string, secretsProvider: SecretsProvider) => { - throw new Error(anyError); - }); - }); + throw new Error(anyError) + }) + }) - test("executes subscribe resolve error", async () => { - var sentResponse: ExecutionResult | null = null; - const anyError = "error"; + test('executes subscribe resolve error', async () => { + var sentResponse: ExecutionResult | null = null + const anyError = 'error' mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input); - expect(sentResponse!.result.case).toBe("error"); - expect(sentResponse!.result.value).toBe(anyError); - return 0; - }); - const runner = await getTestRunner(subscribeRequest); + sentResponse = fromBinary(ExecutionResultSchema, input) + expect(sentResponse!.result.case).toBe('error') + expect(sentResponse!.result.value).toBe(anyError) + return 0 + }) + const runner = await getTestRunner(subscribeRequest) await runner.run(async (_: string, secretsProvider: SecretsProvider) => { - return Promise.reject(new Error(anyError)); - }); - }); + return Promise.reject(new Error(anyError)) + }) + }) - test("executes trigger error", async () => { - var sentResponse: ExecutionResult | null = null; - const anyError = "error"; + test('executes trigger error', async () => { + var sentResponse: ExecutionResult | null = null + const anyError = 'error' mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input); - expect(sentResponse!.result.case).toBe("error"); - expect(sentResponse!.result.value).toBe(anyError); - return 0; - }); - const runner = await getTestRunner(anyExecuteRequest); + sentResponse = fromBinary(ExecutionResultSchema, input) + expect(sentResponse!.result.case).toBe('error') + expect(sentResponse!.result.value).toBe(anyError) + return 0 + }) + const runner = await getTestRunner(anyExecuteRequest) await runner.run(async (_: string, secretsProvider: SecretsProvider) => { - throw new Error(anyError); - }); - }); + throw new Error(anyError) + }) + }) - test("executes workflow with multiple triggers", async () => { - var sentResponse: ExecutionResult | null = null; + test('executes workflow with multiple triggers', async () => { + var sentResponse: ExecutionResult | null = null mockHostBindings.sendResponse = mock((input) => { - sentResponse = fromBinary(ExecutionResultSchema, input); - return 0; - }); - const testRequest = structuredClone(anyExecuteRequest); - const trigger = testRequest.request.value as Trigger; - trigger.id = 1n; + sentResponse = fromBinary(ExecutionResultSchema, input) + return 0 + }) + const testRequest = structuredClone(anyExecuteRequest) + const trigger = testRequest.request.value as Trigger + trigger.id = 1n - const runner = await getTestRunner(testRequest); + const runner = await getTestRunner(testRequest) await runner.run(async (_: string, secretsProvider: SecretsProvider) => { return [ - cre.handler( - basicTrigger.trigger({ name: "foo", number: 10 }), - (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()); - expect(trigger.coolOutput).toBe("hi"); - return 10; - }, - ), - cre.handler( - basicTrigger.trigger({ name: "bar", number: 20 }), - (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()); - expect(trigger.coolOutput).toBe("hi"); - return 20; - }, - ), - cre.handler( - basicTrigger.trigger({ name: "baz", number: 30 }), - (runtime, trigger) => { - expect(runtime.config).toBe(anyConfig.toString()); - expect(trigger.coolOutput).toBe("hi"); - return 30; - }, - ), - ]; - }); - expect(sentResponse).toBeDefined(); - expect(sentResponse!.result.case).toBe("value"); + cre.handler(basicTrigger.trigger({ name: 'foo', number: 10 }), (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()) + expect(trigger.coolOutput).toBe('hi') + return 10 + }), + cre.handler(basicTrigger.trigger({ name: 'bar', number: 20 }), (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()) + expect(trigger.coolOutput).toBe('hi') + return 20 + }), + cre.handler(basicTrigger.trigger({ name: 'baz', number: 30 }), (runtime, trigger) => { + expect(runtime.config).toBe(anyConfig.toString()) + expect(trigger.coolOutput).toBe('hi') + return 30 + }), + ] + }) + expect(sentResponse).toBeDefined() + expect(sentResponse!.result.case).toBe('value') expect( Value.wrap(sentResponse!.result.value as ProtoValue).unwrapToType({ instance: 10, }), - ).toBe(20); - }); + ).toBe(20) + }) - test("get secrets passes max response size", async () => { + test('get secrets passes max response size', async () => { const anySecretResponse = create(SecretResponseSchema, { response: { - case: "secret", + case: 'secret', value: create(SecretSchema, { - id: "Bar", - namespace: "Foo", - owner: "Baz", - value: "Qux", + id: 'Bar', + namespace: 'Foo', + owner: 'Baz', + value: 'Qux', }), }, - }); + }) const anySecretsResponse = create(SecretResponsesSchema, { responses: [anySecretResponse], - }); + }) mockHostBindings.getSecrets = (data, maxResponseSize) => { - const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data; - const secretsRequest = fromBinary(GetSecretsRequestSchema, dataBytes); - expect(secretsRequest.requests.length).toBe(1); - expect(secretsRequest.requests[0].namespace).toBe("Foo"); - expect(secretsRequest.requests[0].id).toBe("Bar"); - expect(secretsRequest.callbackId).toBe(0); - expect(maxResponseSize).toBe(Number(anyMaxResponseSize)); - return 0; - }; + const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data + const secretsRequest = fromBinary(GetSecretsRequestSchema, dataBytes) + expect(secretsRequest.requests.length).toBe(1) + expect(secretsRequest.requests[0].namespace).toBe('Foo') + expect(secretsRequest.requests[0].id).toBe('Bar') + expect(secretsRequest.callbackId).toBe(0) + expect(maxResponseSize).toBe(Number(anyMaxResponseSize)) + return 0 + } mockHostBindings.awaitSecrets = (data, maxResponseSize) => { - const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data; - const awaitSecretsRequest = fromBinary( - AwaitSecretsRequestSchema, - dataBytes, - ); - expect(awaitSecretsRequest.ids.length).toBe(1); - expect(awaitSecretsRequest.ids[0]).toBe(0); - expect(maxResponseSize).toBe(Number(anyMaxResponseSize)); + const dataBytes = Array.isArray(data) ? new Uint8Array(data) : data + const awaitSecretsRequest = fromBinary(AwaitSecretsRequestSchema, dataBytes) + expect(awaitSecretsRequest.ids.length).toBe(1) + expect(awaitSecretsRequest.ids[0]).toBe(0) + expect(maxResponseSize).toBe(Number(anyMaxResponseSize)) // Create the proper AwaitSecretsResponse with a map const awaitSecretsResponse = create(AwaitSecretsResponseSchema, { responses: { 0: anySecretsResponse, }, - }); - return toBinary(AwaitSecretsResponseSchema, awaitSecretsResponse); - }; + }) + return toBinary(AwaitSecretsResponseSchema, awaitSecretsResponse) + } - const dr = getTestRunner(subscribeRequest); - await (await dr).run( - async (_: string, secretsProvider: SecretsProvider) => { - const batched = await secretsProvider - .getSecrets([{ namespace: "Foo", id: "Bar" }]) - .result(); - expect(batched.length).toBe(1); - expect(batched[0].response.case).toBe("secret"); - if (batched[0].response.case === "secret") { - expect(batched[0].response.value.namespace).toBe("Foo"); - expect(batched[0].response.value.id).toBe("Bar"); - expect(batched[0].response.value.owner).toBe("Baz"); - expect(batched[0].response.value.value).toBe("Qux"); - } + const dr = getTestRunner(subscribeRequest) + await (await dr).run(async (_: string, secretsProvider: SecretsProvider) => { + const batched = await secretsProvider.getSecrets([{ namespace: 'Foo', id: 'Bar' }]).result() + expect(batched.length).toBe(1) + expect(batched[0].response.case).toBe('secret') + if (batched[0].response.case === 'secret') { + expect(batched[0].response.value.namespace).toBe('Foo') + expect(batched[0].response.value.id).toBe('Bar') + expect(batched[0].response.value.owner).toBe('Baz') + expect(batched[0].response.value.value).toBe('Qux') + } - // Keep compatibility coverage for single-secret API. - const single = await secretsProvider - .getSecret({ namespace: "Foo", id: "Bar" }) - .result(); - expect(single.namespace).toBe("Foo"); - expect(single.id).toBe("Bar"); - expect(single.owner).toBe("Baz"); - expect(single.value).toBe("Qux"); - return [cre.handler(basicTrigger.trigger({}), () => 10)]; - }, - ); - expect(true).toBe(true); - }); -}); + // Keep compatibility coverage for single-secret API. + const single = await secretsProvider.getSecret({ namespace: 'Foo', id: 'Bar' }).result() + expect(single.namespace).toBe('Foo') + expect(single.id).toBe('Bar') + expect(single.owner).toBe('Baz') + expect(single.value).toBe('Qux') + return [cre.handler(basicTrigger.trigger({}), () => 10)] + }) + expect(true).toBe(true) + }) +}) function getTestRunner(request: ExecuteRequest): Promise> { - const serialized = toBinary(ExecuteRequestSchema, request); - const encoded = Buffer.from(serialized).toString("base64"); + const serialized = toBinary(ExecuteRequestSchema, request) + const encoded = Buffer.from(serialized).toString('base64') // Update the mock to return the specific request - mockHostBindings.getWasiArgs = mock(() => - JSON.stringify(["program", encoded]), - ); + mockHostBindings.getWasiArgs = mock(() => JSON.stringify(['program', encoded])) return Runner.newRunner({ configParser: (b) => { - const stringConfig = Buffer.from(b).toString(); - expect(stringConfig).toBe(anyConfig.toString()); - return stringConfig; + const stringConfig = Buffer.from(b).toString() + expect(stringConfig).toBe(anyConfig.toString()) + return stringConfig }, - }); + }) } diff --git a/packages/cre-sdk/src/sdk/wasm/runner.ts b/packages/cre-sdk/src/sdk/wasm/runner.ts index 985b83e7..80b0767a 100644 --- a/packages/cre-sdk/src/sdk/wasm/runner.ts +++ b/packages/cre-sdk/src/sdk/wasm/runner.ts @@ -1,16 +1,16 @@ -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf' import { type ExecuteRequest, ExecuteRequestSchema, type ExecutionResult, ExecutionResultSchema, TriggerSubscriptionRequestSchema, -} from "@cre/generated/sdk/v1alpha/sdk_pb"; -import { type ConfigHandlerParams, configHandler } from "@cre/sdk/utils/config"; -import type { SecretsProvider, Workflow } from "@cre/sdk/workflow"; -import { Value } from "../utils"; -import { hostBindings } from "./host-bindings"; -import { Runtime } from "./runtime"; +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import { type ConfigHandlerParams, configHandler } from '@cre/sdk/utils/config' +import type { SecretsProvider, Workflow } from '@cre/sdk/workflow' +import { Value } from '../utils' +import { hostBindings } from './host-bindings' +import { Runtime } from './runtime' export class Runner { private constructor( @@ -21,24 +21,21 @@ export class Runner { static async newRunner( configHandlerParams?: ConfigHandlerParams, ): Promise> { - hostBindings.versionV2(); - const request = Runner.getRequest(); - const config = await configHandler( - request, - configHandlerParams, - ); - return new Runner(config, request); + hostBindings.versionV2() + const request = Runner.getRequest() + const config = await configHandler(request, configHandlerParams) + return new Runner(config, request) } private static getRequest(): ExecuteRequest { - const argsString = hostBindings.getWasiArgs(); - let args: any; + const argsString = hostBindings.getWasiArgs() + let args: any try { - args = JSON.parse(argsString); + args = JSON.parse(argsString) } catch (e) { throw new Error( - "Invalid request: could not parse WASI arguments as JSON. Ensure the WASM runtime is passing valid arguments to the workflow", - ); + 'Invalid request: could not parse WASI arguments as JSON. Ensure the WASM runtime is passing valid arguments to the workflow', + ) } // SDK expects exactly 2 args: @@ -47,13 +44,13 @@ export class Runner { if (args.length !== 2) { throw new Error( `Invalid request: expected exactly 2 WASI arguments (script name and base64-encoded request payload), but received ${args.length}`, - ); + ) } - const base64Request = args[1]; + const base64Request = args[1] - const bytes = Buffer.from(base64Request, "base64"); - return fromBinary(ExecuteRequestSchema, bytes); + const bytes = Buffer.from(base64Request, 'base64') + return fromBinary(ExecuteRequestSchema, bytes) } async run( @@ -62,36 +59,36 @@ export class Runner { secretsProvider: SecretsProvider, ) => Promise> | Workflow, ) { - const runtime = new Runtime(this.config, 0, this.request.maxResponseSize); + const runtime = new Runtime(this.config, 0, this.request.maxResponseSize) - let result: Promise | ExecutionResult; + let result: Promise | ExecutionResult try { const workflow = await initFn(this.config, { getSecrets: runtime.getSecrets.bind(runtime), getSecret: runtime.getSecret.bind(runtime), - }); + }) switch (this.request.request.case) { - case "subscribe": - result = this.handleSubscribePhase(this.request, workflow); - break; - case "trigger": - result = this.handleExecutionPhase(this.request, workflow, runtime); - break; + case 'subscribe': + result = this.handleSubscribePhase(this.request, workflow) + break + case 'trigger': + result = this.handleExecutionPhase(this.request, workflow, runtime) + break default: throw new Error( `Unknown request type '${this.request.request.case}': expected 'subscribe' or 'trigger'. This may indicate a version mismatch between the SDK and the CRE runtime`, - ); + ) } } catch (e) { - const err = e instanceof Error ? e.message : String(e); + const err = e instanceof Error ? e.message : String(e) result = create(ExecutionResultSchema, { - result: { case: "error", value: err }, - }); + result: { case: 'error', value: err }, + }) } - const awaitedResult = await result!; - hostBindings.sendResponse(toBinary(ExecutionResultSchema, awaitedResult)); + const awaitedResult = await result! + hostBindings.sendResponse(toBinary(ExecutionResultSchema, awaitedResult)) } async handleExecutionPhase( @@ -99,37 +96,37 @@ export class Runner { workflow: Workflow, runtime: Runtime, ): Promise { - if (req.request.case !== "trigger") { + if (req.request.case !== 'trigger') { throw new Error( `cannot handle non-trigger request as a trigger: received request type '${req.request.case}' in handleExecutionPhase. This is an internal SDK error`, - ); + ) } - const triggerMsg = req.request.value; + const triggerMsg = req.request.value // We're about to cast bigint to number, so we need to check if it's safe - const id = BigInt(triggerMsg.id); + const id = BigInt(triggerMsg.id) if (id > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error( `Trigger ID ${id} exceeds JavaScript safe integer range (Number.MAX_SAFE_INTEGER = ${Number.MAX_SAFE_INTEGER}). This trigger ID cannot be safely represented as a number`, - ); + ) } - const index = Number(triggerMsg.id); + const index = Number(triggerMsg.id) if (Number.isFinite(index) && index >= 0 && index < workflow.length) { - const entry = workflow[index]; - const schema = entry.trigger.outputSchema(); + const entry = workflow[index] + const schema = entry.trigger.outputSchema() if (!triggerMsg.payload) { return create(ExecutionResultSchema, { result: { - case: "error", + case: 'error', value: `trigger payload is missing for handler at index ${index} (trigger ID ${triggerMsg.id}). The trigger event must include a payload`, }, - }); + }) } - const payloadAny = triggerMsg.payload; + const payloadAny = triggerMsg.payload /** * Note: do not hardcode method name; routing by id is authoritative. @@ -138,42 +135,39 @@ export class Runner { * * @see https://github.com/smartcontractkit/cre-sdk-go/blob/5a41d81e3e072008484e85dc96d746401aafcba2/cre/wasm/runner.go#L81 * */ - const decoded = fromBinary(schema, payloadAny.value); - const adapted = entry.trigger.adapt(decoded); + const decoded = fromBinary(schema, payloadAny.value) + const adapted = entry.trigger.adapt(decoded) try { - const result = await entry.fn(runtime, adapted); - const wrapped = Value.wrap(result); + const result = await entry.fn(runtime, adapted) + const wrapped = Value.wrap(result) return create(ExecutionResultSchema, { - result: { case: "value", value: wrapped.proto() }, - }); + result: { case: 'value', value: wrapped.proto() }, + }) } catch (e) { - const err = e instanceof Error ? e.message : String(e); + const err = e instanceof Error ? e.message : String(e) return create(ExecutionResultSchema, { - result: { case: "error", value: err }, - }); + result: { case: 'error', value: err }, + }) } } return create(ExecutionResultSchema, { result: { - case: "error", + case: 'error', value: `trigger not found: no workflow handler registered at index ${index} (trigger ID ${triggerMsg.id}). The workflow has ${workflow.length} handler(s) registered. Verify the trigger subscription matches a registered handler`, }, - }); + }) } - handleSubscribePhase( - req: ExecuteRequest, - workflow: Workflow, - ): ExecutionResult { - if (req.request.case !== "subscribe") { + handleSubscribePhase(req: ExecuteRequest, workflow: Workflow): ExecutionResult { + if (req.request.case !== 'subscribe') { return create(ExecutionResultSchema, { result: { - case: "error", + case: 'error', value: `subscribe request expected but received '${req.request.case}' in handleSubscribePhase. This is an internal SDK error`, }, - }); + }) } // Build TriggerSubscriptionRequest from the workflow entries @@ -181,14 +175,14 @@ export class Runner { id: entry.trigger.capabilityId(), method: entry.trigger.method(), payload: entry.trigger.configAsAny(), - })); + })) const subscriptionRequest = create(TriggerSubscriptionRequestSchema, { subscriptions, - }); + }) return create(ExecutionResultSchema, { - result: { case: "triggerSubscriptions", value: subscriptionRequest }, - }); + result: { case: 'triggerSubscriptions', value: subscriptionRequest }, + }) } } diff --git a/packages/cre-sdk/src/sdk/workflow.ts b/packages/cre-sdk/src/sdk/workflow.ts index 8b4b969b..bfb87293 100644 --- a/packages/cre-sdk/src/sdk/workflow.ts +++ b/packages/cre-sdk/src/sdk/workflow.ts @@ -1,18 +1,18 @@ -import type { Message } from "@bufbuild/protobuf"; +import type { Message } from '@bufbuild/protobuf' import type { Secret, SecretRequest, SecretRequestJson, SecretResponse, -} from "@cre/generated/sdk/v1alpha/sdk_pb"; -import type { Runtime } from "@cre/sdk/runtime"; -import type { Trigger } from "@cre/sdk/utils/triggers/trigger-interface"; -import type { CreSerializable } from "./utils"; +} from '@cre/generated/sdk/v1alpha/sdk_pb' +import type { Runtime } from '@cre/sdk/runtime' +import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface' +import type { CreSerializable } from './utils' export type HandlerFn = ( runtime: Runtime, triggerOutput: TTriggerOutput, -) => Promise> | CreSerializable; +) => Promise> | CreSerializable export interface HandlerEntry< TConfig, @@ -20,13 +20,11 @@ export interface HandlerEntry< TTriggerOutput, TResult, > { - trigger: Trigger; - fn: HandlerFn; + trigger: Trigger + fn: HandlerFn } -export type Workflow = ReadonlyArray< - HandlerEntry ->; +export type Workflow = ReadonlyArray> export const handler = < TRawTriggerOutput extends Message, @@ -39,13 +37,13 @@ export const handler = < ): HandlerEntry => ({ trigger, fn, -}); +}) export type SecretsProvider = { getSecrets(requests: Array): { - result: () => SecretResponse[]; - }; + result: () => SecretResponse[] + } getSecret(request: SecretRequest | SecretRequestJson): { - result: () => Secret; - }; -}; + result: () => Secret + } +}