@blucca/n8n-check 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +10 -0
- package/LICENSE +21 -0
- package/README.md +319 -2
- package/bin/n8n-check.mjs +62 -0
- package/examples/agregado-enrichment/README.md +68 -0
- package/examples/agregado-enrichment/n8n-synthetic +11 -0
- package/examples/agregado-enrichment/observed-results.json +806 -0
- package/examples/agregado-enrichment/prepare.mjs +39 -0
- package/examples/identity-projection/README.md +39 -0
- package/examples/identity-projection/case.json +33 -0
- package/examples/identity-projection/observed-results.json +117 -0
- package/examples/identity-projection/workflow.json +71 -0
- package/examples/identity-projection/wrong-id.json +71 -0
- package/examples/morsof-invoice-history/README.md +77 -0
- package/examples/morsof-invoice-history/UPSTREAM-LICENSE +21 -0
- package/examples/morsof-invoice-history/n8n-datatable.cjs +28 -0
- package/examples/morsof-invoice-history/observed-results.json +113 -0
- package/examples/morsof-invoice-history/prepare.mjs +57 -0
- package/examples/morsof-invoice-history/replay.mjs +35 -0
- package/examples/morsof-invoice-history/run.sh +19 -0
- package/examples/object-body/broken.json +74 -0
- package/examples/object-body/case.json +81 -0
- package/examples/object-body/fixed.json +74 -0
- package/examples/object-body/observed-results.json +449 -0
- package/examples/object-body/retry-case.json +112 -0
- package/examples/object-body/retry.json +77 -0
- package/examples/propfind-body/README.md +75 -0
- package/examples/propfind-body/case.json +51 -0
- package/examples/propfind-body/no-body-control.json +96 -0
- package/examples/propfind-body/observed-results.json +307 -0
- package/examples/propfind-body/workflow.json +96 -0
- package/examples/raw-response/README.md +71 -0
- package/examples/raw-response/file-case.json +54 -0
- package/examples/raw-response/json-body.json +58 -0
- package/examples/raw-response/json-case.json +52 -0
- package/examples/raw-response/observed-results.json +383 -0
- package/examples/raw-response/raw-file.json +88 -0
- package/examples/raw-response/raw-json.json +58 -0
- package/examples/raw-response/raw-text.json +58 -0
- package/examples/raw-response/text-case.json +50 -0
- package/examples/retry-isolation/README.md +71 -0
- package/examples/retry-isolation/direct-case.json +112 -0
- package/examples/retry-isolation/direct.json +101 -0
- package/examples/retry-isolation/editor-template.json +168 -0
- package/examples/retry-isolation/http-batching-case.json +112 -0
- package/examples/retry-isolation/http-batching.json +107 -0
- package/examples/retry-isolation/loop-case.json +117 -0
- package/examples/retry-isolation/loop.json +133 -0
- package/examples/retry-isolation/observed-results.json +155 -0
- package/examples/silent-filter/README.md +57 -0
- package/examples/silent-filter/broken.json +96 -0
- package/examples/silent-filter/case.json +120 -0
- package/examples/silent-filter/fixed.json +96 -0
- package/examples/silent-filter/observed-results.json +273 -0
- package/examples/stop-after/README.md +66 -0
- package/examples/stop-after/broken.json +134 -0
- package/examples/stop-after/case.json +34 -0
- package/examples/stop-after/fixed.json +134 -0
- package/examples/stop-after/observed-results.json +162 -0
- package/examples/version-matrix/README.md +65 -0
- package/examples/version-matrix/observed-results.json +307 -0
- package/examples/version-matrix/upgrade-check.yml +32 -0
- package/package.json +41 -4
- package/src/action.mjs +108 -0
- package/src/case-draft.mjs +163 -0
- package/src/mock.mjs +72 -0
- package/src/report.mjs +73 -0
- package/src/runner.mjs +111 -0
- package/src/version.mjs +3 -0
- package/src/workflow.mjs +140 -0
package/src/action.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const html = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
8
|
+
const cell = value => html(value).replace(/\|/g, '|').replace(/[\r\n]+/g, ' ');
|
|
9
|
+
const detail = value => html(JSON.stringify(value, null, 2)?.slice(0, 12000));
|
|
10
|
+
|
|
11
|
+
export function summary(report) {
|
|
12
|
+
const checks = report.checks ?? [];
|
|
13
|
+
const passed = checks.filter(check => check.passed).length;
|
|
14
|
+
const rows = checks.map(check => `| ${check.passed ? '✅' : '❌'} | ${cell(check.name)} |`);
|
|
15
|
+
const failures = checks.filter(check => !check.passed).map(check => `<details><summary>${html(check.name)}</summary>\n\nExpected:<pre>${detail(check.expected)}</pre>\nActual:<pre>${detail(check.actual)}</pre>\n</details>`);
|
|
16
|
+
const requests = (report.requests ?? []).slice(0, 50).map((r, i) => `| ${i + 1} | ${cell(r.node)} | ${cell(r.method)} | ${cell(r.path)} | ${cell(r.status)} |`);
|
|
17
|
+
return [
|
|
18
|
+
`## n8n-check: ${cell(report.status).toUpperCase()}`,
|
|
19
|
+
`**${cell(report.name)}** · ${passed}/${checks.length} checks · ${(report.requests ?? []).length} HTTP requests`,
|
|
20
|
+
`n8n ${cell(report.n8nVersion ?? 'setup incomplete')} · runner ${cell(report.runnerVersion)} · ${cell(report.network?.mode ?? 'setup incomplete')}`,
|
|
21
|
+
report.error ? `<pre>${html(report.error)}</pre>` : '',
|
|
22
|
+
'| Result | Check |\n| --- | --- |\n' + rows.join('\n'),
|
|
23
|
+
...failures,
|
|
24
|
+
requests.length ? '### Request trace\n\n| # | Node | Method | Path | Status |\n| --- | --- | --- | --- | --- |\n' + requests.join('\n') : '',
|
|
25
|
+
requests.length === 50 ? 'Trace preview: first 50 requests. The JSON report contains the full sequence.' : '',
|
|
26
|
+
'Reports: `report.json` and `junit.xml` in the configured output directory. Upload these files in an `if: always()` step to retain the results.',
|
|
27
|
+
].filter(Boolean).join('\n\n') + '\n';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Existing ancestors are resolved too, so an output symlink follows the same boundary.
|
|
31
|
+
export function workspacePath(workspace, input, kind) {
|
|
32
|
+
if (!input || /[\r\n:,]/.test(input)) throw new Error(`${kind}: supply a repository-relative path without newlines, colons or commas.`);
|
|
33
|
+
if (isAbsolute(input)) throw new Error(`${kind}: use a repository-relative path.`);
|
|
34
|
+
const root = realpathSync(workspace);
|
|
35
|
+
const path = resolve(root, input);
|
|
36
|
+
let ancestor = path;
|
|
37
|
+
while (!existsSync(ancestor)) ancestor = dirname(ancestor);
|
|
38
|
+
const canonical = resolve(realpathSync(ancestor), relative(ancestor, path));
|
|
39
|
+
const rel = relative(root, canonical);
|
|
40
|
+
if (!rel || rel === '..' || rel.startsWith('../') || isAbsolute(rel)) throw new Error(`${kind}: path must be inside the checked-out repository.`);
|
|
41
|
+
if (kind !== 'out' && !statSync(canonical).isFile()) throw new Error(`${kind}: expected a JSON file.`);
|
|
42
|
+
if (kind === 'out' && existsSync(canonical) && !statSync(canonical).isDirectory()) throw new Error('out: expected a directory.');
|
|
43
|
+
return canonical;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function dockerArguments({ workflow, caseFile, out, image, uid, gid }) {
|
|
47
|
+
return ['run', '--rm', '--network', 'none', '--user', `${uid}:${gid}`,
|
|
48
|
+
'--mount', `type=bind,source=${workflow},target=/inputs/workflow.json,readonly`,
|
|
49
|
+
'--mount', `type=bind,source=${caseFile},target=/inputs/case.json,readonly`,
|
|
50
|
+
'--mount', `type=bind,source=${out},target=/results`,
|
|
51
|
+
image, '/inputs/workflow.json', '/inputs/case.json', '--out', '/results'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildArguments({ actionRoot, image, n8nVersion }) {
|
|
55
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(n8nVersion)) {
|
|
56
|
+
throw new Error('n8n-version: supply an exact release, such as 2.41.7.');
|
|
57
|
+
}
|
|
58
|
+
return ['build', '--build-arg', `N8N_VERSION=${n8nVersion}`, '--tag', image, actionRoot];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function execute(args) {
|
|
62
|
+
const result = spawnSync('docker', args, { stdio: 'inherit' });
|
|
63
|
+
if (result.error) throw result.error;
|
|
64
|
+
if (result.signal) throw new Error(`Docker interrupted by ${result.signal}`);
|
|
65
|
+
return result.status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function main(env = process.env) {
|
|
69
|
+
let exitCode = 2;
|
|
70
|
+
try {
|
|
71
|
+
if (process.platform !== 'linux') throw new Error('Use a Linux runner with Docker, such as ubuntu-latest.');
|
|
72
|
+
const workspace = env.GITHUB_WORKSPACE;
|
|
73
|
+
const actionRoot = env.ACTION_ROOT;
|
|
74
|
+
const n8nVersion = env.CHECK_N8N_VERSION || '2.41.7';
|
|
75
|
+
const hash = createHash('sha256').update(`${actionRoot}\0${n8nVersion}`).digest('hex').slice(0, 16);
|
|
76
|
+
const image = `n8n-check-action:${hash}`;
|
|
77
|
+
const build = buildArguments({ actionRoot, image, n8nVersion });
|
|
78
|
+
const workflow = workspacePath(workspace, env.CHECK_WORKFLOW, 'workflow');
|
|
79
|
+
const caseFile = workspacePath(workspace, env.CHECK_CASE, 'case');
|
|
80
|
+
const out = workspacePath(workspace, env.CHECK_OUT || '.n8n-check', 'out');
|
|
81
|
+
for (const input of [workflow, caseFile]) {
|
|
82
|
+
const rel = relative(out, input);
|
|
83
|
+
if (rel && rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel)) throw new Error('out: choose a results directory separate from the workflow and case files.');
|
|
84
|
+
}
|
|
85
|
+
mkdirSync(out, { recursive: true });
|
|
86
|
+
// Each invocation reports its own result, including a build or startup failure.
|
|
87
|
+
for (const file of ['report.json', 'junit.xml']) rmSync(join(out, file), { force: true });
|
|
88
|
+
console.log(`::group::Build n8n-check with n8n ${n8nVersion}`);
|
|
89
|
+
const built = execute(build);
|
|
90
|
+
console.log('::endgroup::');
|
|
91
|
+
if (built !== 0) throw new Error(`Docker build exited ${built}.`);
|
|
92
|
+
const code = execute(dockerArguments({ workflow, caseFile, out, image, uid: process.getuid(), gid: process.getgid() }));
|
|
93
|
+
exitCode = [0, 1, 2].includes(code) ? code : 2;
|
|
94
|
+
if (existsSync(join(out, 'report.json'))) {
|
|
95
|
+
const report = JSON.parse(readFileSync(join(out, 'report.json'), 'utf8'));
|
|
96
|
+
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary(report));
|
|
97
|
+
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `report=${join(out, 'report.json')}\njunit=${join(out, 'junit.xml')}\n`);
|
|
98
|
+
} else throw new Error(`Docker exited ${code} before producing a report.`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
exitCode = 2;
|
|
101
|
+
console.error(`n8n-check action: ${error.message}`);
|
|
102
|
+
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `## n8n-check: SETUP ERROR\n\n<pre>${html(error.message)}</pre>\n`);
|
|
103
|
+
}
|
|
104
|
+
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `exit-code=${exitCode}\n`);
|
|
105
|
+
return exitCode;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) process.exitCode = main();
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { prepareWorkflow, validateCase, executionSlice } from './workflow.mjs';
|
|
2
|
+
|
|
3
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
const copy = value => structuredClone(value);
|
|
5
|
+
const has = (value, key) => Object.hasOwn(value, key);
|
|
6
|
+
|
|
7
|
+
function workflowFrom(source) {
|
|
8
|
+
if (Array.isArray(source)) {
|
|
9
|
+
if (source.length !== 1) throw new Error('Supply one workflow export; received an array with multiple workflows');
|
|
10
|
+
source = source[0];
|
|
11
|
+
}
|
|
12
|
+
if (!plain(source) || !Array.isArray(source.nodes) || !plain(source.connections)) throw new Error('Expected an n8n workflow export with nodes and connections');
|
|
13
|
+
if (source.nodes.some(node => !plain(node) || typeof node.name !== 'string' || typeof node.type !== 'string')) throw new Error('Each workflow node needs a name and type');
|
|
14
|
+
if (new Set(source.nodes.map(node => node.name)).size !== source.nodes.length) throw new Error('Workflow node names must be unique');
|
|
15
|
+
return source;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function linksFrom(source) {
|
|
19
|
+
const links = [];
|
|
20
|
+
for (const [from, types] of Object.entries(source.connections)) {
|
|
21
|
+
if (!plain(types)) throw new Error(`Invalid connections for node: ${from}`);
|
|
22
|
+
for (const branches of Object.values(types)) {
|
|
23
|
+
if (!Array.isArray(branches) || branches.some(branch => !Array.isArray(branch) || branch.some(edge => !plain(edge) || typeof edge.node !== 'string'))) throw new Error(`Invalid connections for node: ${from}`);
|
|
24
|
+
for (const branch of branches) for (const edge of branch) links.push({ from, to: edge.node });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return links;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function reachable(input, links, stopAfter) {
|
|
31
|
+
const kept = new Set(input ? [input] : []);
|
|
32
|
+
for (const name of kept) if (name !== stopAfter) for (const link of links) if (link.from === name) kept.add(link.to);
|
|
33
|
+
return kept;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function targetFor(source, input, links) {
|
|
37
|
+
const kept = reachable(input, links);
|
|
38
|
+
const candidates = source.nodes.filter(node => kept.has(node.name) && node.name !== input && node.type !== 'n8n-nodes-base.stickyNote');
|
|
39
|
+
const terminal = candidates.filter(node => !links.some(link => link.from === node.name));
|
|
40
|
+
return (terminal.find(node => has(source.pinData ?? {}, node.name)) ?? terminal.at(-1) ?? candidates.at(-1))?.name ?? '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function inspectWorkflow(raw) {
|
|
44
|
+
const source = workflowFrom(raw);
|
|
45
|
+
const links = linksFrom(source);
|
|
46
|
+
const nodes = source.nodes.map(node => ({ name: node.name, type: node.type, hasPinData: has(source.pinData ?? {}, node.name), pinItemCount: Array.isArray(source.pinData?.[node.name]) ? source.pinData[node.name].length : 0 }));
|
|
47
|
+
const candidates = nodes.filter(node => node.type !== 'n8n-nodes-base.stickyNote' && links.some(link => link.from === node.name));
|
|
48
|
+
const suggestedInputNode = (candidates.find(node => node.hasPinData && node.pinItemCount > 0) ?? candidates[0] ?? nodes.find(node => node.type !== 'n8n-nodes-base.stickyNote'))?.name ?? '';
|
|
49
|
+
return { nodes, suggestedInputNode, suggestedAssertNode: targetFor(source, suggestedInputNode, links) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pure local planning: exported pins supply JSON snapshots; HTTP contracts are supplied by the author. */
|
|
53
|
+
export function draftCase(raw, options = {}) {
|
|
54
|
+
const source = workflowFrom(raw);
|
|
55
|
+
const inspected = inspectWorkflow(source);
|
|
56
|
+
const links = linksFrom(source);
|
|
57
|
+
const inputNode = options.inputNode ?? inspected.suggestedInputNode;
|
|
58
|
+
const assertNode = options.assertNode ?? options.stopAfter ?? targetFor(source, inputNode, links);
|
|
59
|
+
const output = options.output ?? 0;
|
|
60
|
+
let kept;
|
|
61
|
+
try { ({ kept } = executionSlice(source, inputNode, options.stopAfter)); }
|
|
62
|
+
catch { kept = reachable(inputNode, links, options.stopAfter); } // The structural probe below reports the error alongside pending fields.
|
|
63
|
+
const selected = source.nodes.filter(node => kept.has(node.name));
|
|
64
|
+
const http = selected.filter(node => node.name !== inputNode && node.type === 'n8n-nodes-base.httpRequest');
|
|
65
|
+
const issues = [];
|
|
66
|
+
const issue = (code, path, message) => issues.push({ code, path, message });
|
|
67
|
+
const itemsFor = (optionKey, nodeName, path, allowPins = true) => {
|
|
68
|
+
if (has(options, optionKey) && options[optionKey] !== undefined) {
|
|
69
|
+
const value = options[optionKey];
|
|
70
|
+
if (!Array.isArray(value) || !value.every(plain)) issue('items_required', path, 'Supply an array of JSON objects.');
|
|
71
|
+
else if (optionKey === 'inputItems' && value.length === 0) issue('input_empty', path, 'Add at least one input item to exercise the downstream slice.');
|
|
72
|
+
return copy(value);
|
|
73
|
+
}
|
|
74
|
+
const pins = source.pinData?.[nodeName];
|
|
75
|
+
if (allowPins && Array.isArray(pins) && pins.every(item => plain(item) && plain(item.json) && !has(item, 'binary'))) {
|
|
76
|
+
if (optionKey === 'inputItems' && pins.length === 0) issue('input_empty', path, 'The pinned input is empty. Add at least one input item to exercise the downstream slice.');
|
|
77
|
+
return copy(pins.map(item => item.json));
|
|
78
|
+
}
|
|
79
|
+
const detail = pins !== undefined && allowPins ? ' Use JSON-only pins in [{"json": {...}}] form, or enter the JSON item array.' : ' Pin this node after a known-good run and re-export, or enter the JSON item array.';
|
|
80
|
+
issue('items_required', path, `Supply ${optionKey === 'inputItems' ? 'fixture input' : 'expected output'} for "${nodeName || 'select a node'}".${detail}`);
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
const inputItems = itemsFor('inputItems', inputNode, 'input.items');
|
|
84
|
+
const assertionMode = options.assertionMode ?? 'exact';
|
|
85
|
+
const assertion = { node: assertNode, ...(output ? { output } : {}) };
|
|
86
|
+
if (assertionMode === 'exact') {
|
|
87
|
+
assertion.equals = itemsFor('expectedItems', assertNode, 'assertions[0].equals', output === 0);
|
|
88
|
+
} else if (['pluck', 'count'].includes(assertionMode)) {
|
|
89
|
+
const pins = source.pinData?.[assertNode];
|
|
90
|
+
const usablePins = output === 0 && Array.isArray(pins) && pins.every(item => plain(item) && plain(item.json) && !has(item, 'binary'));
|
|
91
|
+
assertion.count = options.expectedCount !== undefined ? copy(options.expectedCount) : usablePins ? pins.length : null;
|
|
92
|
+
if (!Number.isInteger(assertion.count) || assertion.count < 0) issue('count_required', 'assertions[0].count', 'Enter the expected item count as a whole number starting at 0.');
|
|
93
|
+
if (assertionMode === 'pluck') {
|
|
94
|
+
assertion.pluck = options.pluck === undefined ? 'json.id' : options.pluck;
|
|
95
|
+
const validPath = typeof assertion.pluck === 'string' && assertion.pluck.split('.').every(part => part.length > 0);
|
|
96
|
+
if (!validPath) issue('pluck_required', 'assertions[0].pluck', 'Enter a dot-separated path from the n8n item, such as json.id.');
|
|
97
|
+
assertion.equals = options.expectedValues !== undefined ? copy(options.expectedValues) : null;
|
|
98
|
+
if (options.expectedValues === undefined && usablePins && validPath) {
|
|
99
|
+
const missingItems = [];
|
|
100
|
+
const values = pins.map((item, index) => {
|
|
101
|
+
let value = item;
|
|
102
|
+
for (const key of assertion.pluck.split('.')) {
|
|
103
|
+
if (value === null || typeof value !== 'object' || !has(value, key)) {
|
|
104
|
+
missingItems.push(index);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
value = value[key];
|
|
108
|
+
}
|
|
109
|
+
return copy(value);
|
|
110
|
+
});
|
|
111
|
+
if (missingItems.length) issue('projection_missing', 'assertions[0].equals', `The selected field is missing in pinned items at zero-based indexes ${missingItems.join(', ')}. Choose a present field or enter expected values.`);
|
|
112
|
+
else assertion.equals = values;
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(assertion.equals)) issue('values_required', 'assertions[0].equals', 'Enter an array of expected field values in run order, such as ["row-A", "row-B"].');
|
|
115
|
+
else if (Number.isInteger(assertion.count) && assertion.count >= 0 && assertion.count !== assertion.equals.length) issue('count_mismatch', 'assertions[0].count', 'Match the item count to the number of expected field values.');
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
issue('assertion_mode', 'assertions[0]', 'Choose exact, pluck, or count assertion mode.');
|
|
119
|
+
assertion.equals = null;
|
|
120
|
+
}
|
|
121
|
+
if (inputNode === assertNode && inputNode) issue('assert_downstream', 'assertions[0].node', 'Choose a downstream assertion node to check executed behavior. The input node is replaced with your fixture.');
|
|
122
|
+
if (!assertNode) issue('assert_required', 'assertions[0].node', 'Choose a downstream node whose output this case should preserve.');
|
|
123
|
+
|
|
124
|
+
let mocks;
|
|
125
|
+
if (options.mocks !== undefined) {
|
|
126
|
+
mocks = copy(options.mocks);
|
|
127
|
+
if (Array.isArray(mocks)) for (const [index, mock] of mocks.entries()) {
|
|
128
|
+
if (mock?.url === '/TODO') issue('mock_path_required', `mocks[${index}].url`, `\"${mock.node}\": replace /TODO with the local URL path or expression for this HTTP node.`);
|
|
129
|
+
if (Array.isArray(mock?.routes)) for (const [routeIndex, route] of mock.routes.entries()) {
|
|
130
|
+
if (route?.path === '/TODO') issue('mock_path_required', `mocks[${index}].routes[${routeIndex}].path`, `\"${mock.node}\": replace /TODO with a concrete route path for your fixture IDs.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else mocks = http.map((node, index) => {
|
|
135
|
+
const original = node.parameters?.url;
|
|
136
|
+
let path = '/TODO';
|
|
137
|
+
if (typeof original === 'string' && !original.includes('{{') && !original.startsWith('=')) {
|
|
138
|
+
try { const url = new URL(original); if (['http:', 'https:'].includes(url.protocol)) path = url.pathname + url.search; } catch {}
|
|
139
|
+
}
|
|
140
|
+
const method = node.parameters?.method ?? 'GET';
|
|
141
|
+
issue('mock_contract_required', `mocks[${index}]`, `"${node.name}": enter response status/body and expected request count for each route. Pinned output is a snapshot; HTTP status, request count and response grouping are author-supplied contracts.`);
|
|
142
|
+
if (path === '/TODO') issue('mock_path_required', `mocks[${index}].url`, `"${node.name}": set the local URL expression and concrete route paths for your fixture IDs.`);
|
|
143
|
+
return { node: node.name, url: path, routes: [{ method, path, responses: [{ status: null, json: null }], expect: { count: null } }] };
|
|
144
|
+
});
|
|
145
|
+
const boundary = options.stopAfter === undefined ? {} : { stopAfter: options.stopAfter };
|
|
146
|
+
const spec = { version: 1, name: options.name ?? `${source.name || 'Workflow'} check`, input: { node: inputNode, items: inputItems }, ...boundary, mocks, assertions: [assertion] };
|
|
147
|
+
// Valid placeholders exist only in this structural probe; the saved draft retains pending fields.
|
|
148
|
+
const probe = {
|
|
149
|
+
version: 1, name: 'Draft structure check', input: { node: inputNode, items: [] }, ...boundary,
|
|
150
|
+
mocks: http.map(node => ({ node: node.name, url: '/draft', routes: [{ method: 'GET', path: '/draft', responses: [{ status: 200, json: {} }], expect: { count: 0 } }] })),
|
|
151
|
+
assertions: [{ node: assertNode, equals: [] }],
|
|
152
|
+
};
|
|
153
|
+
try { prepareWorkflow(source, probe, 'http://127.0.0.1:1'); }
|
|
154
|
+
catch (error) { issue('slice_structure', 'workflow', error.message); }
|
|
155
|
+
if (issues.length === 0) {
|
|
156
|
+
try { validateCase(spec); prepareWorkflow(source, spec, 'http://127.0.0.1:1'); }
|
|
157
|
+
catch (error) { issue('case_invalid', 'case', error.message); }
|
|
158
|
+
} else if (options.mocks !== undefined) {
|
|
159
|
+
try { validateCase({ ...spec, input: { node: inputNode, items: [] }, assertions: [{ node: assertNode, equals: [] }] }); }
|
|
160
|
+
catch (error) { issue('case_invalid', 'case', error.message); }
|
|
161
|
+
}
|
|
162
|
+
return { spec, issues, ready: issues.length === 0, summary: { inputNode, assertNode, ...(options.stopAfter === undefined ? {} : { stopAfter: options.stopAfter }), keptNodes: selected.map(node => node.name), omittedNodes: source.nodes.filter(node => !kept.has(node.name)).map(node => node.name), httpNodes: http.map(node => node.name) } };
|
|
163
|
+
}
|
package/src/mock.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
|
|
4
|
+
export async function startMock(spec) {
|
|
5
|
+
const requests = [];
|
|
6
|
+
const counts = new Map();
|
|
7
|
+
const errors = [];
|
|
8
|
+
const server = http.createServer(async (req, res) => {
|
|
9
|
+
const send = (status, body, headers = {}) => {
|
|
10
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
11
|
+
res.end(JSON.stringify(body));
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
let raw = '';
|
|
15
|
+
for await (const chunk of req) {
|
|
16
|
+
raw += chunk;
|
|
17
|
+
if (Buffer.byteLength(raw) > 1024 * 1024) {
|
|
18
|
+
errors.push('Mock request body exceeded 1 MiB');
|
|
19
|
+
return send(413, { error: 'Mock request body limit' });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
23
|
+
const match = url.pathname.match(/^\/(\d+)(\/.*)$/);
|
|
24
|
+
const mockIndex = match ? Number(match[1]) : -1;
|
|
25
|
+
const mock = spec.mocks[mockIndex];
|
|
26
|
+
const path = (match?.[2] ?? url.pathname) + url.search;
|
|
27
|
+
const routeIndex = mock?.routes.findIndex(route => route.method === req.method && route.path === path) ?? -1;
|
|
28
|
+
const route = mock?.routes[routeIndex];
|
|
29
|
+
let body = raw;
|
|
30
|
+
try { body = raw ? JSON.parse(raw) : null; } catch {}
|
|
31
|
+
const record = { node: mock?.node ?? null, method: req.method, path, body, matched: Boolean(route) };
|
|
32
|
+
const key = `${mockIndex}:${routeIndex}`;
|
|
33
|
+
const sequence = counts.get(key) ?? 0;
|
|
34
|
+
counts.set(key, sequence + 1);
|
|
35
|
+
if (requests.length >= (spec.maxRequests ?? 100)) {
|
|
36
|
+
if (!errors.includes('Mock request count cap reached')) errors.push('Mock request count cap reached');
|
|
37
|
+
return send(429, { error: 'Mock request count cap reached' });
|
|
38
|
+
}
|
|
39
|
+
requests.push(record);
|
|
40
|
+
if (!route) {
|
|
41
|
+
record.status = 404;
|
|
42
|
+
return send(404, { error: 'Unexpected mock request', method: req.method, path });
|
|
43
|
+
}
|
|
44
|
+
const response = route.responses[Math.min(sequence, route.responses.length - 1)];
|
|
45
|
+
record.status = response.status;
|
|
46
|
+
record.response = response.json;
|
|
47
|
+
send(response.status, response.json, response.headers);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
errors.push(error.message);
|
|
50
|
+
if (!res.headersSent) send(500, { error: 'Mock server error' });
|
|
51
|
+
else res.end();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.requestTimeout = 10000;
|
|
55
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
56
|
+
return {
|
|
57
|
+
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
|
58
|
+
requests,
|
|
59
|
+
close: () => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }),
|
|
60
|
+
checks() {
|
|
61
|
+
const checks = [{ name: 'mock: all requests match routes', passed: requests.every(r => r.matched), actual: requests.filter(r => !r.matched) }];
|
|
62
|
+
checks.push({ name: 'mock: request limits and server', passed: errors.length === 0, actual: errors });
|
|
63
|
+
for (const mock of spec.mocks) for (const route of mock.routes) {
|
|
64
|
+
const observed = requests.filter(r => r.node === mock.node && r.method === route.method && r.path === route.path);
|
|
65
|
+
const prefix = `${mock.node}: ${route.method} ${route.path}`;
|
|
66
|
+
checks.push({ name: `${prefix} request count`, passed: observed.length === route.expect.count, expected: route.expect.count, actual: observed.length });
|
|
67
|
+
if (route.expect.bodies !== undefined) checks.push({ name: `${prefix} request bodies`, passed: isDeepStrictEqual(observed.map(r => r.body), route.expect.bodies), expected: route.expect.bodies, actual: observed.map(r => r.body) });
|
|
68
|
+
}
|
|
69
|
+
return checks;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
|
|
3
|
+
// n8n surrounds --rawOutput JSON with logs on some failure paths.
|
|
4
|
+
export function executionFrom(text) {
|
|
5
|
+
for (let start = 0; start < text.length; start++) {
|
|
6
|
+
if (text[start] !== '{' || (start > 0 && text[start - 1] !== '\n')) continue;
|
|
7
|
+
let depth = 0, quoted = false, escaped = false;
|
|
8
|
+
for (let i = start; i < text.length; i++) {
|
|
9
|
+
const char = text[i];
|
|
10
|
+
if (quoted) {
|
|
11
|
+
if (escaped) escaped = false;
|
|
12
|
+
else if (char === '\\') escaped = true;
|
|
13
|
+
else if (char === '"') quoted = false;
|
|
14
|
+
} else if (char === '"') quoted = true;
|
|
15
|
+
else if (char === '{') depth++;
|
|
16
|
+
else if (char === '}' && --depth === 0) {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(text.slice(start, i + 1));
|
|
19
|
+
if (value.data?.resultData) return value;
|
|
20
|
+
} catch {}
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function executionChecks(execution, command, assertions) {
|
|
29
|
+
const result = execution?.data?.resultData;
|
|
30
|
+
// Preserve the runtime's explanation when it fails before emitting execution JSON,
|
|
31
|
+
// including errors while serializing a node's response stream.
|
|
32
|
+
const cliOutputTail = !execution
|
|
33
|
+
? (command.stderr?.trim() || command.stdout?.trim() || '').slice(-4000) || undefined
|
|
34
|
+
: undefined;
|
|
35
|
+
const checks = [{
|
|
36
|
+
name: 'n8n execution succeeds',
|
|
37
|
+
passed: Boolean(execution && !result.error && command.exitCode === 0 && !command.error),
|
|
38
|
+
actual: { cliExitCode: command.exitCode, processError: command.error, lastNode: result?.lastNodeExecuted, message: result?.error?.message, description: result?.error?.description, executionFound: Boolean(execution), cliOutputTail },
|
|
39
|
+
}];
|
|
40
|
+
for (const assertion of assertions) {
|
|
41
|
+
const runs = result?.runData?.[assertion.node] ?? [];
|
|
42
|
+
const items = runs.flatMap(run => run.data?.main?.[assertion.output ?? 0] ?? []);
|
|
43
|
+
const name = `${assertion.node}: output ${assertion.output ?? 0}`;
|
|
44
|
+
if (assertion.count !== undefined) checks.push({ name: `${name} count`, passed: items.length === assertion.count, expected: assertion.count, actual: items.length });
|
|
45
|
+
if (assertion.equals !== undefined) {
|
|
46
|
+
const missingItems = [];
|
|
47
|
+
const path = assertion.pluck?.split('.');
|
|
48
|
+
const actual = items.map((item, index) => {
|
|
49
|
+
if (!path) return item.json;
|
|
50
|
+
let value = item;
|
|
51
|
+
for (const key of path) {
|
|
52
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) {
|
|
53
|
+
missingItems.push(index);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
value = value[key];
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
});
|
|
60
|
+
checks.push({ name: path ? `${name} pluck ${assertion.pluck}` : name, passed: missingItems.length === 0 && isDeepStrictEqual(actual, assertion.equals), expected: assertion.equals, actual: missingItems.length ? { values: actual, missingItems } : actual });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return checks;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const xml = value => String(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '').replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]);
|
|
67
|
+
export function junit(report) {
|
|
68
|
+
const error = report.status === 'error';
|
|
69
|
+
const checks = error ? [{ name: 'setup', passed: false, actual: report.error }] : report.checks;
|
|
70
|
+
const failed = checks.filter(check => !check.passed).length;
|
|
71
|
+
const cases = checks.map(check => ` <testcase classname="n8n-check" name="${xml(check.name)}">${check.passed ? '' : `\n <${error ? 'error' : 'failure'} message="${xml(check.name)}">${xml(JSON.stringify({ expected: check.expected, actual: check.actual }, null, 2))}</${error ? 'error' : 'failure'}>\n `}</testcase>`).join('\n');
|
|
72
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites tests="${checks.length}" failures="${error ? 0 : failed}" errors="${error ? 1 : 0}">\n <testsuite name="${xml(report.name)}" tests="${checks.length}" failures="${error ? 0 : failed}" errors="${error ? 1 : 0}" time="${(report.durationMs / 1000).toFixed(3)}">\n${cases}\n </testsuite>\n</testsuites>\n`;
|
|
73
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile, writeFile, mkdir, mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { validateCase, prepareWorkflow } from './workflow.mjs';
|
|
6
|
+
import { startMock } from './mock.mjs';
|
|
7
|
+
import { executionFrom, executionChecks, junit } from './report.mjs';
|
|
8
|
+
import { version } from './version.mjs';
|
|
9
|
+
|
|
10
|
+
export async function checkNetwork(allowNetwork) {
|
|
11
|
+
if (allowNetwork) return { mode: 'host-network', enforced: false };
|
|
12
|
+
if (process.platform === 'linux') {
|
|
13
|
+
const interfaces = (await readFile('/proc/self/net/dev', 'utf8')).trim().split('\n').slice(2).map(line => line.split(':')[0].trim());
|
|
14
|
+
if (interfaces.length === 1 && interfaces[0] === 'lo') return { mode: 'loopback-only', enforced: true, interfaces };
|
|
15
|
+
}
|
|
16
|
+
throw new Error('Run in a loopback-only network namespace (Docker --network none), or use --allow-network for a trusted local development run. See README Quick start.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runtimeEnv(home) {
|
|
20
|
+
const env = {};
|
|
21
|
+
for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'LANG', 'LC_ALL', 'TZ']) {
|
|
22
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...env, HOME: home, USERPROFILE: home, TMPDIR: join(home, 'tmp'), TEMP: join(home, 'tmp'), TMP: join(home, 'tmp'),
|
|
26
|
+
N8N_USER_FOLDER: home, DB_TYPE: 'sqlite', N8N_ENCRYPTION_KEY: randomBytes(32).toString('hex'),
|
|
27
|
+
N8N_DIAGNOSTICS_ENABLED: 'false', N8N_VERSION_NOTIFICATIONS_ENABLED: 'false', N8N_TEMPLATES_ENABLED: 'false',
|
|
28
|
+
N8N_RUNNERS_MODE: 'internal', N8N_LOG_LEVEL: 'info', N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'true',
|
|
29
|
+
N8N_BLOCK_ENV_ACCESS_IN_NODE: 'true', N8N_COMMUNITY_PACKAGES_ENABLED: 'false',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function command(binary, args, env, timeoutMs = 120000) {
|
|
34
|
+
return new Promise(resolve => {
|
|
35
|
+
let child;
|
|
36
|
+
let stdout = '', stderr = '', bytes = 0, error, settled = false;
|
|
37
|
+
const stop = () => {
|
|
38
|
+
try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch {}
|
|
39
|
+
};
|
|
40
|
+
try { child = spawn(binary, args, { env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' }); }
|
|
41
|
+
catch (e) { return resolve({ stdout, stderr, exitCode: null, error: e.message }); }
|
|
42
|
+
const timer = setTimeout(() => { error = `Process timeout after ${timeoutMs} ms`; stop(); }, timeoutMs);
|
|
43
|
+
const capture = stream => chunk => {
|
|
44
|
+
bytes += Buffer.byteLength(chunk);
|
|
45
|
+
if (bytes > 16 * 1024 * 1024) { error = 'Process output exceeded 16 MiB'; stop(); return; }
|
|
46
|
+
if (stream === 'stdout') stdout += chunk.toString(); else stderr += chunk.toString();
|
|
47
|
+
};
|
|
48
|
+
child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
|
|
49
|
+
child.stdout.on('data', capture('stdout')); child.stderr.on('data', capture('stderr'));
|
|
50
|
+
const interrupt = () => { error = 'Process interrupted'; stop(); };
|
|
51
|
+
process.once('SIGINT', interrupt); process.once('SIGTERM', interrupt);
|
|
52
|
+
child.on('error', e => { error = e.message; });
|
|
53
|
+
child.on('close', (exitCode, signal) => {
|
|
54
|
+
if (settled) return; settled = true; clearTimeout(timer);
|
|
55
|
+
process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', interrupt);
|
|
56
|
+
// Ensure task-runner descendants terminate with their CLI parent.
|
|
57
|
+
stop();
|
|
58
|
+
resolve({ stdout, stderr, exitCode, signal, error });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runCase(options) {
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
const out = resolve(options.out ?? '.n8n-check');
|
|
66
|
+
const report = { schemaVersion: 1, runnerVersion: version, name: 'n8n-check', startedAt: new Date().toISOString(), nodeVersion: process.version, status: 'error', checks: [], artifacts: out };
|
|
67
|
+
let mock;
|
|
68
|
+
await mkdir(out, { recursive: true });
|
|
69
|
+
try {
|
|
70
|
+
const spec = validateCase(JSON.parse(await readFile(resolve(options.caseFile), 'utf8')));
|
|
71
|
+
report.name = spec.name;
|
|
72
|
+
report.network = await checkNetwork(options.allowNetwork);
|
|
73
|
+
const source = JSON.parse(await readFile(resolve(options.workflowFile), 'utf8'));
|
|
74
|
+
mock = await startMock(spec);
|
|
75
|
+
const prepared = prepareWorkflow(source, spec, mock.baseUrl);
|
|
76
|
+
report.preparation = { inputNode: spec.input.node, changes: prepared.changes, omittedNodes: prepared.omittedNodes };
|
|
77
|
+
const runDir = await mkdtemp(join(out, 'run-'));
|
|
78
|
+
report.runDirectory = runDir;
|
|
79
|
+
const home = join(runDir, 'n8n-home');
|
|
80
|
+
await mkdir(join(home, 'tmp'), { recursive: true });
|
|
81
|
+
const env = runtimeEnv(home);
|
|
82
|
+
const n8n = options.n8n ?? process.env.N8N_BINARY ?? 'n8n';
|
|
83
|
+
const invoke = async (args, name, timeout) => {
|
|
84
|
+
const result = await command(n8n, args, env, timeout);
|
|
85
|
+
await Promise.all(['stdout', 'stderr'].map(stream => writeFile(join(runDir, `${name}.${stream}.log`), result[stream])));
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
const version = await invoke(['--version'], 'version');
|
|
89
|
+
if (version.exitCode !== 0 || version.error) throw new Error(`n8n runtime unavailable: ${version.error || version.stderr || version.stdout}. Supply --n8n /path/to/n8n or use the Docker quick start.`);
|
|
90
|
+
report.n8nVersion = version.stdout.trim();
|
|
91
|
+
const workflowPath = join(runDir, 'workflow.json');
|
|
92
|
+
await writeFile(workflowPath, JSON.stringify(prepared.workflow, null, 2) + '\n');
|
|
93
|
+
const imported = await invoke(['import:workflow', `--input=${workflowPath}`], 'import');
|
|
94
|
+
if (imported.exitCode !== 0 || imported.error) throw new Error(`n8n import failed: ${imported.error || imported.stderr || imported.stdout}. Logs: ${runDir}`);
|
|
95
|
+
const executed = await invoke(['execute', `--id=${prepared.workflow.id}`, '--rawOutput'], 'execute', spec.timeoutMs ?? 60000);
|
|
96
|
+
const execution = executionFrom(executed.stdout + '\n' + executed.stderr);
|
|
97
|
+
if (execution) await writeFile(join(runDir, 'execution.json'), JSON.stringify(execution, null, 2) + '\n');
|
|
98
|
+
report.checks.push(...executionChecks(execution, executed, spec.assertions), ...mock.checks());
|
|
99
|
+
report.requests = mock.requests;
|
|
100
|
+
report.status = report.checks.every(check => check.passed) ? 'passed' : 'failed';
|
|
101
|
+
} catch (error) {
|
|
102
|
+
report.status = 'error'; report.error = error.message;
|
|
103
|
+
} finally {
|
|
104
|
+
if (mock) { report.requests = mock.requests; await mock.close(); }
|
|
105
|
+
}
|
|
106
|
+
report.durationMs = Date.now() - start;
|
|
107
|
+
report.exitCode = report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2;
|
|
108
|
+
await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2) + '\n');
|
|
109
|
+
await writeFile(join(out, 'junit.xml'), junit(report));
|
|
110
|
+
return report;
|
|
111
|
+
}
|
package/src/version.mjs
ADDED