n8n/packages/workflow/test/expression-shadow.test.ts
Luigi Gubello 5120d25cbc
fix(editor): Count missing input apart in the QuickJS shadow report (no-changelog) (#41214)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:15:19 +00:00

166 lines
5.2 KiB
TypeScript

import { createRequire } from 'node:module';
import { readFile } from 'node:fs/promises';
import { ExpressionError } from '../src/errors/expression.error';
import { ExpressionExtensionError } from '../src/errors/expression-extension.error';
import { Expression } from '../src/expression';
import type { ExpressionEvaluationOutcome, ExpressionShadowRunner } from '../src/expression';
// The shadow run observes the legacy engine only, so these run in the legacy project.
describe.runIf(process.env.N8N_EXPRESSION_ENGINE === 'legacy')('Expression shadow run', () => {
const data = () =>
({
$json: { name: 'n8n', list: [1, 2, 3] },
$thisRunIndex: 0,
$thisItemIndex: 0,
}) as never;
const resolve = (expression: string) =>
new Expression('UTC').resolveSimpleParameterValue(expression, data());
afterEach(() => {
Expression.setShadowRunner(undefined);
vi.restoreAllMocks();
});
it('calls the runner before the legacy engine and passes the legacy outcome', () => {
const calls: string[] = [];
const outcomes: ExpressionEvaluationOutcome[] = [];
const runner: ExpressionShadowRunner = {
beforeLegacy: (context) => {
calls.push(`before ${context.source} ${context.timezone}`);
return (legacy) => {
calls.push('after');
outcomes.push(legacy);
};
},
};
Expression.setShadowRunner(runner);
expect(resolve('={{ $json.name.toUpperCase() }}')).toBe('N8N');
expect(calls).toEqual(['before {{ $json.name.toUpperCase() }} UTC', 'after']);
expect(outcomes).toEqual([{ ok: true, value: 'N8N' }]);
});
it('passes a legacy error to the runner and still throws it to the caller', () => {
const outcomes: ExpressionEvaluationOutcome[] = [];
Expression.setShadowRunner({ beforeLegacy: () => (legacy) => outcomes.push(legacy) });
expect(() => resolve('={{ ) }}')).toThrow('invalid syntax');
expect(outcomes).toEqual([expect.objectContaining({ ok: false, errorClass: 'SyntaxError' })]);
});
it('keeps the legacy result when the runner throws', () => {
Expression.setShadowRunner({
beforeLegacy: () => {
throw new Error('shadow bug');
},
});
expect(resolve('={{ $json.list.length }}')).toBe(3);
});
it('keeps the legacy result when the outcome callback throws', () => {
Expression.setShadowRunner({
beforeLegacy: () => () => {
throw new Error('shadow bug');
},
});
expect(resolve('={{ $json.list.length }}')).toBe(3);
});
describe('createQuickJsShadowEvaluator', () => {
const start = async (bridgeTimeout = 5000) => {
const require = createRequire(import.meta.url);
const runtimeBundle = await readFile(
require.resolve('@n8n/expression-runtime/runtime-bundle.iife.js'),
'utf8',
);
return await Expression.createQuickJsShadowEvaluator({
bridgeTimeout,
bridgeMemoryLimit: 128,
maxCodeCacheSize: 1024,
runtimeBundle,
});
};
it('evaluates next to the legacy engine without replacing it', async () => {
const evaluator = await start();
try {
expect(evaluator.evaluate('{{ $json.list.length + 1 }}', data(), 'UTC')).toEqual({
ok: true,
value: 4,
});
expect(Expression.getActiveImplementation()).toBe('legacy');
} finally {
await evaluator.dispose();
}
});
it('returns a timeout as an outcome instead of throwing', async () => {
const evaluator = await start(50);
try {
expect(
evaluator.evaluate('{{ (() => { while (true) {} })() }}', data(), 'UTC'),
).toMatchObject({ ok: false, errorClass: 'timeout' });
} finally {
await evaluator.dispose();
}
});
/** An evaluator whose engine throws whatever `thrown` returns. */
const startThrowing = async (thrown: () => unknown) => {
vi.spyOn(
Expression as unknown as { buildEvaluator: () => Promise<unknown> },
'buildEvaluator',
).mockResolvedValue({
initialize: async () => {},
acquire: async () => true,
release: async () => {},
dispose: async () => {},
evaluate: () => {
throw thrown();
},
});
return await Expression.createQuickJsShadowEvaluator({
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
maxCodeCacheSize: 1024,
});
};
it('names an error by its class only when the class is a known one', async () => {
const thrown = (name: string) => Object.assign(new Error('x'), { name });
let next = thrown('TypeError');
const evaluator = await startThrowing(() => next);
expect(evaluator.evaluate('{{ 1 }}', data(), 'UTC')).toMatchObject({
ok: false,
errorClass: 'TypeError',
});
next = thrown('jane@acme.com');
expect(evaluator.evaluate('{{ 1 }}', data(), 'UTC')).toMatchObject({
ok: false,
errorClass: 'other',
});
});
it('names an n8n error by its class when a minified build renames it', async () => {
const minified = (error: Error) => Object.defineProperty(error, 'name', { value: 'Xe' });
let next = minified(new ExpressionError('x'));
const evaluator = await startThrowing(() => next);
expect(evaluator.evaluate('{{ 1 }}', data(), 'UTC')).toMatchObject({
ok: false,
errorClass: 'ExpressionError',
});
next = minified(new ExpressionExtensionError('x'));
expect(evaluator.evaluate('{{ 1 }}', data(), 'UTC')).toMatchObject({
ok: false,
errorClass: 'ExpressionExtensionError',
});
});
});
});