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
17 changes: 17 additions & 0 deletions .changeset/workflow-mock-set-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@tailor-platform/sdk": minor
"@tailor-platform/create-sdk": patch
---

Add `workflowMock.setEnv()` to control the `env` value passed to job bodies when `createWorkflowJob().trigger()` is invoked locally. Tests using the `tailor-runtime` Vitest environment can now configure the env through the same `workflowMock` helper they use for `setJobHandler` / `setWaitHandler`, without touching `process.env`.

```typescript
import { workflowMock } from "@tailor-platform/sdk/vitest";

test("workflow.mainJob.trigger() executes all jobs", async () => {
workflowMock.setEnv({ STAGE: "test" });
await workflow.mainJob.trigger({ orderId: "order-1", amount: 100 });
});
```

The previous env-var-based pattern is now deprecated. A non-breaking fallback is retained, but `workflowMock.setEnv()` takes priority when both are set.
2 changes: 1 addition & 1 deletion packages/create-sdk/templates/workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Demonstrates workflow patterns with job chaining, trigger testing, and dependenc
- Workflow with multiple jobs (`createWorkflow`, `createWorkflowJob`)
- Job chaining via `.trigger()`
- Database operations in workflow jobs (DI pattern)
- Integration testing with `WORKFLOW_TEST_ENV_KEY`
- Integration testing with `workflow.mainJob.trigger()`

## Getting Started

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { WORKFLOW_TEST_ENV_KEY } from "@tailor-platform/sdk/test";
import { afterEach, describe, expect, test, vi } from "vitest";
import { workflowMock } from "@tailor-platform/sdk/vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import workflow, {
fulfillOrder,
processPayment,
Expand All @@ -8,8 +8,11 @@ import workflow, {
} from "./order-fulfillment";

