409e92d6ae
Build and Push Docker Images / Build Docker Image (push) Has been cancelled
Build and Push Docker Images / Build Railway Docker Image (push) Has been cancelled
Build and Publish n8n Docker Image / test-image (push) Has been cancelled
Dependency Compatibility Check / Fresh Install Dependency Check (push) Has been cancelled
Build and Publish n8n Docker Image / build-and-push (push) Has been cancelled
Build and Publish n8n Docker Image / create-release (push) Has been cancelled
Automated Release / Detect Version Change (push) Has been cancelled
Automated Release / Generate Release Notes (push) Has been cancelled
Automated Release / Create GitHub Release (push) Has been cancelled
Automated Release / Package MCPB Bundle (push) Has been cancelled
Automated Release / Build and Verify (push) Has been cancelled
Automated Release / Publish to NPM (push) Has been cancelled
Automated Release / Build and Push Docker Images (push) Has been cancelled
Automated Release / Update Documentation (push) Has been cancelled
Automated Release / Notify Release Completion (push) Has been cancelled
Secret Scan / secretlint (push) Has been cancelled
Test Suite / test (push) Has been cancelled
Test Suite / cjs-runtime (push) Has been cancelled
Test Suite / publish-results (push) Has been cancelled
631 lines
24 KiB
TypeScript
631 lines
24 KiB
TypeScript
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 ? `<x>${$json.vat_id}</x>` : `<y>${$json.customer_email}</y>` }}';
|
|
const issue = ExpressionFormatValidator.validateAndFix(value, 'body', context);
|
|
|
|
expect(issue).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Bracket balance leniency (audit A6)', () => {
|
|
it('does not flag =-prefixed JSON bodies with stray closing braces', () => {
|
|
const value = '={"chat_id": {{ $json.id }}, "reply_markup": {"inline_keyboard": {{ JSON.stringify($json.kb) }}}}';
|
|
const issue = ExpressionFormatValidator.validateAndFix(value, 'jsonBody', context);
|
|
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('does not flag literal fields containing braces', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix(
|
|
'ads{id,status,insights{clicks,impressions}}',
|
|
'fields',
|
|
context
|
|
);
|
|
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('still flags a dangling {{ in an =-prefixed value', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix('={{ $json.value }', 'field', context);
|
|
|
|
expect(issue).toBeTruthy();
|
|
expect(issue?.explanation).toContain('Unmatched expression brackets');
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
it('should handle null values', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix(null, 'field', context);
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('should handle undefined values', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix(undefined, 'field', context);
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('should handle empty strings', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix('', 'field', context);
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('should handle numbers', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix(42, 'field', context);
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('should handle booleans', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix(true, 'field', context);
|
|
expect(issue).toBeNull();
|
|
});
|
|
|
|
it('should handle arrays', () => {
|
|
const issue = ExpressionFormatValidator.validateAndFix(['item1', 'item2'], 'field', context);
|
|
expect(issue).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('validateNodeParameters', () => {
|
|
const context = {
|
|
nodeType: 'n8n-nodes-base.emailSend',
|
|
nodeName: 'Send Email',
|
|
nodeId: 'email-1'
|
|
};
|
|
|
|
it('should validate all parameters recursively', () => {
|
|
const parameters = {
|
|
fromEmail: '{{ $env.SENDER_EMAIL }}',
|
|
toEmail: 'user@example.com',
|
|
subject: 'Test {{ $json.type }}',
|
|
body: {
|
|
html: '<p>Hello {{ $json.name }}</p>',
|
|
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: '=<!DOCTYPE html><p>{{ $json.body }}</p>' }
|
|
}
|
|
};
|
|
|
|
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: '<set to the resource display name>'
|
|
},
|
|
issueType: 'missing-cached-result-name' as const,
|
|
explanation: 'resource locator is missing cachedResultName.',
|
|
severity: 'warning' as const
|
|
};
|
|
|
|
const message = ExpressionFormatValidator.formatErrorMessage(issue, context);
|
|
|
|
expect(message).toContain('Suggested shape (replace the placeholder');
|
|
expect(message).not.toContain('Fixed (correct):');
|
|
expect(message).toContain('"cachedResultName": "<set to the resource display name>"');
|
|
});
|
|
});
|
|
|
|
describe('Real-world examples', () => {
|
|
it('should validate Email Send node example', () => {
|
|
const context = {
|
|
nodeType: 'n8n-nodes-base.emailSend',
|
|
nodeName: 'Error Handler',
|
|
nodeId: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0'
|
|
};
|
|
|
|
const parameters = {
|
|
fromEmail: '{{ $env.ADMIN_EMAIL }}',
|
|
toEmail: 'admin@company.com',
|
|
subject: 'GitHub Issue Workflow Error - HIGH PRIORITY',
|
|
options: {}
|
|
};
|
|
|
|
const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context);
|
|
|
|
expect(issues).toHaveLength(1);
|
|
expect(issues[0].fieldPath).toBe('fromEmail');
|
|
expect(issues[0].correctedValue).toBe('={{ $env.ADMIN_EMAIL }}');
|
|
});
|
|
|
|
it('should validate GitHub node example', () => {
|
|
const context = {
|
|
nodeType: 'n8n-nodes-base.github',
|
|
nodeName: 'Send Welcome Comment',
|
|
nodeId: '3c742ca1-af8f-4d80-a47e-e68fb1ced491'
|
|
};
|
|
|
|
const parameters = {
|
|
operation: 'createComment',
|
|
owner: '{{ $vars.GITHUB_OWNER }}',
|
|
repository: '{{ $vars.GITHUB_REPO }}',
|
|
issueNumber: null,
|
|
body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!\n\nThank you for creating this issue.'
|
|
};
|
|
|
|
const issues = ExpressionFormatValidator.validateNodeParameters(parameters, context);
|
|
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.some(i => i.fieldPath === 'owner')).toBe(true);
|
|
expect(issues.some(i => i.fieldPath === 'repository')).toBe(true);
|
|
expect(issues.some(i => i.fieldPath === 'body')).toBe(true);
|
|
});
|
|
});
|
|
}); |