import { describe, it, expect } from 'vitest';
import { ExpressionFormatValidator } from '../../../src/services/expression-format-validator';
describe('ExpressionFormatValidator', () => {
describe('validateAndFix', () => {
const context = {
nodeType: 'n8n-nodes-base.httpRequest',
nodeName: 'HTTP Request',
nodeId: 'test-id-1'
};
describe('Simple string expressions', () => {
it('should detect missing = prefix for expression', () => {
const value = '{{ $env.API_KEY }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context);
expect(issue).toBeTruthy();
expect(issue?.issueType).toBe('missing-prefix');
expect(issue?.correctedValue).toBe('={{ $env.API_KEY }}');
expect(issue?.severity).toBe('error');
});
it('should accept expression with = prefix', () => {
const value = '={{ $env.API_KEY }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'apiKey', context);
expect(issue).toBeNull();
});
it('should detect mixed content without prefix', () => {
const value = 'Bearer {{ $env.TOKEN }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context);
expect(issue).toBeTruthy();
expect(issue?.issueType).toBe('missing-prefix');
expect(issue?.correctedValue).toBe('=Bearer {{ $env.TOKEN }}');
});
it('should accept mixed content with prefix', () => {
const value = '=Bearer {{ $env.TOKEN }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'authorization', context);
expect(issue).toBeNull();
});
it('should ignore plain strings without expressions', () => {
const value = 'https://api.example.com';
const issue = ExpressionFormatValidator.validateAndFix(value, 'url', context);
expect(issue).toBeNull();
});
});
describe('Resource Locator fields', () => {
const githubContext = {
nodeType: 'n8n-nodes-base.github',
nodeName: 'GitHub',
nodeId: 'github-1'
};
it('should detect expression in owner field needing resource locator', () => {
const value = '{{ $vars.GITHUB_OWNER }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext);
expect(issue).toBeTruthy();
expect(issue?.issueType).toBe('needs-resource-locator');
// Corrections use mode: 'expression' which renders a raw expression input,
// not a dropdown — so cachedResultName is intentionally omitted (#715).
expect(issue?.correctedValue).toEqual({
__rl: true,
value: '={{ $vars.GITHUB_OWNER }}',
mode: 'expression'
});
expect(issue?.severity).toBe('error');
});
it('should accept resource locator with expression', () => {
const value = {
__rl: true,
value: '={{ $vars.GITHUB_OWNER }}',
mode: 'expression'
};
const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext);
expect(issue).toBeNull();
});
it('should detect missing prefix in resource locator value', () => {
const value = {
__rl: true,
value: '{{ $vars.GITHUB_OWNER }}',
mode: 'expression'
};
const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext);
expect(issue).toBeTruthy();
expect(issue?.issueType).toBe('missing-prefix');
expect(issue?.correctedValue.value).toBe('={{ $vars.GITHUB_OWNER }}');
});
// The "should use resource locator format" recommendation was removed:
// its name-suffix heuristic was 98.9% false-positive on the template
// corpus and its autofix corrupted plain-string configs (audit B5).
it('does not recommend resource locator format for a correctly prefixed expression', () => {
const value = '={{ $vars.GITHUB_OWNER }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'owner', githubContext);
expect(issue).toBeNull();
});
it('does not flag plain-string fields whose names merely end in Id (telegram chatId)', () => {
const telegramContext = {
nodeType: 'n8n-nodes-base.telegram',
nodeName: 'Telegram',
nodeId: 'telegram-1'
};
const issue = ExpressionFormatValidator.validateAndFix('={{ $json.chatId }}', 'chatId', telegramContext);
expect(issue).toBeNull();
});
});
describe('Missing cachedResultName warning (Issue #715)', () => {
const airtableContext = {
nodeType: 'n8n-nodes-base.airtable',
nodeName: 'Airtable',
nodeId: 'airtable-1'
};
it('warns when a __rl field is missing cachedResultName', () => {
const params = {
base: { __rl: true, mode: 'id', value: 'appXYZ' },
table: { __rl: true, mode: 'id', value: 'tblABC' }
};
const issues = ExpressionFormatValidator.validateNodeParameters(params, airtableContext);
const cachedNameIssues = issues.filter(i => i.issueType === 'missing-cached-result-name');
expect(cachedNameIssues).toHaveLength(2);
expect(cachedNameIssues[0].severity).toBe('warning');
expect(cachedNameIssues[0].fieldPath).toBe('base');
expect(cachedNameIssues[1].fieldPath).toBe('table');
expect(cachedNameIssues[0].explanation).toMatch(/cachedResultName/);
});
it('does not warn when cachedResultName is present and non-empty', () => {
const params = {
base: { __rl: true, mode: 'id', value: 'appXYZ', cachedResultName: 'My Base' }
};
const issues = ExpressionFormatValidator.validateNodeParameters(params, airtableContext);
expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(0);
});
it('warns when cachedResultName is present but empty string', () => {
const params = {
base: { __rl: true, mode: 'id', value: 'appXYZ', cachedResultName: '' }
};
const issues = ExpressionFormatValidator.validateNodeParameters(params, airtableContext);
expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(1);
});
it('does NOT warn for mode: expression (raw expression input has no dropdown)', () => {
// Critical regression guard: validator.generateCorrection emits __rl with
// mode: 'expression' and no cachedResultName — re-validating that output
// must not produce a fresh warning (would cause an autofix loop).
const params = {
base: { __rl: true, mode: 'expression', value: '={{ $json.baseId }}' }
};
const issues = ExpressionFormatValidator.validateNodeParameters(params, airtableContext);
expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(0);
});
it('does NOT warn for mode: url (URL input has no dropdown)', () => {
const params = {
base: { __rl: true, mode: 'url', value: 'https://airtable.com/appXYZ' }
};
const issues = ExpressionFormatValidator.validateNodeParameters(params, airtableContext);
expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(0);
});
it('warns for mode: list (list selection also uses cached labels)', () => {
const params = {
base: { __rl: true, mode: 'list', value: 'appXYZ' }
};
const issues = ExpressionFormatValidator.validateNodeParameters(params, airtableContext);
expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(1);
});
});
describe('Multiple expressions', () => {
it('should detect multiple expressions without prefix', () => {
const value = '{{ $json.first }} - {{ $json.last }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context);
expect(issue).toBeTruthy();
expect(issue?.issueType).toBe('missing-prefix');
expect(issue?.correctedValue).toBe('={{ $json.first }} - {{ $json.last }}');
});
it('should accept multiple expressions with prefix', () => {
const value = '={{ $json.first }} - {{ $json.last }}';
const issue = ExpressionFormatValidator.validateAndFix(value, 'fullName', context);
expect(issue).toBeNull();
});
});
describe('Template literals inside expressions (#338, audit A4)', () => {
it('does not flag backtick template literals inside a prefixed expression', () => {
const value = '={{ $json.vat_id ? `
Hello {{ $json.name }}
', text: 'Hello {{ $json.name }}' }, options: { replyTo: '={{ $env.REPLY_EMAIL }}' } }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(4); expect(issues.map(i => i.fieldPath)).toContain('fromEmail'); expect(issues.map(i => i.fieldPath)).toContain('subject'); expect(issues.map(i => i.fieldPath)).toContain('body.html'); expect(issues.map(i => i.fieldPath)).toContain('body.text'); }); it('should handle arrays with expressions', () => { const parameters = { recipients: [ '{{ $json.email1 }}', 'static@example.com', '={{ $json.email2 }}' ] }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('recipients[0]'); expect(issues[0].correctedValue).toBe('={{ $json.email1 }}'); }); it('should handle nested objects', () => { const parameters = { config: { database: { host: '{{ $env.DB_HOST }}', port: 5432, name: 'mydb' } } }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('config.database.host'); }); it('should skip circular references', () => { const circular: any = { a: 1 }; circular.self = circular; const parameters = { normal: '{{ $json.value }}', circular }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); // Should only find the issue in 'normal', not crash on circular expect(issues).toHaveLength(1); expect(issues[0].fieldPath).toBe('normal'); }); describe('Junk bracket-index keys from botched partial updates (audit A5)', () => { // Diff/patch tooling can write a bracket path (e.g. "assignments[5]") as a // literal object key instead of mutating the array element. n8n stores such // keys but ignores them at runtime. Descending into them builds a path that // collides with the real array element, producing a misleading // missing-prefix error on a healthy field. const setContext = { nodeType: 'n8n-nodes-base.set', nodeName: 'Email 3 - Workflows', nodeId: 'set-1' }; it('ignores junk sibling keys like "assignments[5]" that n8n ignores at runtime', () => { const parameters = { assignments: { assignments: [ { id: '1', name: 'text', value: "=Hi {{ $('Process One at a Time').item.json.name || 'there' }}, welcome" } ], 'assignments[5]': { value: "Hi {{ $('Process One at a Time').item.json.name || 'there' }}, welcome" }, 'assignments[6]': { value: '={{ $json.body }}
' } } }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, setContext); expect(issues).toHaveLength(0); }); it('still errors on a real array element with a missing = prefix', () => { const parameters = { assignments: { assignments: [ { id: '1', name: 'text', value: 'Hi {{ $json.name }}, welcome' } ] } }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, setContext); expect(issues).toHaveLength(1); expect(issues[0].issueType).toBe('missing-prefix'); expect(issues[0].fieldPath).toBe('assignments.assignments[0].value'); expect(issues[0].severity).toBe('error'); }); }); describe('Profile gating for the cachedResultName advisory (#715)', () => { const airtableContext = { nodeType: 'n8n-nodes-base.airtable', nodeName: 'Airtable', nodeId: 'airtable-1' }; const buildParams = () => ({ base: { __rl: true, mode: 'id', value: 'appXYZ' } }); it.each(['minimal', 'runtime'] as const)('suppresses the advisory under %s', (profile) => { const issues = ExpressionFormatValidator.validateNodeParameters(buildParams(), airtableContext, profile); expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(0); }); it.each(['ai-friendly', 'strict'] as const)('emits the advisory under %s', (profile) => { const issues = ExpressionFormatValidator.validateNodeParameters(buildParams(), airtableContext, profile); expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(1); }); it('emits the advisory when no profile is given (autofix compatibility)', () => { const issues = ExpressionFormatValidator.validateNodeParameters(buildParams(), airtableContext); expect(issues.filter(i => i.issueType === 'missing-cached-result-name')).toHaveLength(1); }); }); describe('Code node raw source fields (Issue #746)', () => { // Pre-fix, validateRecursive walked into jsCode/pythonCode and the universal expression // validator counted {{ vs }} occurrences, false-positiving on JS object literals like // `[{ json: { x: 1 }}]` that produce adjacent `}}` characters with no `{{` to match. const codeContext = { nodeType: 'n8n-nodes-base.code', nodeName: 'Code', nodeId: 'code-1' }; it('does not flag jsCode containing template literals and compact `}}`', () => { const parameters = { jsCode: "const d='15', m='04', y='2026';\nreturn [{json:{iso:`${y}-${m}-${d}`}}];" }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, codeContext); expect(issues).toHaveLength(0); }); it('does not flag pythonCode containing f-strings and compact `}}`', () => { const parameters = { pythonCode: "x = 1\nreturn [{'json': {'msg': f'{x} items'}}]" }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, codeContext); expect(issues).toHaveLength(0); }); it('does not flag legacy functionCode field either', () => { const parameters = { functionCode: "return [{json:{x:1}}];" }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, codeContext); expect(issues).toHaveLength(0); }); it('still validates ordinary expression fields on the same parameters object', () => { const parameters = { jsCode: "return [{json:{x:1}}];", // skipped someExpressionField: '{{ $json.value }}' // missing = prefix — should still flag }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, codeContext); expect(issues.length).toBe(1); expect(issues[0].fieldPath).toBe('someExpressionField'); }); it('skips jsCode even when nested under another object/array', () => { // The recursion descends through arrays and nested objects, so the skip // must apply wherever the key appears, not only at the top level. const parameters = { steps: [ { id: 'a', config: { jsCode: 'return [{json:{x:1}}];' } } ] }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, codeContext); expect(issues).toHaveLength(0); }); }); it('should handle maximum recursion depth', () => { // Create a deeply nested object (105 levels deep, exceeding the limit of 100) let deepObject: any = { value: '{{ $json.data }}' }; let current = deepObject; for (let i = 0; i < 105; i++) { current.nested = { value: `{{ $json.level${i} }}` }; current = current.nested; } const parameters = { deep: deepObject }; const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context); // Should find expression format issues up to the depth limit const depthWarning = issues.find(i => i.explanation.includes('Maximum recursion depth')); expect(depthWarning).toBeTruthy(); expect(depthWarning?.severity).toBe('warning'); // Should still find some expression format errors before hitting the limit const formatErrors = issues.filter(i => i.issueType === 'missing-prefix'); expect(formatErrors.length).toBeGreaterThan(0); expect(formatErrors.length).toBeLessThanOrEqual(100); // Should not exceed the depth limit }); }); describe('formatErrorMessage', () => { const context = { nodeType: 'n8n-nodes-base.github', nodeName: 'Create Issue', nodeId: 'github-1' }; it('should format error message for missing prefix', () => { const issue = { fieldPath: 'title', currentValue: '{{ $json.title }}', correctedValue: '={{ $json.title }}', issueType: 'missing-prefix' as const, explanation: "Expression missing required '=' prefix.", severity: 'error' as const }; const message = ExpressionFormatValidator.formatErrorMessage(issue, context); expect(message).toContain("Expression format error in node 'Create Issue'"); expect(message).toContain('Field \'title\''); expect(message).toContain('Current (incorrect):'); expect(message).toContain('"title": "{{ $json.title }}"'); expect(message).toContain('Fixed (correct):'); expect(message).toContain('"title": "={{ $json.title }}"'); }); it('should format error message for resource locator', () => { const issue = { fieldPath: 'owner', currentValue: '{{ $vars.OWNER }}', correctedValue: { __rl: true, value: '={{ $vars.OWNER }}', mode: 'expression' }, issueType: 'needs-resource-locator' as const, explanation: 'Field needs resource locator format.', severity: 'error' as const }; const message = ExpressionFormatValidator.formatErrorMessage(issue, context); expect(message).toContain("Expression format error in node 'Create Issue'"); expect(message).toContain('Current (incorrect):'); expect(message).toContain('"owner": "{{ $vars.OWNER }}"'); expect(message).toContain('Fixed (correct):'); expect(message).toContain('"__rl": true'); expect(message).toContain('"value": "={{ $vars.OWNER }}"'); expect(message).toContain('"mode": "expression"'); }); it('uses "Suggested shape" label for missing-cachedResultName so the placeholder is not mistaken for a valid value', () => { // The correctedValue carries a placeholder string that must be filled in; // labeling it "Fixed (correct)" would be misleading (Copilot caught this). const issue = { fieldPath: 'base', currentValue: { __rl: true, mode: 'id', value: 'appXYZ' }, correctedValue: { __rl: true, mode: 'id', value: 'appXYZ', cachedResultName: '