Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/sdk/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions packages/sdk/src/configure/services/workflow/job.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { brandValue } from "@/utils/brand";
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";
Expand Down Expand Up @@ -104,12 +105,18 @@ export const createWorkflowJob = <const Name extends string, I = undefined, O =
config: CreateWorkflowJobConfig<Name, I, O>,
): WorkflowJob<Name, I, Awaited<O>> => {
const body = config.body as (input: I, context: WorkflowJobContext) => O | Promise<O>;

// 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<O>;
},
body,
} as WorkflowJob<Name, I, Awaited<O>>,
Expand Down
108 changes: 108 additions & 0 deletions packages/sdk/src/configure/services/workflow/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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<unknown>;

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 PlatformWorkflow = {
triggerWorkflow: (name: string, args?: unknown, options?: unknown) => Promise<string>;
triggerJobFunction: (name: string, args?: unknown) => unknown;
};

type GlobalWithRegistry = typeof globalThis & {
[JOB_REGISTRY_KEY]?: Map<string, RegisteredJobBody>;
[WORKFLOW_REGISTRY_KEY]?: Map<string, RegisteredWorkflow>;
tailor?: { workflow?: PlatformWorkflow };
};

function jobs(): Map<string, RegisteredJobBody> {
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<string, RegisteredWorkflow> {
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);
}

/**
* 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;
}
12 changes: 6 additions & 6 deletions packages/sdk/src/configure/services/workflow/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { brandValue } from "@/utils/brand";
import { getPlatformWorkflow, registerWorkflow } from "./registry";
import type { WorkflowJob } from "./job";
import type { AuthInvoker } from "../auth";
import type { MachineUserName } from "@/configure/types/machine-user";
Expand Down Expand Up @@ -62,16 +63,15 @@ interface WorkflowDefinition<Job extends WorkflowJob<any, any, any>> {
export function createWorkflow<Job extends WorkflowJob<any, any, any>>(
config: WorkflowDefinition<Job>,
): Workflow<Job> {
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<Job>,
"workflow",
);
}
87 changes: 87 additions & 0 deletions packages/sdk/src/utils/platform-serialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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<string, unknown> = {};
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/);
});

it("throws with a specific message when the root value is a function", () => {
expect(() => platformSerialize(() => 1)).toThrow(
/function is not JSON-serializable at <root>/,
);
});

it("throws with a specific message when the root value is a symbol", () => {
expect(() => platformSerialize(Symbol("x"))).toThrow(
/Symbol is not JSON-serializable at <root>/,
);
});
});
});
65 changes: 65 additions & 0 deletions packages/sdk/src/utils/platform-serialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* 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<T>(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;

// 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 <root>");
}
if (typeof value === "symbol") {
throw new TypeError("platformSerialize: Symbol is not JSON-serializable at <root>");
}

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<string, unknown>)[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;
});

return JSON.parse(serialized as string) as T;
}

function formatPath(key: string): string {
return key === "" ? "<root>" : `"${key}"`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading