From 57e00d6bfc2f9602af0ac9c0235da6ec0e04b12e Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Mon, 18 May 2026 22:38:57 +0900 Subject: [PATCH 1/6] feat(sdk): add workflowMock.setEnv() and deprecate WORKFLOW_TEST_ENV_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds workflowMock.setEnv() so tests using the tailor-runtime Vitest environment can configure the env passed to job bodies through the same helper they use for setJobHandler / setWaitHandler — without touching process.env. The legacy WORKFLOW_TEST_ENV_KEY (TAILOR_TEST_WORKFLOW_ENV) env-var path remains supported as a fallback; the export is now marked @deprecated. workflowMock.setEnv() takes priority when both are set. --- .changeset/workflow-mock-set-env.md | 17 ++++++++++++ .../create-sdk/templates/workflow/README.md | 2 +- .../src/workflow/order-fulfillment.test.ts | 4 --- packages/sdk/docs/testing.md | 11 ++++---- .../src/configure/services/workflow/job.ts | 26 ++++++++++++++++--- .../sdk/src/vitest/__tests__/mock.test.ts | 26 +++++++++++++++++++ packages/sdk/src/vitest/mock.ts | 21 +++++++++++++++ 7 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 .changeset/workflow-mock-set-env.md diff --git a/.changeset/workflow-mock-set-env.md b/.changeset/workflow-mock-set-env.md new file mode 100644 index 000000000..db3910baa --- /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 legacy `WORKFLOW_TEST_ENV_KEY` (`TAILOR_TEST_WORKFLOW_ENV`) env-var path remains supported as a fallback; the export is now marked `@deprecated`. `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..6ddb55ac6 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,4 +1,3 @@ -import { WORKFLOW_TEST_ENV_KEY } from "@tailor-platform/sdk/test"; import { afterEach, describe, expect, test, vi } from "vitest"; import workflow, { fulfillOrder, @@ -9,7 +8,6 @@ import workflow, { describe("order fulfillment workflow", () => { afterEach(() => { - vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -115,8 +113,6 @@ 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({})); - const result = await workflow.mainJob.trigger({ orderId: "order-3", amount: 300, diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 7597673d8..c215bdb9a 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 without any mocking, 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..92c72aa65 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -56,9 +56,20 @@ export interface WorkflowJob Output | Promise; } +// globalThis key consumed by `.trigger()` when the body is invoked locally. +// The matching writer is `workflowMock.setEnv()` in `@tailor-platform/sdk/vitest`. +// Tests outside the `tailor-runtime` Vitest environment may assign this key +// directly. Keep the literal in sync with `WORKFLOW_ENV_GLOBAL_KEY` in +// `src/vitest/mock.ts` — the configure module avoids the cross-module import +// to preserve the module boundary. +const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv"; + /** - * 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"; @@ -108,7 +119,16 @@ export const createWorkflowJob = { - 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 so + // existing tests using `vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...)` keep + // working. + const fromGlobal = (globalThis as Record)[WORKFLOW_ENV_GLOBAL_KEY]; + const env = ( + fromGlobal !== undefined + ? fromGlobal + : 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..57023e01e 100644 --- a/packages/sdk/src/vitest/__tests__/mock.test.ts +++ b/packages/sdk/src/vitest/__tests__/mock.test.ts @@ -157,6 +157,32 @@ 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({}); + }); }); describe("error classes", () => { diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index a1f0a21b7..6d35c418b 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -136,6 +136,12 @@ export const STATE_KEY = "__tailorMockState"; // the environment itself is active. export const RUNTIME_FLAG_KEY = "__tailorRuntimeActive"; +// globalThis key consumed by `createWorkflowJob().trigger()` when the body is +// invoked locally (outside the platform runtime). The configure module reads +// this directly via globalThis to avoid a runtime dependency on vitest/mock, +// preserving the configure → vitest module boundary. +export const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv"; + function getState(): MockState { const g = globalThis as Record; if (!g[STATE_KEY]) { @@ -354,6 +360,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: Record): 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 +421,7 @@ export const workflowMock = { state.waitHandler = null; state.resolveHandler = null; state.workflowCalls.length = 0; + delete (globalThis as Record)[WORKFLOW_ENV_GLOBAL_KEY]; }, }; @@ -1182,4 +1202,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]; } From 8a1fc4173f598b583cdedf9f0442ba4d569a24b2 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Mon, 18 May 2026 23:19:07 +0900 Subject: [PATCH 2/6] test: cover setEnv priority/fallback and demo it in workflow template Self-review revealed two gaps: - mock.test.ts did not verify that workflowMock.setEnv() wins over vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...), or that the deprecated env-var path still works when setEnv is not called. - The workflow template's integration test exercised .trigger() but never called workflowMock.setEnv() / workflowMock.reset(), so it did not demonstrate the new helper. Add two mock.test.ts cases for the priority/fallback contract, and update the template to call workflowMock.reset() in beforeEach and workflowMock.setEnv({ STAGE: 'test' }) in the integration test. --- .../src/workflow/order-fulfillment.test.ts | 9 +++++- .../sdk/src/vitest/__tests__/mock.test.ts | 31 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) 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 6ddb55ac6..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,4 +1,5 @@ -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, @@ -7,6 +8,10 @@ import workflow, { } from "./order-fulfillment"; describe("order fulfillment workflow", () => { + beforeEach(() => { + workflowMock.reset(); + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -113,6 +118,8 @@ describe("order fulfillment workflow", () => { describe("integration tests with .trigger()", () => { test("workflow.mainJob.trigger() executes all jobs", async () => { + workflowMock.setEnv({ STAGE: "test" }); + const result = await workflow.mainJob.trigger({ orderId: "order-3", amount: 300, diff --git a/packages/sdk/src/vitest/__tests__/mock.test.ts b/packages/sdk/src/vitest/__tests__/mock.test.ts index 57023e01e..43a64ffea 100644 --- a/packages/sdk/src/vitest/__tests__/mock.test.ts +++ b/packages/sdk/src/vitest/__tests__/mock.test.ts @@ -1,5 +1,5 @@ /* 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 { tailordbMock, workflowMock, @@ -183,6 +183,35 @@ describe("mock", () => { 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" }); + vi.unstubAllEnvs(); + }); + + 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" }); + vi.unstubAllEnvs(); + }); }); describe("error classes", () => { From 50d8a26f01f5e80e1bf50f0cd0da555e7ddc96ce Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Mon, 18 May 2026 23:25:10 +0900 Subject: [PATCH 3/6] docs: drop deprecated env-var name from changeset Match the docs decision (testing.md does not mention WORKFLOW_TEST_ENV_KEY either): the changeset still records the deprecation, but no longer names the legacy export. Users who relied on the env var will see the @deprecated JSDoc warning at the import site. --- .changeset/workflow-mock-set-env.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/workflow-mock-set-env.md b/.changeset/workflow-mock-set-env.md index db3910baa..289e15675 100644 --- a/.changeset/workflow-mock-set-env.md +++ b/.changeset/workflow-mock-set-env.md @@ -14,4 +14,4 @@ test("workflow.mainJob.trigger() executes all jobs", async () => { }); ``` -The legacy `WORKFLOW_TEST_ENV_KEY` (`TAILOR_TEST_WORKFLOW_ENV`) env-var path remains supported as a fallback; the export is now marked `@deprecated`. `workflowMock.setEnv()` takes priority when both are set. +The previous env-var-based pattern is now deprecated. A non-breaking fallback is retained, but `workflowMock.setEnv()` takes priority when both are set. From 20b53dd945b5037e2c36397898fab704b9163f51 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Tue, 19 May 2026 22:13:07 +0900 Subject: [PATCH 4/6] refactor(sdk): tighten setEnv typing and prevent env mutation leak - Shallow-copy global env in trigger() 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. - Tighten workflowMock.setEnv() parameter from Record to TailorEnv to match the value type job bodies receive in their context. - Add afterEach(vi.unstubAllEnvs) to workflowMock describe block so a failing test cannot leak env stubs into subsequent tests. - Add drift-guard test asserting WORKFLOW_ENV_GLOBAL_KEY is identical between configure/job.ts and vitest/mock.ts. mock.ts must remain free of @/-alias imports because nested Vitest configs for integration tests do not resolve them, so the constant is re-declared instead of imported. - Reword testing.md "without any mocking" to "with real job bodies" since workflowMock.setEnv() is itself a mock helper. --- packages/sdk/docs/testing.md | 2 +- .../src/configure/services/workflow/job.ts | 23 +++++++++++-------- .../sdk/src/vitest/__tests__/mock.test.ts | 15 ++++++++++-- packages/sdk/src/vitest/mock.ts | 16 +++++++------ 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index c215bdb9a..0fc841a44 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -603,7 +603,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. Use `workflowMock.setEnv()` to control the env value that triggered jobs receive in their context (defaults to `{}`): +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 { 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 92c72aa65..7b5630aac 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -3,6 +3,15 @@ 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`. Re-exported by `src/vitest/mock.ts` so both + * the writer (mock) and reader (this file) share a single source of truth. + * @internal + */ +export const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv"; + /** * Context object passed as the second argument to workflow job body functions. */ @@ -56,14 +65,6 @@ export interface WorkflowJob Output | Promise; } -// globalThis key consumed by `.trigger()` when the body is invoked locally. -// The matching writer is `workflowMock.setEnv()` in `@tailor-platform/sdk/vitest`. -// Tests outside the `tailor-runtime` Vitest environment may assign this key -// directly. Keep the literal in sync with `WORKFLOW_ENV_GLOBAL_KEY` in -// `src/vitest/mock.ts` — the configure module avoids the cross-module import -// to preserve the module boundary. -const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv"; - /** * Environment variable key historically consumed by `createWorkflowJob().trigger()` * to inject the `env` value passed to job bodies during local execution. @@ -122,11 +123,13 @@ export const createWorkflowJob = )[WORKFLOW_ENV_GLOBAL_KEY]; const env = ( fromGlobal !== undefined - ? fromGlobal + ? { ...(fromGlobal as Record) } : JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}") ) as TailorEnv; return await body(args as I, { env, invoker: null }); diff --git a/packages/sdk/src/vitest/__tests__/mock.test.ts b/packages/sdk/src/vitest/__tests__/mock.test.ts index 43a64ffea..6c326b385 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, 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" }); @@ -196,7 +202,6 @@ describe("mock", () => { workflowMock.setEnv({ STAGE: "from-setenv" }); expect(await captureEnv.trigger()).toEqual({ STAGE: "from-setenv" }); - vi.unstubAllEnvs(); }); test("falls back to WORKFLOW_TEST_ENV_KEY env var when setEnv not called", async () => { @@ -210,7 +215,13 @@ describe("mock", () => { vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "from-env-var" })); expect(await captureEnv.trigger()).toEqual({ STAGE: "from-env-var" }); - vi.unstubAllEnvs(); + }); + + 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); }); }); diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index 6d35c418b..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 // --------------------------------------------------------------------------- @@ -136,12 +144,6 @@ export const STATE_KEY = "__tailorMockState"; // the environment itself is active. export const RUNTIME_FLAG_KEY = "__tailorRuntimeActive"; -// globalThis key consumed by `createWorkflowJob().trigger()` when the body is -// invoked locally (outside the platform runtime). The configure module reads -// this directly via globalThis to avoid a runtime dependency on vitest/mock, -// preserving the configure → vitest module boundary. -export const WORKFLOW_ENV_GLOBAL_KEY = "__tailorWorkflowTestEnv"; - function getState(): MockState { const g = globalThis as Record; if (!g[STATE_KEY]) { @@ -369,7 +371,7 @@ export const workflowMock = { * `workflowMock.reset()`. * @param env - Env object to pass to job bodies invoked via `.trigger()` */ - setEnv(env: Record): void { + setEnv(env: TailorEnv): void { (globalThis as Record)[WORKFLOW_ENV_GLOBAL_KEY] = env; }, From e891545e93f65ff90861179d8831917e4f8154a0 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Tue, 19 May 2026 22:22:59 +0900 Subject: [PATCH 5/6] refactor(sdk): harden globalThis env handling and document drift guard Address Copilot review on #1186: - Validate fromGlobal is a non-null object before spreading. A non-object value (string, number, etc.) accidentally assigned to the global key now falls back to the env-var path instead of producing a surprising env shape. - Update WORKFLOW_ENV_GLOBAL_KEY JSDoc to accurately describe the duplicate declaration in src/vitest/mock.ts (it's re-declared, not re-exported, because the nested tailor-runtime Vitest config does not resolve @/ aliases), and point readers at the drift-guard test. - Add a regression test exercising the non-object fallback path. --- .../src/configure/services/workflow/job.ts | 20 +++++++++++-------- .../sdk/src/vitest/__tests__/mock.test.ts | 18 +++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 7b5630aac..719004f03 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -6,8 +6,11 @@ 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`. Re-exported by `src/vitest/mock.ts` so both - * the writer (mock) and reader (this file) share a single source of truth. + * `@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"; @@ -121,14 +124,15 @@ export const createWorkflowJob = { // `workflowMock.setEnv()` takes priority. Fall back to the deprecated - // `TAILOR_TEST_WORKFLOW_ENV` env var when the global key is unset so - // existing tests using `vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...)` keep - // working. Shallow-copy the global env 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. + // `TAILOR_TEST_WORKFLOW_ENV` env var when the global key is unset (or + // set to a non-plain-object) so existing tests using + // `vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...)` keep working. Shallow-copy + // the global env 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 = ( - fromGlobal !== undefined + typeof fromGlobal === "object" && fromGlobal !== null ? { ...(fromGlobal as Record) } : JSON.parse(process.env[WORKFLOW_TEST_ENV_KEY] || "{}") ) as TailorEnv; diff --git a/packages/sdk/src/vitest/__tests__/mock.test.ts b/packages/sdk/src/vitest/__tests__/mock.test.ts index 6c326b385..aa8db8b0a 100644 --- a/packages/sdk/src/vitest/__tests__/mock.test.ts +++ b/packages/sdk/src/vitest/__tests__/mock.test.ts @@ -217,6 +217,24 @@ describe("mock", () => { 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 From 3c35a3c3e96443125ccefcbf8d210944cc5022cf Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Tue, 19 May 2026 22:30:46 +0900 Subject: [PATCH 6/6] docs(sdk): clarify globalThis env fallback wording in trigger() Address Copilot review on #1186: the previous comment said the fallback path was used for "non-plain-object" values, but the runtime check (typeof === "object" && !== null) actually accepts arrays, class instances, etc. Rewrite the comment to describe what the check really does (reject null and non-object primitives) and note that non-plain-object cases can only arise from direct globalThis misuse, since the setEnv() type constrains callers to TailorEnv. No behavior change. --- .../sdk/src/configure/services/workflow/job.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 719004f03..e16bb43e0 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -124,12 +124,16 @@ export const createWorkflowJob = { // `workflowMock.setEnv()` takes priority. Fall back to the deprecated - // `TAILOR_TEST_WORKFLOW_ENV` env var when the global key is unset (or - // set to a non-plain-object) so existing tests using - // `vi.stubEnv(WORKFLOW_TEST_ENV_KEY, ...)` keep working. Shallow-copy - // the global env 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. + // `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