Skip to content

Commit 52db376

Browse files
Support projects with Node.js step dependencies in vitest plugin (#1524)
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
1 parent fb97b6b commit 52db376

11 files changed

Lines changed: 180 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/builders": patch
3+
---
4+
5+
Fix dependency resolution for step imports with .ts, .mts, and .cts extensions

.changeset/fix-vitest-node-deps.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/vitest": patch
3+
---
4+
5+
Fix step dependencies not being mockable when imported from TypeScript files

docs/content/docs/testing/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Unit testing works well for individual steps. A simple workflow that only calls
8585
For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically — it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required.
8686

8787
<Callout type="warn">
88-
Inside integration tests, which run the full workflow runtime, `vi.mock()` and related calls do not work — neither for your own modules nor for third-party npm packages. All step dependencies are inlined into the compiled bundle by esbuild, bypassing Vitest's module system entirely. To test steps with mocked dependencies, use [unit tests](#unit-testing-steps) instead. Consider dependency injection or environment variable-based conditional logic for controlling behavior in integration tests.
88+
`vi.mock()` and related calls do _not_ work inside workflow functions, only step functions. Your workflow functions cannot import third party code that needs to be mocked. Mocking works for npm packages imported in step functions. If something needs to be mocked, it likely belongs inside a step function either way.
8989
</Callout>
9090

9191
### Vitest Configuration

packages/builders/src/base-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export abstract class BaseBuilder {
110110
'**/.output/**',
111111
'**/.vercel/**',
112112
'**/.workflow-data/**',
113+
'**/.workflow-vitest/**',
113114
'**/.well-known/workflow/**',
114115
'**/.svelte-kit/**',
115116
'**/.turbo/**',

packages/builders/src/get-input-files.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ describe('getInputFiles', () => {
101101
writeFile(srcDir, '.vercel/output/step.ts');
102102
writeFile(srcDir, '.svelte-kit/output/step.ts');
103103
writeFile(srcDir, '.workflow-data/state.ts');
104+
writeFile(srcDir, '.workflow-vitest/workflows.mjs');
104105
writeFile(srcDir, '.well-known/workflow/route.ts');
105106
writeFile(srcDir, '.turbo/cache/build.ts');
106107
writeFile(srcDir, '.cache/babel/plugin.js');
@@ -131,6 +132,9 @@ describe('getInputFiles', () => {
131132
expect(files).not.toContain(
132133
normalize(join(srcDir, '.workflow-data/state.ts'))
133134
);
135+
expect(files).not.toContain(
136+
normalize(join(srcDir, '.workflow-vitest/workflows.mjs'))
137+
);
134138
expect(files).not.toContain(
135139
normalize(join(srcDir, '.well-known/workflow/route.ts'))
136140
);

packages/builders/src/swc-esbuild-plugin.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,64 @@ function writeFile(path: string, contents = ''): void {
2727
writeFileSync(path, contents, 'utf-8');
2828
}
2929

30+
describe('createSwcPlugin externalizeNonSteps', () => {
31+
let testRoot: string;
32+
33+
beforeEach(() => {
34+
testRoot = mkdtempSync(join(realTmpdir, 'workflow-swc-plugin-'));
35+
applySwcTransformMock.mockReset();
36+
applySwcTransformMock.mockImplementation(
37+
async (_filename: string, source: string) => ({
38+
code: source,
39+
workflowManifest: {},
40+
})
41+
);
42+
});
43+
44+
afterEach(() => {
45+
rmSync(testRoot, { recursive: true, force: true });
46+
});
47+
48+
it.each([
49+
{ inputExt: '.ts', outputExt: '.js' },
50+
{ inputExt: '.tsx', outputExt: '.js' },
51+
{ inputExt: '.mts', outputExt: '.mjs' },
52+
{ inputExt: '.cts', outputExt: '.cjs' },
53+
])('rewrites externalized $inputExt imports to $outputExt', async ({
54+
inputExt,
55+
outputExt,
56+
}) => {
57+
const outdir = join(testRoot, 'out');
58+
const srcDir = join(testRoot, 'src');
59+
const stepFile = join(srcDir, 'step.ts');
60+
61+
writeFile(join(srcDir, `dep${inputExt}`), 'export const dep = {};');
62+
writeFile(stepFile, `import { dep } from './dep';\nconsole.log(dep);`);
63+
64+
const result = await esbuild.build({
65+
entryPoints: [stepFile],
66+
absWorkingDir: testRoot,
67+
outdir,
68+
bundle: true,
69+
format: 'esm',
70+
platform: 'node',
71+
write: false,
72+
plugins: [
73+
createSwcPlugin({
74+
mode: 'step',
75+
entriesToBundle: [stepFile],
76+
outdir,
77+
}),
78+
],
79+
});
80+
81+
expect(result.errors).toHaveLength(0);
82+
const output = result.outputFiles[0].text;
83+
expect(output).toContain(`/dep${outputExt}`);
84+
expect(output).not.toContain(`/dep${inputExt}`);
85+
});
86+
});
87+
3088
describe('createSwcPlugin projectRoot', () => {
3189
let testRoot: string;
3290

packages/builders/src/swc-esbuild-plugin.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,24 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin {
124124
const isFilePath =
125125
args.path.startsWith('.') || args.path.startsWith('/');
126126

127-
return {
128-
external: true,
129-
path: isFilePath
130-
? relative(options.outdir || process.cwd(), resolvedPath).replace(
131-
/\\/g,
132-
'/'
133-
)
134-
: args.path,
135-
};
127+
let externalPath: string;
128+
if (isFilePath) {
129+
externalPath = relative(
130+
options.outdir || process.cwd(),
131+
resolvedPath
132+
).replace(/\\/g, '/');
133+
134+
// Rewrite TypeScript extensions to their JS equivalents so the
135+
// externalized import is loadable by Node's native ESM loader.
136+
externalPath = externalPath
137+
.replace(/\.tsx?$/, '.js')
138+
.replace(/\.mts$/, '.mjs')
139+
.replace(/\.cts$/, '.cjs');
140+
} else {
141+
externalPath = args.path;
142+
}
143+
144+
return { external: true, path: externalPath };
136145
} catch (_) {}
137146
return null;
138147
});

packages/vitest/src/index.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ function getOutDir(cwd: string): string {
8686
export function workflow(): Plugin[] {
8787
const dir = fileURLToPath(new URL('.', import.meta.url));
8888
return [
89-
workflowTransformPlugin(),
89+
workflowTransformPlugin({
90+
exclude: [join(process.cwd(), '.workflow-vitest') + '/'],
91+
}),
9092
{
9193
name: 'workflow:vitest',
9294
config() {
@@ -139,17 +141,28 @@ export async function setupWorkflowTests(
139141
const cwd = options?.cwd ?? process.cwd();
140142
const outDir = getOutDir(cwd);
141143

142-
const workflowsModule = await import(
143-
/* @vite-ignore */ pathToFileURL(join(outDir, 'workflows.mjs')).href
144-
);
145-
const stepsModule = await import(
146-
/* @vite-ignore */ pathToFileURL(join(outDir, 'steps.mjs')).href
147-
);
148-
149-
const workflowHandler = workflowsModule.POST as (
150-
req: Request
151-
) => Promise<Response>;
152-
const stepHandler = stepsModule.POST as (req: Request) => Promise<Response>;
144+
// Lazy-load bundles on first dispatch instead of eagerly at setup time.
145+
// Eager native import() during setupFiles loads step dependencies into
146+
// the module cache before vi.mock() can intercept them, breaking mocks
147+
// in unit tests that never execute workflows.
148+
function createLazyHandler(
149+
bundlePath: string
150+
): (req: Request) => Promise<Response> {
151+
let handler: ((req: Request) => Promise<Response>) | undefined;
152+
let loading: Promise<(req: Request) => Promise<Response>> | undefined;
153+
154+
return async (req: Request) => {
155+
if (!handler) {
156+
// If the import rejects (e.g. missing bundle), the rejected promise is
157+
// cached so all subsequent calls fail fast with the same error.
158+
loading ??= import(
159+
/* @vite-ignore */ pathToFileURL(bundlePath).href
160+
).then((mod) => mod.POST as (req: Request) => Promise<Response>);
161+
handler = await loading;
162+
}
163+
return handler(req);
164+
};
165+
}
153166

154167
// Each vitest worker uses a unique tag to isolate its test data.
155168
// All workers write to the shared .workflow-data directory so runs
@@ -163,8 +176,14 @@ export async function setupWorkflowTests(
163176
await world.start?.();
164177
await world.clear();
165178

166-
world.registerHandler('__wkf_workflow_', workflowHandler);
167-
world.registerHandler('__wkf_step_', stepHandler);
179+
world.registerHandler(
180+
'__wkf_workflow_',
181+
createLazyHandler(join(outDir, 'workflows.mjs'))
182+
);
183+
world.registerHandler(
184+
'__wkf_step_',
185+
createLazyHandler(join(outDir, 'steps.mjs'))
186+
);
168187

169188
setWorld(world);
170189
}

workbench/vitest/test/mock.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
11
/**
22
* Test whether vi.mock() works for third-party npm packages
33
* in the vitest integration test environment.
4-
*
5-
* This test verifies that mocking third-party packages (like `ms`)
6-
* does NOT work in integration tests because the step bundle is compiled
7-
* by esbuild which inlines all dependencies. vi.mock() only affects
8-
* the vitest module graph, not the pre-compiled step bundle.
94
*/
5+
6+
import ms from 'ms';
107
import { describe, expect, it, vi } from 'vitest';
118
import { start } from 'workflow/api';
12-
import { durationWorkflow } from '../workflows/third-party.js';
9+
import {
10+
durationWorkflow,
11+
durationWorkflowInline,
12+
durationWorkflowStepUtil,
13+
} from '../workflows/third-party.js';
1314

1415
vi.mock('ms', () => ({
1516
default: () => 42,
1617
}));
1718

1819
describe('third-party mocking', () => {
19-
it('vi.mock does NOT intercept third-party imports in steps', async () => {
20+
it('vi.mock intercepts external npm package used in step', async () => {
21+
// Mock works outside the workflow bundle
22+
expect(ms('1h')).toBe(42);
23+
2024
const run = await start(durationWorkflow, ['1h']);
2125
const result = await run.returnValue;
22-
// If the mock worked, result would be { ms: 42 }
23-
// Since the step bundle inlines dependencies, the real ms() is used
24-
expect(result).toEqual({ ms: 3_600_000 }); // real ms('1h') = 3600000
26+
27+
// Mock works inside the workflow bundle
28+
expect(result).toEqual({ ms: 42 });
29+
});
30+
31+
it.fails('vi.mock intercepts external npm package used in workflow', async () => {
32+
expect(ms('1h')).toBe(42);
33+
34+
const run = await start(durationWorkflowInline, ['1h']);
35+
const result = await run.returnValue;
36+
37+
// Mock doesn't yet work inside the workflow bundle
38+
expect(result).toEqual({ ms: 42 });
39+
});
40+
41+
it('vi.mock intercepts internal import used in step', async () => {
42+
const run = await start(durationWorkflowStepUtil, ['1h']);
43+
const result = await run.returnValue;
44+
45+
// Mock doesn't work for internalized local dependencies
46+
expect(result).toEqual({ ms: 42 });
2547
});
2648
});

workbench/vitest/workflows/third-party.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,30 @@
33
* Used to test whether vi.mock() works for third-party dependencies.
44
*/
55
import ms from 'ms';
6+
import { formatDurationUtil } from './utils';
67

7-
async function formatDuration(duration: string) {
8+
export async function formatDurationStepUsingExternal(duration: string) {
9+
'use step';
10+
return formatDurationUtil(duration);
11+
}
12+
13+
export async function formatDurationStep(duration: string) {
814
'use step';
915
return ms(duration);
1016
}
1117

1218
export async function durationWorkflow(duration: string) {
1319
'use workflow';
14-
const result = await formatDuration(duration);
20+
const result = await formatDurationStep(duration);
1521
return { ms: result };
1622
}
23+
24+
export async function durationWorkflowInline(duration: string) {
25+
'use workflow';
26+
return { ms: ms(duration) };
27+
}
28+
29+
export async function durationWorkflowStepUtil(duration: string) {
30+
'use workflow';
31+
return { ms: await formatDurationStepUsingExternal(duration) };
32+
}

0 commit comments

Comments
 (0)