diff --git a/.changeset/workflow-mock-set-env.md b/.changeset/workflow-mock-set-env.md new file mode 100644 index 000000000..289e15675 --- /dev/null +++ b/.changeset/workflow-mock-set-env.md @@ -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. diff --git a/packages/create-sdk/templates/workflow/README.md b/packages/create-sdk/templates/workflow/README.md index 1ff8b93e1..8476d85fc 100644 --- a/packages/create-sdk/templates/workflow/README.md +++ b/packages/create-sdk/templates/workflow/README.md @@ -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 diff --git a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts index df23b1dcc..c67ae6c71 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts @@ -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, @@ -8,8 +8,11 @@ import workflow, { } from "./order-fulfillment"; describe("order fulfillment workflow", () => { + beforeEach(() => { + workflowMock.reset(); + }); + afterEach(() => { - vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -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", diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 7597673d8..0fc841a44 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -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): @@ -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 }); diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 5dffbf201..e16bb43e0 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -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. */ @@ -57,8 +69,11 @@ export interface WorkflowJob { - 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)[WORKFLOW_ENV_GLOBAL_KEY]; + const env = ( + typeof fromGlobal === "object" && fromGlobal !== null + ? { ...(fromGlobal as Record) } + : JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}") + ) as TailorEnv; return await body(args as I, { env, invoker: null }); }, body, diff --git a/packages/sdk/src/vitest/__tests__/mock.test.ts b/packages/sdk/src/vitest/__tests__/mock.test.ts index 24416584a..aa8db8b0a 100644 --- a/packages/sdk/src/vitest/__tests__/mock.test.ts +++ b/packages/sdk/src/vitest/__tests__/mock.test.ts @@ -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, @@ -12,6 +13,7 @@ import { cleanupMocks, STATE_KEY, RUNTIME_FLAG_KEY, + WORKFLOW_ENV_GLOBAL_KEY, } from "../mock"; describe("mock", () => { @@ -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" }); @@ -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" }); + }); + + 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)[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)[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", () => { diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index a1f0a21b7..f92fb220e 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -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 // --------------------------------------------------------------------------- @@ -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)[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 @@ -402,6 +423,7 @@ export const workflowMock = { state.waitHandler = null; state.resolveHandler = null; state.workflowCalls.length = 0; + delete (globalThis as Record)[WORKFLOW_ENV_GLOBAL_KEY]; }, }; @@ -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]; }