From b38cda26d3c89e73ea4e3459f47b4fe2c8d90156 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Tue, 19 May 2026 01:01:42 +0900 Subject: [PATCH 1/2] feat(vitest): route workflow trigger through globalThis mock Mirror the Platform JSON serialization boundary in workflow mocks so tests catch invalid payloads (NaN/Infinity/BigInt/class instances) the same way the real Platform does. Both wjob.trigger() and wf.trigger() now delegate to globalThis.tailor.workflow.triggerJobFunction / triggerWorkflow, matching the wait/resolve pattern. - Add platformSerialize utility validating JSON-boundary safety - Introduce a globalThis-keyed registry so the mock can look up job and workflow bodies by name across module instances - Route wf.trigger() through mockTriggerJobFunction so main-job invocations are recorded in triggeredJobs and honor setJobHandler / enqueueResult uniformly --- packages/sdk/docs/testing.md | 6 +- .../src/configure/services/workflow/job.ts | 28 ++- .../configure/services/workflow/registry.ts | 86 +++++++ .../configure/services/workflow/workflow.ts | 37 ++- .../sdk/src/utils/platform-serialize.test.ts | 75 ++++++ packages/sdk/src/utils/platform-serialize.ts | 59 +++++ .../__tests__/integration/vitest.config.ts | 9 + .../sdk/src/vitest/__tests__/mock.test.ts | 237 +++++++++++++++++- packages/sdk/src/vitest/mock.ts | 115 +++++++-- 9 files changed, 611 insertions(+), 41 deletions(-) create mode 100644 packages/sdk/src/configure/services/workflow/registry.ts create mode 100644 packages/sdk/src/utils/platform-serialize.test.ts create mode 100644 packages/sdk/src/utils/platform-serialize.ts diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 7597673d8..4d24270b4 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -18,7 +18,7 @@ Unit-test entrypoints exposed by the SDK: Helpers under `@tailor-platform/sdk/test`: - `unauthenticatedTailorUser` — default `user` value for resolver contexts -- `WORKFLOW_TEST_ENV_KEY` — env key consumed by `.trigger()` when run locally +- `WORKFLOW_TEST_ENV_KEY` — env key the workflow mock reads as the `env` argument when it executes a registered job body Platform API mocks under `@tailor-platform/sdk/vitest` (auto-injected by the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below): @@ -604,7 +604,7 @@ describe("processWithApproval", () => { #### Running a full workflow locally -To exercise the full chain without any mocking, call `workflow.mainJob.trigger()`. Dependent jobs run their real `.body()` functions. Set `WORKFLOW_TEST_ENV_KEY` first so triggered jobs see the workflow env: +To exercise the full chain without staging job responses, call `workflow.mainJob.trigger()` (or `workflow.trigger()`) under the `tailor-runtime` environment. Each `createWorkflowJob` registers its body at import time and the workflow mock falls back to running the registered body whenever no handler/result is configured for that name — so dependent jobs run their real `.body()` functions automatically. If a job reads `env`, stub `WORKFLOW_TEST_ENV_KEY` first so the mock can hand the deserialized object to the body: ```typescript import { WORKFLOW_TEST_ENV_KEY } from "@tailor-platform/sdk/test"; @@ -626,6 +626,8 @@ describe("order-fulfillment workflow", () => { **Use when:** you want to verify orchestration end to end without the cost of a real deployment. +**Requires:** `environment: "tailor-runtime"`. Outside that environment `.trigger()` throws because `globalThis.tailor.workflow` is not injected. + ## End-to-End (E2E) Tests E2E tests run against a deployed Tailor Platform application. They exercise the full stack — GraphQL, TailorDB, auth, workflows — end to end. diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 5dffbf201..7c27074f9 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -1,4 +1,5 @@ import { brandValue } from "@/utils/brand"; +import { registerJob, type RegisteredJobBody } from "./registry"; import type { TailorEnv } from "@/types/env"; import type { JsonCompatible } from "@/types/helpers"; import type { TailorInvoker } from "@/types/user"; @@ -67,6 +68,23 @@ interface CreateWorkflowJobConfig { readonly body: JobBody; } +function getPlatformWorkflow() { + const platform = globalThis as { + tailor?: { + workflow?: { + triggerJobFunction: (name: string, args?: unknown) => unknown; + }; + }; + }; + const workflow = platform.tailor?.workflow; + if (!workflow) { + throw new Error( + "tailor.workflow is not available. Use the tailor-runtime environment from @tailor-platform/sdk/vitest in tests.", + ); + } + return workflow; +} + /** * Create a workflow job definition. * @@ -104,12 +122,18 @@ export const createWorkflowJob = , ): WorkflowJob> => { const body = config.body as (input: I, context: WorkflowJobContext) => O | Promise; + + // Register on the global job registry so the vitest mock can execute this + // body when `globalThis.tailor.workflow.triggerJobFunction(name, args)` is + // invoked. In production, the bundler rewrites `.trigger()` calls and the + // platform routes by name, so this registry is never read. + registerJob(config.name, body as RegisteredJobBody); + return brandValue( { name: config.name, trigger: async (args?: unknown) => { - const env: TailorEnv = JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}"); - return await body(args as I, { env, invoker: null }); + return (await getPlatformWorkflow().triggerJobFunction(config.name, args)) as Awaited; }, body, } as WorkflowJob>, diff --git a/packages/sdk/src/configure/services/workflow/registry.ts b/packages/sdk/src/configure/services/workflow/registry.ts new file mode 100644 index 000000000..c38be40f2 --- /dev/null +++ b/packages/sdk/src/configure/services/workflow/registry.ts @@ -0,0 +1,86 @@ +import type { TailorEnv } from "@/types/env"; +import type { TailorInvoker } from "@/types/user"; + +/** + * Body signature shared by workflow jobs at registry-write time. + * The user's `createWorkflowJob`/`createWorkflow` body uses concrete types, + * but the registry erases them for storage. + */ +export type RegisteredJobBody = ( + args: unknown, + context: { env: TailorEnv; invoker: TailorInvoker | null }, +) => unknown | Promise; + +export interface RegisteredWorkflow { + mainJobName: string; +} + +const JOB_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:job-registry"); +const WORKFLOW_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:workflow-registry"); + +type GlobalWithRegistry = typeof globalThis & { + [JOB_REGISTRY_KEY]?: Map; + [WORKFLOW_REGISTRY_KEY]?: Map; +}; + +function jobs(): Map { + const g = globalThis as GlobalWithRegistry; + let map = g[JOB_REGISTRY_KEY]; + if (!map) { + map = new Map(); + g[JOB_REGISTRY_KEY] = map; + } + return map; +} + +function workflows(): Map { + const g = globalThis as GlobalWithRegistry; + let map = g[WORKFLOW_REGISTRY_KEY]; + if (!map) { + map = new Map(); + g[WORKFLOW_REGISTRY_KEY] = map; + } + return map; +} + +/** + * Register a job body keyed by job name. Called as a side effect by + * `createWorkflowJob` so that the vitest mock can execute the body when + * `globalThis.tailor.workflow.triggerJobFunction(name, args)` is invoked. + * + * In production builds, the bundler rewrites `.trigger()` calls so this + * registry is never read; the write is a harmless no-op. + * @param name - Job name + * @param body - Job body function + */ +export function registerJob(name: string, body: RegisteredJobBody): void { + jobs().set(name, body); +} + +/** + * Look up a registered job body by name. + * @param name - Job name + * @returns The registered body, or undefined when no job is registered + */ +export function getRegisteredJob(name: string): RegisteredJobBody | undefined { + return jobs().get(name); +} + +/** + * Register a workflow's main job name. The mock looks up the main job in the + * job registry to execute the workflow locally. + * @param name - Workflow name + * @param mainJobName - Name of the workflow's main job + */ +export function registerWorkflow(name: string, mainJobName: string): void { + workflows().set(name, { mainJobName }); +} + +/** + * Look up a registered workflow by name. + * @param name - Workflow name + * @returns The registered workflow, or undefined + */ +export function getRegisteredWorkflow(name: string): RegisteredWorkflow | undefined { + return workflows().get(name); +} diff --git a/packages/sdk/src/configure/services/workflow/workflow.ts b/packages/sdk/src/configure/services/workflow/workflow.ts index 71724e3b6..59b5d0bce 100644 --- a/packages/sdk/src/configure/services/workflow/workflow.ts +++ b/packages/sdk/src/configure/services/workflow/workflow.ts @@ -1,10 +1,36 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { brandValue } from "@/utils/brand"; +import { registerWorkflow } from "./registry"; import type { WorkflowJob } from "./job"; import type { AuthInvoker } from "../auth"; import type { MachineUserName } from "@/configure/types/machine-user"; import type { ConcurrencyPolicy, RetryPolicy } from "@/types/workflow.generated"; +type TriggerWorkflowOptions = { + authInvoker?: AuthInvoker | MachineUserName; +}; + +function getPlatformWorkflow() { + const platform = globalThis as { + tailor?: { + workflow?: { + triggerWorkflow: ( + name: string, + args?: unknown, + options?: TriggerWorkflowOptions, + ) => Promise; + }; + }; + }; + const workflow = platform.tailor?.workflow; + if (!workflow) { + throw new Error( + "tailor.workflow is not available. Use the tailor-runtime environment from @tailor-platform/sdk/vitest in tests.", + ); + } + return workflow; +} + export type { ConcurrencyPolicy, RetryPolicy }; export interface WorkflowConfig< @@ -62,16 +88,15 @@ interface WorkflowDefinition> { export function createWorkflow>( config: WorkflowDefinition, ): Workflow { + registerWorkflow(config.name, config.mainJob.name); + return brandValue( { ...config, - // For local execution, directly call mainJob.trigger() - // In production, bundler transforms this to tailor.workflow.triggerWorkflow() - trigger: async (args) => { - await config.mainJob.trigger(...([args] as unknown as [])); - return "00000000-0000-0000-0000-000000000000"; + trigger: async (args, options) => { + return await getPlatformWorkflow().triggerWorkflow(config.name, args, options); }, - }, + } as Workflow, "workflow", ); } diff --git a/packages/sdk/src/utils/platform-serialize.test.ts b/packages/sdk/src/utils/platform-serialize.test.ts new file mode 100644 index 000000000..b54edd2e8 --- /dev/null +++ b/packages/sdk/src/utils/platform-serialize.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { platformSerialize } from "./platform-serialize"; + +describe("platformSerialize", () => { + describe("happy path", () => { + it("round-trips plain JSON values", () => { + expect(platformSerialize({ a: 1, b: "x", c: [true, null, { d: 2 }] })).toEqual({ + a: 1, + b: "x", + c: [true, null, { d: 2 }], + }); + }); + + it("returns undefined unchanged", () => { + expect(platformSerialize(undefined)).toBeUndefined(); + }); + + it("strips undefined properties (JSON.stringify semantics)", () => { + expect(platformSerialize({ a: 1, b: undefined })).toEqual({ a: 1 }); + }); + }); + + describe("Platform parity errors", () => { + it("throws on NaN", () => { + expect(() => platformSerialize({ n: NaN })).toThrow(/non-finite/); + }); + + it("throws on Infinity", () => { + expect(() => platformSerialize({ n: Infinity })).toThrow(/non-finite/); + }); + + it("throws on -Infinity", () => { + expect(() => platformSerialize(-Infinity)).toThrow(/non-finite/); + }); + + it("throws on BigInt", () => { + expect(() => platformSerialize({ n: 1n })).toThrow(/BigInt/); + }); + + it("throws on Date instances", () => { + expect(() => platformSerialize({ at: new Date() })).toThrow(/Date instance/); + }); + + it("throws on Map instances", () => { + expect(() => platformSerialize({ m: new Map() })).toThrow(/Map instance/); + }); + + it("throws on Set instances", () => { + expect(() => platformSerialize({ s: new Set() })).toThrow(/Set instance/); + }); + + it("throws on Error instances", () => { + expect(() => platformSerialize({ e: new Error("boom") })).toThrow(/Error instance/); + }); + + it("throws on user-defined class instances", () => { + class Dto { + constructor(public x: number) {} + } + expect(() => platformSerialize({ d: new Dto(1) })).toThrow(/Dto instance/); + }); + + it("throws on circular references via JSON.stringify", () => { + const obj: Record = {}; + obj.self = obj; + expect(() => platformSerialize(obj)).toThrow(TypeError); + }); + }); + + describe("class instance detection at top level", () => { + it("throws when the root value is a class instance", () => { + expect(() => platformSerialize(new Error("boom"))).toThrow(/Error instance/); + }); + }); +}); diff --git a/packages/sdk/src/utils/platform-serialize.ts b/packages/sdk/src/utils/platform-serialize.ts new file mode 100644 index 000000000..5ef11e46c --- /dev/null +++ b/packages/sdk/src/utils/platform-serialize.ts @@ -0,0 +1,59 @@ +/** + * Validate and serialize a value as it would cross the Platform JSON boundary. + * + * Mirrors the runtime checks the platform performs on workflow arguments, + * wait payloads, and trigger inputs so that local tests fail in the same + * places production fails. + * + * Throws on: + * - `NaN` / `Infinity` / `-Infinity` (`JSON.stringify` would silently emit `null`) + * - `BigInt` (TypeError is thrown by `JSON.stringify`; we emit a clearer message) + * - Non-plain objects (class instances, including `Date`, `Map`, `Set`, `Error`, + * and user-defined DTOs whose prototype is not `Object.prototype`) + * + * The replacer reads `this[key]` so the check sees the original value before + * any `toJSON` conversion (e.g. `Date.prototype.toJSON`). + * @param value - Value to validate and round-trip + * @returns The JSON-normalized value (undefined/function properties stripped, etc.) + */ +export function platformSerialize(value: T): T { + // Top-level undefined is allowed (matches the no-input convention for jobs); + // JSON.stringify(undefined) would otherwise yield the string "undefined" and + // JSON.parse would throw on it. + if (value === undefined) return undefined as T; + + const serialized = JSON.stringify(value, function (key, val) { + if (typeof val === "number" && !Number.isFinite(val)) { + throw new TypeError( + `platformSerialize: non-finite number at ${formatPath(key)}: ${String(val)}`, + ); + } + if (typeof val === "bigint") { + throw new TypeError( + `platformSerialize: BigInt is not JSON-serializable at ${formatPath(key)}`, + ); + } + // Look at the pre-toJSON value so Date/Map/Set/etc. can be detected. + const raw = (this as Record)[key]; + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + const proto = Object.getPrototypeOf(raw); + if (proto !== Object.prototype && proto !== null) { + const ctor = (raw as { constructor?: { name?: string } }).constructor?.name ?? "anonymous"; + throw new TypeError( + `platformSerialize: non-plain object at ${formatPath(key)} (${ctor} instance)`, + ); + } + } + return val; + }); + + // `JSON.stringify` returns undefined when the root value is a function/symbol. + if (serialized === undefined) { + throw new TypeError("platformSerialize: value at is not JSON-serializable"); + } + return JSON.parse(serialized) as T; +} + +function formatPath(key: string): string { + return key === "" ? "" : `"${key}"`; +} diff --git a/packages/sdk/src/vitest/__tests__/integration/vitest.config.ts b/packages/sdk/src/vitest/__tests__/integration/vitest.config.ts index 364eb0ff3..8f1813a3f 100644 --- a/packages/sdk/src/vitest/__tests__/integration/vitest.config.ts +++ b/packages/sdk/src/vitest/__tests__/integration/vitest.config.ts @@ -4,9 +4,18 @@ import { defineConfig } from "vitest/config"; import { createBlockPlugin } from "../../plugin"; const here = dirname(fileURLToPath(import.meta.url)); +const sdkSrc = resolve(here, "../../.."); export default defineConfig({ plugins: [createBlockPlugin()], + resolve: { + // Match the SDK's main tsconfig path mapping so files reachable from the + // tailor-runtime environment (e.g. mock.ts → configure/) can use `@/` + // imports just like in the main test suite. + alias: { + "@": sdkSrc, + }, + }, test: { watch: false, environment: resolve(here, "../../environment.ts"), diff --git a/packages/sdk/src/vitest/__tests__/mock.test.ts b/packages/sdk/src/vitest/__tests__/mock.test.ts index 24416584a..52ca8f8d3 100644 --- a/packages/sdk/src/vitest/__tests__/mock.test.ts +++ b/packages/sdk/src/vitest/__tests__/mock.test.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createWorkflowJob } from "@/configure/services/workflow/job"; +import { createWorkflow } from "@/configure/services/workflow/workflow"; import { tailordbMock, workflowMock, @@ -113,45 +115,45 @@ describe("mock", () => { workflowMock.reset(); }); - test("records triggered jobs", () => { + test("records triggered jobs", async () => { const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - trigger("my-job", { key: "value" }); + await trigger("my-job", { key: "value" }); expect(workflowMock.triggeredJobs).toEqual([{ jobName: "my-job", args: { key: "value" } }]); }); - test("setJobHandler provides content-based responses", () => { + test("setJobHandler provides content-based responses", async () => { workflowMock.setJobHandler((jobName) => { if (jobName === "validate") return { valid: true }; return null; }); const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - const result = trigger("validate", {}); + const result = await trigger("validate", {}); expect(result).toEqual({ valid: true }); }); - test("enqueueResults provides order-based responses", () => { + test("enqueueResults provides order-based responses", async () => { workflowMock.enqueueResults({ step: 1 }, { step: 2 }); const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - expect(trigger("job1", {})).toEqual({ step: 1 }); - expect(trigger("job2", {})).toEqual({ step: 2 }); + expect(await trigger("job1", {})).toEqual({ step: 1 }); + expect(await trigger("job2", {})).toEqual({ step: 2 }); }); - test("enqueueResult takes priority over jobHandler", () => { + test("enqueueResult takes priority over jobHandler", async () => { workflowMock.setJobHandler(() => ({ fallback: true })); workflowMock.enqueueResult({ queued: true }); const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - expect(trigger("job1", {})).toEqual({ queued: true }); - expect(trigger("job2", {})).toEqual({ fallback: true }); + expect(await trigger("job1", {})).toEqual({ queued: true }); + expect(await trigger("job2", {})).toEqual({ fallback: true }); }); - test("reset clears all state", () => { + test("reset clears all state", async () => { const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - trigger("job", {}); + await trigger("job", {}); workflowMock.reset(); @@ -597,6 +599,217 @@ describe("mock", () => { }); }); + // Round-trip tests: exercise createWorkflowJob / createWorkflow through the + // mock so the registry → globalThis.tailor.workflow → mock path is covered + // end-to-end. Names must be unique per-test to avoid clobbering the global + // registry across the suite. + describe("workflow delegation through globalThis.tailor.workflow", () => { + beforeEach(() => { + workflowMock.reset(); + }); + + test("wjob.trigger() executes the registered body when no handler is set", async () => { + const seen: unknown[] = []; + const fn = createWorkflowJob({ + name: "delegation-default-body", + body: (input: { id: string }) => { + seen.push(input); + return { received: input.id }; + }, + }); + const result = await fn.trigger({ id: "x" }); + expect(seen).toEqual([{ id: "x" }]); + expect(result).toEqual({ received: "x" }); + expect(workflowMock.triggeredJobs).toEqual([ + { jobName: "delegation-default-body", args: { id: "x" } }, + ]); + }); + + test("setJobHandler opts out of the registered body fallback", async () => { + let bodyRan = false; + const fn = createWorkflowJob({ + name: "delegation-handler-wins", + body: () => { + bodyRan = true; + return { fromBody: true }; + }, + }); + workflowMock.setJobHandler(() => ({ fromHandler: true })); + const result = await fn.trigger(); + expect(bodyRan).toBe(false); + expect(result).toEqual({ fromHandler: true }); + }); + + test("enqueueResult wins over both handler and registered body", async () => { + let bodyRan = false; + const fn = createWorkflowJob({ + name: "delegation-queue-wins", + body: () => { + bodyRan = true; + return { fromBody: true }; + }, + }); + workflowMock.setJobHandler(() => ({ fromHandler: true })); + workflowMock.enqueueResult({ fromQueue: true }); + const result = await fn.trigger(); + expect(bodyRan).toBe(false); + expect(result).toEqual({ fromQueue: true }); + }); + + test("wjob.trigger() args are platformSerialized before the body sees them", async () => { + let seen: unknown; + const fn = createWorkflowJob({ + name: "delegation-serialize-args", + body: (input: { id: string }) => { + seen = input; + return { ok: true }; + }, + }); + // platformSerialize strips properties whose value is undefined (matches + // JSON.stringify), so the body should see `{ id }` only — never the + // raw `extra: undefined` the caller passed. + await (fn.trigger as (a: unknown) => Promise)({ + id: "x", + extra: undefined, + }); + expect(seen).toEqual({ id: "x" }); + }); + + test("wjob.trigger() throws when args contain a class instance (Platform boundary)", async () => { + const fn = createWorkflowJob({ + name: "delegation-class-args", + body: () => ({ ok: true }), + }); + await expect( + (fn.trigger as (a: unknown) => Promise)({ at: new Date() }), + ).rejects.toThrow(/non-plain object/); + }); + + test("wjob.trigger() throws when the body returns NaN (Platform boundary)", async () => { + const fn = createWorkflowJob({ + name: "delegation-nan-output", + // NaN passes the static JsonCompatible check (it is `number`) but the + // runtime boundary still rejects it — mirroring Platform behavior. + body: () => ({ score: Number.NaN }), + }); + await expect(fn.trigger()).rejects.toThrow(/NaN|non-finite/); + }); + + test("wf.trigger() executes the registered main job", async () => { + const mainSeen: unknown[] = []; + const main = createWorkflowJob({ + name: "delegation-wf-main", + body: (input: { x: number }) => { + mainSeen.push(input); + return { doubled: input.x * 2 }; + }, + }); + const wf = createWorkflow({ + name: "delegation-wf", + mainJob: main, + }); + const execId = await wf.trigger({ x: 21 }); + expect(execId).toBe("mock-execution-id"); + expect(mainSeen).toEqual([{ x: 21 }]); + expect(workflowMock.calls).toEqual([ + { method: "triggerWorkflow", args: ["delegation-wf", { x: 21 }, undefined] }, + ]); + // The main job invocation is also recorded as a regular triggered job + // since mockTriggerWorkflow routes through mockTriggerJobFunction. + expect(workflowMock.triggeredJobs).toEqual([ + { jobName: "delegation-wf-main", args: { x: 21 } }, + ]); + }); + + test("setJobHandler intercepts the main job when wf.trigger() runs", async () => { + let mainRan = false; + const main = createWorkflowJob({ + name: "delegation-wf-main-stubbed", + body: (input: { id: string }) => { + mainRan = true; + return { fromBody: true, id: input.id }; + }, + }); + const wf = createWorkflow({ + name: "delegation-wf-main-stubbed-wf", + mainJob: main, + }); + workflowMock.setJobHandler(() => ({ fromHandler: true })); + const execId = await wf.trigger({ id: "x" }); + expect(execId).toBe("mock-execution-id"); + expect(mainRan).toBe(false); + }); + + test("setTriggerHandler opts out of executing the registered main job", async () => { + let mainRan = false; + const main = createWorkflowJob({ + name: "delegation-wf-handler-main", + body: (input: { id: string }) => { + mainRan = true; + return { id: input.id }; + }, + }); + const wf = createWorkflow({ + name: "delegation-wf-handler", + mainJob: main, + }); + workflowMock.setTriggerHandler("custom-exec-id"); + const execId = await wf.trigger({ id: "x" }); + expect(mainRan).toBe(false); + expect(execId).toBe("custom-exec-id"); + }); + + test("mockWait records the platform-serialized payload", () => { + // platformSerialize strips properties whose value is undefined — the + // handler and waitCalls should see the normalized payload, never the + // raw object the caller passed. + const seenInHandler: unknown[] = []; + workflowMock.setWaitHandler((key: string, payload: unknown) => { + seenInHandler.push({ key, payload }); + return { approved: true }; + }); + const result = (globalThis as any).tailor.workflow.wait("approve", { + reason: "ok", + dropped: undefined, + }); + expect(result).toEqual({ approved: true }); + expect(seenInHandler).toEqual([{ key: "approve", payload: { reason: "ok" } }]); + expect(workflowMock.waitCalls).toEqual([{ key: "approve", payload: { reason: "ok" } }]); + }); + + test("mockWait throws when payload contains a class instance", () => { + expect(() => (globalThis as any).tailor.workflow.wait("approve", { at: new Date() })).toThrow( + /non-plain object/, + ); + }); + + test("mockResolve wraps the callback so its return value crosses the JSON boundary", async () => { + let callbackReturn: unknown; + workflowMock.setResolveHandler(async (_executionId, _key, callback) => { + callbackReturn = await callback({ approved: true }); + }); + await (globalThis as any).tailor.workflow.resolve( + "exec-1", + "approval", + // Callback's return value is platform-serialized — `dropped: undefined` + // is stripped before reaching the handler. + () => ({ ok: true, dropped: undefined }), + ); + expect(callbackReturn).toEqual({ ok: true }); + }); + + test("mockResolve rejects when callback returns a class instance", async () => { + workflowMock.setResolveHandler(async (_executionId, _key, callback) => { + await callback({ approved: true }); + }); + await expect( + (globalThis as any).tailor.workflow.resolve("exec-1", "approval", () => ({ + at: new Date(), + })), + ).rejects.toThrow(/non-plain object/); + }); + }); + describe("injectMocks / cleanupMocks", () => { test("cleanupMocks removes all globals", () => { cleanupMocks(globalThis); diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index a1f0a21b7..d26f1b1eb 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -5,6 +5,10 @@ * globalThis by the tailor-runtime Vitest environment. Tests can configure * responses and assert on recorded calls via the exported mock objects. */ +import { WORKFLOW_TEST_ENV_KEY } from "@/configure/services/workflow/job"; +import { getRegisteredJob, getRegisteredWorkflow } from "@/configure/services/workflow/registry"; +import { platformSerialize } from "@/utils/platform-serialize"; +import type { TailorEnv } from "@/types/env"; // --------------------------------------------------------------------------- // Types @@ -97,10 +101,10 @@ interface MockState { executedQueries: ExecutedQuery[]; createdClients: CreatedClient[]; // Workflow - jobHandler: JobHandler; + jobHandler: JobHandler | null; jobResultQueue: unknown[]; triggeredJobs: TriggeredJob[]; - triggerHandler: string | TriggerHandlerFn; + triggerHandler: string | TriggerHandlerFn | null; waitHandler: unknown | WaitHandlerFn; resolveHandler: ResolveHandler | null; workflowCalls: WorkflowCall[]; @@ -150,10 +154,10 @@ function createDefaultState(): MockState { queryResultQueue: [], executedQueries: [], createdClients: [], - jobHandler: () => null, + jobHandler: null, jobResultQueue: [], triggeredJobs: [], - triggerHandler: "mock-execution-id", + triggerHandler: null, waitHandler: null, resolveHandler: null, workflowCalls: [], @@ -300,6 +304,11 @@ export const tailordbMock = { export const workflowMock = { /** * Set a fallback job handler. Called when the result queue is empty. + * + * Setting a handler opts out of the registered-body fallback — when a handler + * is set, the mock returns whatever the handler returns instead of executing + * the `createWorkflowJob` body. To stub-and-record without setting a handler, + * leave `jobHandler` unset and the mock will execute the registered body. * @param handler - Function that returns a result for a given job name and args */ setJobHandler(handler: JobHandler): void { @@ -308,8 +317,9 @@ export const workflowMock = { /** * Enqueue a single result for the next `triggerJobFunction` call. Consumed in FIFO - * order; when the queue is exhausted, subsequent calls fall back to `setJobHandler` - * (default: null). Use `enqueueResults` to stage multiple results in one call. + * order; when the queue is exhausted, subsequent calls fall back to `setJobHandler`, + * then to executing the registered job body, then to `null`. Use `enqueueResults` + * to stage multiple results in one call. * @param result - Result to return from the next `triggerJobFunction` call */ enqueueResult(result: unknown): void { @@ -338,7 +348,14 @@ export const workflowMock = { /** * Configure what `tailor.workflow.triggerWorkflow` returns. Pass a string to return * the same execution ID for every call, or a function `(name, args, options) => string` - * to compute one per call. Default: `"mock-execution-id"`. + * to compute one per call. + * + * Setting a handler opts out of executing the registered workflow's main job — + * the mock returns the handler's value without running the body or recording + * the main job in `triggeredJobs`. When unset, the mock invokes the main job + * via the same path as a regular `triggerJobFunction` call (so the main job + * shows up in `triggeredJobs` and respects `setJobHandler` / `enqueueResult`) + * and returns `"mock-execution-id"`. * @param handler - Static execution ID or a function that returns one */ setTriggerHandler(handler: string | TriggerHandlerFn): void { @@ -392,13 +409,19 @@ export const workflowMock = { .map((c) => ({ executionId: c.args[0] as string, key: c.args[1] as string })); }, - /** Reset all workflow mock state. Call in `beforeEach`. */ + /** + * Reset all workflow mock state. Call in `beforeEach`. + * + * Does NOT clear the job/workflow registry — those are populated by + * `createWorkflowJob`/`createWorkflow` side effects at module-import time + * and are not per-test state. + */ reset(): void { const state = getState(); - state.jobHandler = () => null; + state.jobHandler = null; state.jobResultQueue.length = 0; state.triggeredJobs.length = 0; - state.triggerHandler = "mock-execution-id"; + state.triggerHandler = null; state.waitHandler = null; state.resolveHandler = null; state.workflowCalls.length = 0; @@ -631,11 +654,39 @@ function resolveQuery(query: string, params: unknown[]): MockQueryResult { // Mock: tailor.workflow // --------------------------------------------------------------------------- -function mockTriggerJobFunction(jobName: string, args?: unknown): unknown { +/** + * Build the context passed to a registered job body when the mock executes + * it. Mirrors the platform's job context shape (`{ env, invoker }`); env is + * sourced from `process.env[WORKFLOW_TEST_ENV_KEY]` for backward compat with + * the previous local-trigger implementation. + * @returns The job context with env and a null invoker + */ +function buildJobContext(): { env: TailorEnv; invoker: null } { + let env: TailorEnv = {} as TailorEnv; + try { + env = JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}"); + } catch { + // Malformed env JSON: leave env as the empty object so the test can still run. + } + return { env, invoker: null }; +} + +const DEFAULT_EXECUTION_ID = "mock-execution-id"; + +async function mockTriggerJobFunction(jobName: string, args?: unknown): Promise { const state = getState(); - state.triggeredJobs.push({ jobName, args }); + const serializedArgs = platformSerialize(args); + state.triggeredJobs.push({ jobName, args: serializedArgs }); + if (state.jobResultQueue.length > 0) return state.jobResultQueue.shift(); - return state.jobHandler(jobName, args); + if (state.jobHandler) return state.jobHandler(jobName, serializedArgs); + + const body = getRegisteredJob(jobName); + if (body) { + const output = await body(serializedArgs, buildJobContext()); + return platformSerialize(output); + } + return null; } async function mockTriggerWorkflow( @@ -644,16 +695,35 @@ async function mockTriggerWorkflow( options?: TriggerWorkflowOptions, ): Promise { const state = getState(); - state.workflowCalls.push({ method: "triggerWorkflow", args: [workflowName, args, options] }); + const serializedArgs = platformSerialize(args); + state.workflowCalls.push({ + method: "triggerWorkflow", + args: [workflowName, serializedArgs, options], + }); + const handler = state.triggerHandler; - return typeof handler === "function" ? handler(workflowName, args, options) : handler; + if (handler !== null) { + return typeof handler === "function" ? handler(workflowName, serializedArgs, options) : handler; + } + + const workflow = getRegisteredWorkflow(workflowName); + if (workflow) { + // Route the main job through mockTriggerJobFunction so the invocation + // appears in `triggeredJobs` and respects `setJobHandler` / `enqueueResult` + // uniformly — there is no longer a special path for the workflow entry. + await mockTriggerJobFunction(workflow.mainJobName, serializedArgs); + } + return DEFAULT_EXECUTION_ID; } function mockWait(key: string, payload?: unknown): unknown { const state = getState(); - state.workflowCalls.push({ method: "wait", args: [key, payload] }); + const serializedPayload = platformSerialize(payload); + state.workflowCalls.push({ method: "wait", args: [key, serializedPayload] }); const handler = state.waitHandler; - return typeof handler === "function" ? (handler as WaitHandlerFn)(key, payload) : handler; + return typeof handler === "function" + ? (handler as WaitHandlerFn)(key, serializedPayload) + : handler; } // Records the resolve call. By default the callback is not invoked, mirroring @@ -662,15 +732,22 @@ function mockWait(key: string, payload?: unknown): unknown { // resolve→wait wiring can register a handler via workflowMock.setResolveHandler // — the handler receives `(executionId, key, callback)` and decides whether to // invoke the callback (typically with a synthesized payload). +// +// The callback is wrapped so its return value crosses the same JSON boundary +// the platform enforces — so a test that hands the callback a Date or NaN +// fails locally the same way it would on platform. async function mockResolve( executionId: string, key: string, callback: (payload: unknown) => unknown | Promise, ): Promise { const state = getState(); - state.workflowCalls.push({ method: "resolve", args: [executionId, key, callback] }); + const wrappedCallback = async (payload: unknown): Promise => { + return platformSerialize(await callback(payload)); + }; + state.workflowCalls.push({ method: "resolve", args: [executionId, key, wrappedCallback] }); if (state.resolveHandler) { - await state.resolveHandler(executionId, key, callback); + await state.resolveHandler(executionId, key, wrappedCallback); } } From e6a77c821a7de8410ba1e750f8dce2edfb9e9cf1 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Tue, 19 May 2026 16:19:14 +0900 Subject: [PATCH 2/2] refactor(workflow): consolidate platform-workflow shim and harden serialize errors - Move getPlatformWorkflow() into registry.ts so job.ts / workflow.ts share one shim - Note body fallback behaviour in the Workflow Mock docs section - Throw specific TypeError messages for root-level function / symbol in platformSerialize --- packages/sdk/docs/testing.md | 2 +- .../src/configure/services/workflow/job.ts | 19 +------------ .../configure/services/workflow/registry.ts | 22 +++++++++++++++ .../configure/services/workflow/workflow.ts | 27 +------------------ .../sdk/src/utils/platform-serialize.test.ts | 12 +++++++++ packages/sdk/src/utils/platform-serialize.ts | 16 +++++++---- 6 files changed, 48 insertions(+), 50 deletions(-) diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 4d24270b4..22398d59c 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -107,7 +107,7 @@ test("content-based mock", async () => { ### Workflow Mock -The environment auto-injects `tailor.workflow.triggerJobFunction`. Use `workflowMock` to configure job responses: +The environment auto-injects `tailor.workflow.triggerJobFunction`. Use `workflowMock` to configure job responses. When no handler/result is configured for a job, the mock falls back to running the body registered by `createWorkflowJob`, so dependent jobs execute their real implementations by default — see [Running a full workflow locally](#running-a-full-workflow-locally) for that flow. ```typescript import { workflowMock } from "@tailor-platform/sdk/vitest"; diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 7c27074f9..dcc110c97 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -1,5 +1,5 @@ import { brandValue } from "@/utils/brand"; -import { registerJob, type RegisteredJobBody } from "./registry"; +import { getPlatformWorkflow, registerJob, type RegisteredJobBody } from "./registry"; import type { TailorEnv } from "@/types/env"; import type { JsonCompatible } from "@/types/helpers"; import type { TailorInvoker } from "@/types/user"; @@ -68,23 +68,6 @@ interface CreateWorkflowJobConfig { readonly body: JobBody; } -function getPlatformWorkflow() { - const platform = globalThis as { - tailor?: { - workflow?: { - triggerJobFunction: (name: string, args?: unknown) => unknown; - }; - }; - }; - const workflow = platform.tailor?.workflow; - if (!workflow) { - throw new Error( - "tailor.workflow is not available. Use the tailor-runtime environment from @tailor-platform/sdk/vitest in tests.", - ); - } - return workflow; -} - /** * Create a workflow job definition. * diff --git a/packages/sdk/src/configure/services/workflow/registry.ts b/packages/sdk/src/configure/services/workflow/registry.ts index c38be40f2..d95fade69 100644 --- a/packages/sdk/src/configure/services/workflow/registry.ts +++ b/packages/sdk/src/configure/services/workflow/registry.ts @@ -18,9 +18,15 @@ export interface RegisteredWorkflow { const JOB_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:job-registry"); const WORKFLOW_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:workflow-registry"); +type PlatformWorkflow = { + triggerWorkflow: (name: string, args?: unknown, options?: unknown) => Promise; + triggerJobFunction: (name: string, args?: unknown) => unknown; +}; + type GlobalWithRegistry = typeof globalThis & { [JOB_REGISTRY_KEY]?: Map; [WORKFLOW_REGISTRY_KEY]?: Map; + tailor?: { workflow?: PlatformWorkflow }; }; function jobs(): Map { @@ -84,3 +90,19 @@ export function registerWorkflow(name: string, mainJobName: string): void { export function getRegisteredWorkflow(name: string): RegisteredWorkflow | undefined { return workflows().get(name); } + +/** + * Return the `globalThis.tailor.workflow` shim used by `.trigger()` calls. + * Production builds install it natively; tests install it via the + * `tailor-runtime` vitest environment. + * @returns The platform-injected workflow shim + */ +export function getPlatformWorkflow(): PlatformWorkflow { + const workflow = (globalThis as GlobalWithRegistry).tailor?.workflow; + if (!workflow) { + throw new Error( + "tailor.workflow is not available. Use the tailor-runtime environment from @tailor-platform/sdk/vitest in tests.", + ); + } + return workflow; +} diff --git a/packages/sdk/src/configure/services/workflow/workflow.ts b/packages/sdk/src/configure/services/workflow/workflow.ts index 59b5d0bce..a22c7b7fa 100644 --- a/packages/sdk/src/configure/services/workflow/workflow.ts +++ b/packages/sdk/src/configure/services/workflow/workflow.ts @@ -1,36 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { brandValue } from "@/utils/brand"; -import { registerWorkflow } from "./registry"; +import { getPlatformWorkflow, registerWorkflow } from "./registry"; import type { WorkflowJob } from "./job"; import type { AuthInvoker } from "../auth"; import type { MachineUserName } from "@/configure/types/machine-user"; import type { ConcurrencyPolicy, RetryPolicy } from "@/types/workflow.generated"; -type TriggerWorkflowOptions = { - authInvoker?: AuthInvoker | MachineUserName; -}; - -function getPlatformWorkflow() { - const platform = globalThis as { - tailor?: { - workflow?: { - triggerWorkflow: ( - name: string, - args?: unknown, - options?: TriggerWorkflowOptions, - ) => Promise; - }; - }; - }; - const workflow = platform.tailor?.workflow; - if (!workflow) { - throw new Error( - "tailor.workflow is not available. Use the tailor-runtime environment from @tailor-platform/sdk/vitest in tests.", - ); - } - return workflow; -} - export type { ConcurrencyPolicy, RetryPolicy }; export interface WorkflowConfig< diff --git a/packages/sdk/src/utils/platform-serialize.test.ts b/packages/sdk/src/utils/platform-serialize.test.ts index b54edd2e8..a9682253b 100644 --- a/packages/sdk/src/utils/platform-serialize.test.ts +++ b/packages/sdk/src/utils/platform-serialize.test.ts @@ -71,5 +71,17 @@ describe("platformSerialize", () => { it("throws when the root value is a class instance", () => { expect(() => platformSerialize(new Error("boom"))).toThrow(/Error instance/); }); + + it("throws with a specific message when the root value is a function", () => { + expect(() => platformSerialize(() => 1)).toThrow( + /function is not JSON-serializable at /, + ); + }); + + it("throws with a specific message when the root value is a symbol", () => { + expect(() => platformSerialize(Symbol("x"))).toThrow( + /Symbol is not JSON-serializable at /, + ); + }); }); }); diff --git a/packages/sdk/src/utils/platform-serialize.ts b/packages/sdk/src/utils/platform-serialize.ts index 5ef11e46c..307281e49 100644 --- a/packages/sdk/src/utils/platform-serialize.ts +++ b/packages/sdk/src/utils/platform-serialize.ts @@ -22,6 +22,16 @@ export function platformSerialize(value: T): T { // JSON.parse would throw on it. if (value === undefined) return undefined as T; + // Root-level function/symbol would make JSON.stringify return undefined, + // which we report below as a generic error. Catch them here so the message + // is specific (mirrors the per-property messages produced by the replacer). + if (typeof value === "function") { + throw new TypeError("platformSerialize: function is not JSON-serializable at "); + } + if (typeof value === "symbol") { + throw new TypeError("platformSerialize: Symbol is not JSON-serializable at "); + } + const serialized = JSON.stringify(value, function (key, val) { if (typeof val === "number" && !Number.isFinite(val)) { throw new TypeError( @@ -47,11 +57,7 @@ export function platformSerialize(value: T): T { return val; }); - // `JSON.stringify` returns undefined when the root value is a function/symbol. - if (serialized === undefined) { - throw new TypeError("platformSerialize: value at is not JSON-serializable"); - } - return JSON.parse(serialized) as T; + return JSON.parse(serialized as string) as T; } function formatPath(key: string): string {