Support projects with Node.js step dependencies in vitest plugin#1524
Support projects with Node.js step dependencies in vitest plugin#1524VaguelySerious merged 11 commits intomainfrom
Conversation
Five issues prevented @workflow/vitest from working in projects where step functions transitively import Node.js modules (postgres, ioredis, node:path, etc.): 1. Externalized step imports kept .ts/.tsx extensions that Node's ESM loader can't resolve. Rewrite to .js/.mjs/.cjs in the esbuild plugin. 2. VitestBuilder hardcoded dirs: ['.'] scanning the entire project. Production builders scope to framework entry points (e.g. src/app). Add dirs option to workflow() and thread it through to the builder. 3. .workflow-vitest/ wasn't in the ignore list, so stale bundles from previous runs got rediscovered as input files. 4. workflowTransformPlugin added classId to node_modules serde classes via Vite, then the step bundle also registered classId during its esbuild build — double defineProperty with configurable:false. Skip node_modules packages that only match serde patterns. 5. Eager native import() of step/workflow bundles in setupFiles loaded dependencies into the module cache before vi.mock() could intercept them. Replace with memoized lazy handlers that defer import() until first workflow dispatch.
The .workflow-vitest exclude + lazy bundle loading already prevent the double classId registration. The broad node_modules skip could break legitimate serde-only npm packages.
🦋 Changeset detectedLatest commit: 8d043d7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (59 failed)mongodb (3 failed):
redis (2 failed):
turso (54 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
The dirs option was introduced to narrow file scanning, but the root causes (TS extension rewriting, stale bundle rediscovery, eager bundle loading) are addressed by the other fixes in this PR. Keeping dirs: ['.'] as the hardcoded default.
|
@pranaygp Good call — removed the |
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
| externalPath = externalPath | ||
| .replace(/\.tsx?$/, '.js') | ||
| .replace(/\.mts$/, '.mjs') | ||
| .replace(/\.cts$/, '.cjs'); |
There was a problem hiding this comment.
AI Review: Note
The extension rewriting regex chain is correct — .ts/.tsx → .js, .mts → .mjs, .cts → .cjs with no cross-matching. However, this fix benefits ALL 6 framework builders that use externalizeNonSteps (next, astro, sveltekit, nitro, nest), not just vitest. This is a production correctness fix worth calling out in the changeset.
Also note: this only applies when enhanced-resolve successfully resolves the import. Imports using the TypeScript ESM convention (from './dep.js' where the file is dep.ts) silently fail in enhanced-resolve, fall through to esbuild's built-in resolver, and get inlined instead of externalized. This is pre-existing and out of scope, but worth being aware of — it means local step dependencies with .js import specifiers can't be mocked.
There was a problem hiding this comment.
There was something to this to that it seemed see #1555 cc @matchai @VaguelySerious
|
|
||
| <Callout type="warn"> | ||
| 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. | ||
| `vi.mock()` and related calls do _not_ work inside workflow functions, only step functions. Your workflow functions may not import third party code that needs to be mocked. If something needs to be mocked, it likely belongs inside a step function either way. |
There was a problem hiding this comment.
AI Review: Nit
"may not" is ambiguous — could be read as possibility ("might not") or prohibition ("must not"). Consider "must not" or "cannot" for clarity.
Also, vi.mock() works for externalized step dependencies (npm packages and extensionless local imports), but NOT for local dependencies imported with .js extensions (pre-existing limitation where enhanced-resolve can't resolve .js → .ts). Consider adding a note like "Mocking works for npm packages imported in step functions" to set expectations precisely.
| } | ||
| return handler(req); | ||
| }; | ||
| } |
There was a problem hiding this comment.
AI Review: Note
Good pattern — the loading ??= memoization correctly deduplicates concurrent dispatches. One subtlety: if the import() rejects (e.g., bundle file missing/corrupt), the rejected promise is cached and all subsequent handler calls immediately reject with the same error. This is appropriate fail-fast behavior for a test runner, but a brief comment noting this would help future readers understand the intentional design.
| return [ | ||
| workflowTransformPlugin(), | ||
| workflowTransformPlugin({ | ||
| exclude: [join(process.cwd(), '.workflow-vitest')], |
There was a problem hiding this comment.
AI Review: Nit
The startsWith matching in workflowTransformPlugin's exclude check would also match a hypothetical .workflow-vitest-backup/ directory. Appending a / to the exclude path (join(process.cwd(), '.workflow-vitest') + '/') would make matching more precise. Very unlikely to matter in practice.
…add comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three issues prevented
@workflow/vitestfrom working in projects where step functions transitively import Node.js modules (postgres, ioredis, node:path, etc.):1. Externalized step imports keep TS extensions
externalizeNonStepsemits externalized import paths with raw.ts/.tsxextensions that Node's native ESM loader can't resolve. Rewrite to.js/.mjs/.cjsin the esbuild plugin'sonResolve.2. Stale bundles rediscovered as input
.workflow-vitest/wasn't in the base builder's ignore list, so stale bundles from previous runs got picked up during file discovery.3. Eager bundle loading poisons module cache
setupWorkflowTests()eagerly imported both bundles via nativeimport()during vitestsetupFiles. This loaded step dependencies into the module cache beforevi.mock()could intercept them, breaking mocks in unit tests that never execute workflows. Replaced with memoized lazy handlers that deferimport()until first workflow dispatch.Mock behavior change: with lazy loading, externalized step dependencies now resolve through vite-node after mocks are active.
vi.mock()now intercepts them as expected when they used to be ignored.