describe("order fulfillment workflow", () => {
beforeEach(() => {
workflowMock.reset();
});

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -115,7 +118,7 @@ describe("order fulfillment workflow", () => {

describe("integration tests with .trigger()", () => {
test("workflow.mainJob.trigger() executes all jobs", async () => {
vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({}));
workflowMock.setEnv({ STAGE: "test" });

const result = await workflow.mainJob.trigger({
orderId: "order-3",
Expand Down
11 changes: 5 additions & 6 deletions packages/sdk/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ 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

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 @@ -604,18 +603,18 @@ 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 with real job bodies, call `workflow.mainJob.trigger()`. Dependent jobs run their real `.body()` functions. Use `workflowMock.setEnv()` to control the env value that triggered jobs receive in their context (defaults to `{}`):

```typescript
import { WORKFLOW_TEST_ENV_KEY } from "@tailor-platform/sdk/test";
import { afterEach, describe, expect, test, vi } from "vitest";
import { workflowMock } from "@tailor-platform/sdk/vitest";
import { beforeEach, describe, expect, test } from "vitest";
import workflow from "./order-fulfillment";

describe("order-fulfillment workflow", () => {
afterEach(() => vi.unstubAllEnvs());
beforeEach(() => workflowMock.reset());

test("mainJob.trigger() executes all jobs", async () => {
vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({}));
workflowMock.setEnv({ PAYMENT_GATEWAY: "stripe" });

const result = await workflow.mainJob.trigger({ orderId: "order-3", amount: 300 });

Expand Down
37 changes: 34 additions & 3 deletions packages/sdk/src/configure/services/workflow/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import type { TailorEnv } from "@/types/env";
import type { JsonCompatible } from "@/types/helpers";
import type { TailorInvoker } from "@/types/user";

/**
* Well-known `globalThis` key consumed by `createWorkflowJob().trigger()` when
* the body is invoked locally. Written by `workflowMock.setEnv()` from
* `@tailor-platform/sdk/vitest`. The same literal is re-declared (not imported)
* in `src/vitest/mock.ts` because that file is loaded by the `tailor-runtime`
* Vitest environment in nested configs that do not resolve `@/` aliases. The
* drift-guard test in `src/vitest/__tests__/mock.test.ts` asserts both copies
* stay identical.
* @internal
*/
export const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv";

/**
* Context object passed as the second argument to workflow job body functions.
*/
Expand Down Expand Up @@ -57,8 +69,11 @@ export interface WorkflowJob<Name extends string = string, Input = undefined, Ou
}

/**
* Environment variable key for workflow testing.
* Contains JSON-serialized TailorEnv object.
* Environment variable key historically consumed by `createWorkflowJob().trigger()`
* to inject the `env` value passed to job bodies during local execution.
* @deprecated Use `workflowMock.setEnv()` from `@tailor-platform/sdk/vitest`.
* Retained for backwards compatibility — `.trigger()` still falls back to this env
* var when `workflowMock.setEnv()` has not been called.
*/
export const WORKFLOW_TEST_ENV_KEY = "TAILOR_TEST_WORKFLOW_ENV";

Expand Down Expand Up @@ -108,7 +123,23 @@ export const createWorkflowJob = <const Name extends string, I = undefined, O =
{
name: config.name,
trigger: async (args?: unknown) => {
const env: TailorEnv = JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}");
// `workflowMock.setEnv()` takes priority. Fall back to the deprecated
// `TAILOR_TEST_WORKFLOW_ENV` env var when the global key is unset, null,
// or a non-object primitive (string / number / boolean / symbol) so
// existing tests using `vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...)` keep
// working. Any object value (plain objects, arrays, class instances,
// etc.) is shallow-copied and used as-is; the type system constrains
// `setEnv()` callers to `TailorEnv`, so non-plain-object cases only
// arise from direct `globalThis` misuse. Shallow-copy so job bodies
// cannot mutate the source object across triggers, matching the
// env-var path which returns a fresh object from `JSON.parse` each
// call.
const fromGlobal = (globalThis as Record<string, unknown>)[WORKFLOW_ENV_GLOBAL_KEY];
const env = (
typeof fromGlobal === "object" && fromGlobal !== null
? { ...(fromGlobal as Record<string, unknown>) }
: JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}")
Comment on lines +137 to +141
Comment on lines +126 to +141
) as TailorEnv;
Comment on lines +126 to +142
return await body(args as I, { env, invoker: null });
},
body,
Expand Down
86 changes: 85 additions & 1 deletion packages/sdk/src/vitest/__tests__/mock.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WORKFLOW_ENV_GLOBAL_KEY as JOB_WORKFLOW_ENV_GLOBAL_KEY } from "../../configure/services/workflow/job";
import {
tailordbMock,
workflowMock,
Expand All @@ -12,6 +13,7 @@ import {
cleanupMocks,
STATE_KEY,
RUNTIME_FLAG_KEY,
WORKFLOW_ENV_GLOBAL_KEY,
} from "../mock";

describe("mock", () => {
Expand Down Expand Up @@ -113,6 +115,10 @@ describe("mock", () => {
workflowMock.reset();
});

afterEach(() => {
vi.unstubAllEnvs();
});

test("records triggered jobs", () => {
const trigger = (globalThis as any).tailor.workflow.triggerJobFunction;
trigger("my-job", { key: "value" });
Expand Down Expand Up @@ -157,6 +163,84 @@ describe("mock", () => {

expect(workflowMock.triggeredJobs).toHaveLength(0);
});

test("setEnv exposes env to job bodies via .trigger()", async () => {
const { createWorkflowJob } = await import("../../configure/services/workflow/job");
const captureEnv = createWorkflowJob({
name: "capture-env",
body: (_input: undefined, ctx) => ctx.env,
});

workflowMock.setEnv({ STAGE: "test", REGION: "asia" });
const env = await captureEnv.trigger();

expect(env).toEqual({ STAGE: "test", REGION: "asia" });
});

test("reset clears env back to {}", async () => {
const { createWorkflowJob } = await import("../../configure/services/workflow/job");
const captureEnv = createWorkflowJob({
name: "capture-env-reset",
body: (_input: undefined, ctx) => ctx.env,
});

workflowMock.setEnv({ STAGE: "test" });
workflowMock.reset();

expect(await captureEnv.trigger()).toEqual({});
});

test("setEnv takes priority over WORKFLOW_TEST_ENV_KEY env var", async () => {
const { createWorkflowJob, WORKFLOW_TEST_ENV_KEY } =
await import("../../configure/services/workflow/job");
const captureEnv = createWorkflowJob({
name: "capture-env-priority",
body: (_input: undefined, ctx) => ctx.env,
});

vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "fallback" }));
workflowMock.setEnv({ STAGE: "from-setenv" });

expect(await captureEnv.trigger()).toEqual({ STAGE: "from-setenv" });
});
Comment on lines +201 to +205
Comment on lines +201 to +205

test("falls back to WORKFLOW_TEST_ENV_KEY env var when setEnv not called", async () => {
const { createWorkflowJob, WORKFLOW_TEST_ENV_KEY } =
await import("../../configure/services/workflow/job");
const captureEnv = createWorkflowJob({
name: "capture-env-fallback",
body: (_input: undefined, ctx) => ctx.env,
});

vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "from-env-var" }));

expect(await captureEnv.trigger()).toEqual({ STAGE: "from-env-var" });
});

test("falls back to env var when globalThis key holds a non-object", async () => {
const { createWorkflowJob, WORKFLOW_TEST_ENV_KEY } =
await import("../../configure/services/workflow/job");
const captureEnv = createWorkflowJob({
name: "capture-env-non-object",
body: (_input: undefined, ctx) => ctx.env,
});

try {
(globalThis as Record<string, unknown>)[WORKFLOW_ENV_GLOBAL_KEY] = "not-an-object";
vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "from-env-var" }));

expect(await captureEnv.trigger()).toEqual({ STAGE: "from-env-var" });
} finally {
delete (globalThis as Record<string, unknown>)[WORKFLOW_ENV_GLOBAL_KEY];
}
});

test("WORKFLOW_ENV_GLOBAL_KEY is the same constant in mock.ts and job.ts", () => {
// The constant is declared in both files because mock.ts must remain
// import-free of `@/` aliases (it's loaded by nested Vitest configs that
// do not resolve them). This test guards against silent drift.
expect(WORKFLOW_ENV_GLOBAL_KEY).toBe(JOB_WORKFLOW_ENV_GLOBAL_KEY);
});
});

describe("error classes", () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/src/vitest/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
* responses and assert on recorded calls via the exported mock objects.
*/

import type { TailorEnv } from "../types/env";

// Re-declared (not imported) because mock.ts is loaded by the tailor-runtime
// Vitest environment in nested configs that do not resolve `@/` aliases.
// `mock.test.ts` asserts this matches `WORKFLOW_ENV_GLOBAL_KEY` exported from
// `configure/services/workflow/job.ts` so the two declarations cannot drift.
export const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -354,6 +362,19 @@ export const workflowMock = {
getState().waitHandler = handler;
}) as SetWaitHandler,

/**
* Set the `env` value passed to job bodies when `.trigger()` is invoked locally.
*
* `createWorkflowJob().trigger()` runs the body in-process during tests (in production,
* the bundler rewrites it to `tailor.workflow.triggerJobFunction`). This helper provides
* the `env` argument that the body receives via its `WorkflowJobContext`. Reset by
* `workflowMock.reset()`.
* @param env - Env object to pass to job bodies invoked via `.trigger()`
*/
setEnv(env: TailorEnv): void {
(globalThis as Record<string, unknown>)[WORKFLOW_ENV_GLOBAL_KEY] = env;
},

/**
* Configure how `tailor.workflow.resolve` runs the user-supplied callback. The handler
* receives `(executionId, key, callback)` — invoke `callback(payload)` to drive
Expand Down Expand Up @@ -402,6 +423,7 @@ export const workflowMock = {
state.waitHandler = null;
state.resolveHandler = null;
state.workflowCalls.length = 0;
delete (globalThis as Record<string, unknown>)[WORKFLOW_ENV_GLOBAL_KEY];
},
};

Expand Down Expand Up @@ -1182,4 +1204,5 @@ export function cleanupMocks(global: typeof globalThis): void {
delete g.TailorDBFileError;
delete g[STATE_KEY];
delete g[RUNTIME_FLAG_KEY];
delete g[WORKFLOW_ENV_GLOBAL_KEY];
}
Loading