test: Centralize the zod CJS-pin alias in shared Vitest config (#33627)

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Matsu
2026-07-06 09:20:27 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cb079b80c5
commit deb52ae6e4
6 changed files with 71 additions and 78 deletions
+5 -15
View File
@@ -2,21 +2,11 @@ import { createVitestConfig } from '@n8n/vitest-config/node';
import path from 'node:path';
import { mergeConfig } from 'vite';
export default mergeConfig(createVitestConfig({}), {
// `zod` is pinned to its CJS build (via `pinCjs`) so `schema instanceof ZodSchema` in
// src/converters/tool.ts holds against zod objects created in externalized CJS workspace
// deps. See `cjsPinAliases` in @n8n/vitest-config/node for the rationale.
export default mergeConfig(createVitestConfig({}, { pinCjs: ['zod'] }), {
resolve: {
alias: [
{ find: 'src', replacement: path.resolve(__dirname, './src') },
// zod has dual ESM/CJS exports (two separate `ZodType` class identities).
// Workspace deps CJS-require zod while tests ESM-import it, so
// `schema instanceof ZodSchema` in src/converters/tool.ts fails across the
// two. Pin the top-level `zod` import to the CJS file so both share one
// module instance. `require.resolve` follows zod's `require` export condition,
// so it tracks the installed (catalog) version automatically. Subpaths like
// `zod/v4` keep their normal resolution.
{
find: /^zod$/,
replacement: require.resolve('zod'),
},
],
alias: [{ find: 'src', replacement: path.resolve(__dirname, './src') }],
},
});
@@ -5,22 +5,13 @@ import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decora
// Shared base config for both the unit (vite.config.ts) and integration
// (vitest.config.integration.ts) runs. Test include/exclude is intentionally
// left to the extending configs so they don't merge-concatenate each other's globs.
export const baseConfig = mergeConfig(createVitestConfigWithDecorators({}), {
resolve: {
alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') },
// zod has dual ESM/CJS exports (`./dist/esm/index.js` for `import`,
// `./dist/cjs/index.js` for `require`) — two separate files with two separate
// `ZodType` classes. Workspace deps CJS-require zod, test files ESM-import it, and
// `instanceof z.ZodX` checks in source fail between them. Pin the top-level `zod`
// import to the CJS file so both code paths share a single module instance.
{
find: /^zod$/,
replacement: path.resolve(
__dirname,
'../../../node_modules/.pnpm/[email protected]/node_modules/zod/dist/cjs/index.js',
),
},
],
export const baseConfig = mergeConfig(
// Pin `zod` to its CJS build so `instanceof z.ZodError` holds against the externalized
// CJS dist. See `cjsPinAliases` in @n8n/vitest-config/node for the rationale.
createVitestConfigWithDecorators({}, { pinCjs: ['zod'] }),
{
resolve: {
alias: [{ find: '@', replacement: path.resolve(__dirname, './src') }],
},
},
});
);
+5 -15
View File
@@ -84,22 +84,12 @@ function rewriteLazyRequireForTests(): Plugin {
};
}
export default mergeConfig(createVitestConfig({}), {
// `zod` is pinned to its CJS build (via `pinCjs`) so `instanceof` checks (e.g. in
// sanitize-mcp-schemas) hold against zod objects created in externalized CJS workspace
// deps. See `cjsPinAliases` in @n8n/vitest-config/node for the rationale.
export default mergeConfig(createVitestConfig({}, { pinCjs: ['zod'] }), {
plugins: [rewriteLazyRequireForTests()],
resolve: {
alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') },
// zod has dual ESM/CJS exports (two separate `ZodType` class identities).
// Workspace deps CJS-require zod while test files ESM-import it, so
// `instanceof` checks (e.g. in sanitize-mcp-schemas) fail across the two
// module instances. Pin the top-level `zod` import to the CJS file so all
// code paths share one instance. `require.resolve` follows zod's `require`
// export condition, so it tracks the installed (catalog) version
// automatically. Subpaths like `zod/v4` resolve normally.
{
find: /^zod$/,
replacement: require.resolve('zod'),
},
],
alias: [{ find: '@', replacement: path.resolve(__dirname, './src') }],
},
});
+10 -2
View File
@@ -2,8 +2,16 @@ import { mergeConfig } from 'vitest/config';
import type { InlineConfig } from 'vitest/node';
import { createVitestConfig } from './node.js';
export const createVitestConfigWithDecorators = (options: InlineConfig = {}) => {
const baseConfig = createVitestConfig(options);
export const createVitestConfigWithDecorators = (
options: InlineConfig = {},
// `pinCjs` is opt-in per package (default none): pinning a dep to CJS also forces its
// transitive dual-build deps to CJS (e.g. `n8n-workflow` drags luxon's `DateTime`
// identity along), which can break unrelated `instanceof`/`expect.any` checks. Only
// packages whose own tests need a specific dep unified should pass it. See
// `cjsPinAliases` for the full rationale.
{ pinCjs = [] }: { pinCjs?: string[] } = {},
) => {
const baseConfig = createVitestConfig(options, { pinCjs });
return mergeConfig(baseConfig, {
test: {
server: {
+32 -2
View File
@@ -1,8 +1,32 @@
import { createRequire } from 'node:module';
import { join } from 'node:path';
import type { Alias } from 'vite';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
import type { InlineConfig } from 'vitest/node';
import { coverageExcludes } from './coverage-excludes.js';
/**
* Pin dual-build (ESM+CJS) deps to their CJS entry so a single class identity is shared
* across test/source (which ESM-import the dep) and externalized workspace dist (which
* `require()`s the CJS build) — otherwise cross-boundary `instanceof` checks fail. Vite's
* externalizer always prefers a dep's `import` export condition, so the two sides land on
* different files; rewriting the bare specifier to a concrete CJS path is the only way to
* unify them. Resolved from the consuming package (cwd) so it tracks the catalog version.
* The exact-match regex leaves subpaths (e.g. `zod/v4`) on their normal resolution. Deps
* not resolvable from the package are skipped (harmless — they can't be imported there).
*/
export const cjsPinAliases = (deps: string[], from: string = process.cwd()): Alias[] => {
const req = createRequire(join(from, 'noop.js'));
return deps.flatMap((dep) => {
try {
return [{ find: new RegExp(`^${dep}$`), replacement: req.resolve(dep) }];
} catch {
return [];
}
});
};
/**
* Per-suite fork-pool cap, applied only in CI.
*
@@ -50,7 +74,13 @@ export const createBaseInlineConfig = (options: InlineConfig = {}): InlineConfig
...options,
});
export const createVitestConfig = (options: InlineConfig = {}) =>
defineConfig({ test: createBaseInlineConfig(options) });
export const createVitestConfig = (
options: InlineConfig = {},
{ pinCjs = [] }: { pinCjs?: string[] } = {},
) =>
defineConfig({
test: createBaseInlineConfig(options),
...(pinCjs.length ? { resolve: { alias: cjsPinAliases(pinCjs) } } : {}),
});
export const vitestConfig = createVitestConfig();
+10 -26
View File
@@ -3,37 +3,21 @@ import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decora
import path from 'node:path';
export default mergeConfig(
createVitestConfigWithDecorators({
globalSetup: ['./test/setup.ts'],
setupFiles: ['./test/setup-mocks.ts'],
}),
createVitestConfigWithDecorators(
{
globalSetup: ['./test/setup.ts'],
setupFiles: ['./test/setup-mocks.ts'],
},
// Pin `zod` and `n8n-workflow` to their CJS build so cross-boundary `instanceof`
// (`ZodType`, `UserError`) holds against the externalized CJS dist. See
// `cjsPinAliases` in @n8n/vitest-config/node for the rationale.
{ pinCjs: ['zod', 'n8n-workflow'] },
),
{
resolve: {
alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') },
{ find: '@test', replacement: path.resolve(__dirname, './test') },
// zod has dual ESM/CJS exports (`./dist/esm/index.js` for `import`,
// `./dist/cjs/index.js` for `require`) — two separate files with two separate
// `ZodType` classes. @n8n/config dist CJS-requires zod, test files ESM-import
// it, and `instanceof` fails between them. Pin the top-level `zod` import to the
// CJS file so both code paths share a single module instance. `require.resolve`
// follows zod's `require` export condition, so it tracks the installed (catalog)
// version automatically. Subpaths like `zod/v4` keep their normal resolution.
{
find: /^zod$/,
replacement: require.resolve('zod'),
},
// `n8n-workflow` has dual ESM/CJS builds (`./dist/esm/index.js` for `import`,
// `./dist/cjs/index.js` for `require`), each with its own `UserError` class.
// `@n8n/backend-network` is loaded from its CJS dist and `require`s the CJS copy,
// so `SsrfBlockedIpError extends UserError` (CJS), while test files ESM-import
// `UserError` (ESM) — and `instanceof` fails between them. Pin the top-level
// `n8n-workflow` import to the CJS file so both code paths share one module
// instance. `require.resolve` follows the `require` export condition.
{
find: /^n8n-workflow$/,
replacement: require.resolve('n8n-workflow'),
},
],
},
oxc: {