@blucca/n8n-check 0.0.0-stage → 0.1.7

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.
Files changed (78) hide show
  1. package/Dockerfile +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +323 -2
  4. package/bin/n8n-check.mjs +62 -0
  5. package/examples/agregado-enrichment/README.md +68 -0
  6. package/examples/agregado-enrichment/n8n-synthetic +11 -0
  7. package/examples/agregado-enrichment/observed-results.json +806 -0
  8. package/examples/agregado-enrichment/prepare.mjs +39 -0
  9. package/examples/identity-projection/README.md +39 -0
  10. package/examples/identity-projection/case.json +33 -0
  11. package/examples/identity-projection/observed-results.json +117 -0
  12. package/examples/identity-projection/workflow.json +71 -0
  13. package/examples/identity-projection/wrong-id.json +71 -0
  14. package/examples/morsof-invoice-history/README.md +77 -0
  15. package/examples/morsof-invoice-history/UPSTREAM-LICENSE +21 -0
  16. package/examples/morsof-invoice-history/n8n-datatable.cjs +28 -0
  17. package/examples/morsof-invoice-history/observed-results.json +113 -0
  18. package/examples/morsof-invoice-history/prepare.mjs +57 -0
  19. package/examples/morsof-invoice-history/replay.mjs +35 -0
  20. package/examples/morsof-invoice-history/run.sh +19 -0
  21. package/examples/object-body/broken.json +74 -0
  22. package/examples/object-body/case.json +81 -0
  23. package/examples/object-body/fixed.json +74 -0
  24. package/examples/object-body/observed-results.json +449 -0
  25. package/examples/object-body/retry-case.json +112 -0
  26. package/examples/object-body/retry.json +77 -0
  27. package/examples/propfind-body/README.md +75 -0
  28. package/examples/propfind-body/case.json +51 -0
  29. package/examples/propfind-body/no-body-control.json +96 -0
  30. package/examples/propfind-body/observed-results.json +307 -0
  31. package/examples/propfind-body/workflow.json +96 -0
  32. package/examples/raw-response/README.md +71 -0
  33. package/examples/raw-response/file-case.json +54 -0
  34. package/examples/raw-response/json-body.json +58 -0
  35. package/examples/raw-response/json-case.json +52 -0
  36. package/examples/raw-response/observed-results.json +383 -0
  37. package/examples/raw-response/raw-file.json +88 -0
  38. package/examples/raw-response/raw-json.json +58 -0
  39. package/examples/raw-response/raw-text.json +58 -0
  40. package/examples/raw-response/text-case.json +50 -0
  41. package/examples/retry-isolation/README.md +71 -0
  42. package/examples/retry-isolation/direct-case.json +112 -0
  43. package/examples/retry-isolation/direct.json +101 -0
  44. package/examples/retry-isolation/editor-template.json +168 -0
  45. package/examples/retry-isolation/http-batching-case.json +112 -0
  46. package/examples/retry-isolation/http-batching.json +107 -0
  47. package/examples/retry-isolation/loop-case.json +117 -0
  48. package/examples/retry-isolation/loop.json +133 -0
  49. package/examples/retry-isolation/observed-results.json +155 -0
  50. package/examples/silent-filter/README.md +57 -0
  51. package/examples/silent-filter/broken.json +96 -0
  52. package/examples/silent-filter/case.json +120 -0
  53. package/examples/silent-filter/fixed.json +96 -0
  54. package/examples/silent-filter/observed-results.json +273 -0
  55. package/examples/stop-after/README.md +66 -0
  56. package/examples/stop-after/broken.json +134 -0
  57. package/examples/stop-after/case.json +34 -0
  58. package/examples/stop-after/fixed.json +134 -0
  59. package/examples/stop-after/observed-results.json +162 -0
  60. package/examples/vapi-tool-contract/README.md +67 -0
  61. package/examples/vapi-tool-contract/backend-503.case.json +113 -0
  62. package/examples/vapi-tool-contract/empty-orders.case.json +113 -0
  63. package/examples/vapi-tool-contract/observed-results.json +234 -0
  64. package/examples/vapi-tool-contract/success.case.json +118 -0
  65. package/examples/vapi-tool-contract/workflow.json +143 -0
  66. package/examples/vapi-tool-contract/wrong-id.json +143 -0
  67. package/examples/version-matrix/README.md +65 -0
  68. package/examples/version-matrix/observed-results.json +307 -0
  69. package/examples/version-matrix/upgrade-check.yml +32 -0
  70. package/package.json +41 -4
  71. package/src/action.mjs +108 -0
  72. package/src/case-draft.mjs +163 -0
  73. package/src/local-bundle.mjs +154 -0
  74. package/src/mock.mjs +72 -0
  75. package/src/report.mjs +73 -0
  76. package/src/runner.mjs +111 -0
  77. package/src/version.mjs +3 -0
  78. package/src/workflow.mjs +140 -0
@@ -0,0 +1,154 @@
1
+ // Portable browser module: UTF-8, uncompressed ZIP; no network or dependencies.
2
+ export const RUNNER_URL = 'https://github.com/blucca/n8n-check/releases/download/v0.1.6/blucca-n8n-check-0.1.6.tgz';
3
+ export const RUNNER_SHA256 = '8ac4b07df6fc6eb83726793137e5ad3eb7e97f5bcca243a97cc6c82e6a5ed53d';
4
+ const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
5
+
6
+ export function localBundleFiles({ workflow, caseFile, n8nVersion = '2.41.7' }) {
7
+ if (typeof n8nVersion !== 'string' || !versionPattern.test(n8nVersion)) throw new Error('Choose an exact n8n release, such as 2.41.7.');
8
+ for (const [name, value] of Object.entries({ workflow, caseFile })) {
9
+ if (!value || typeof value !== 'object') throw new Error(`${name}: supply a JSON object.`);
10
+ }
11
+ return {
12
+ 'workflow.json': JSON.stringify(workflow, null, 2) + '\n',
13
+ 'case.json': JSON.stringify(caseFile, null, 2) + '\n',
14
+ 'run.sh': `#!/bin/sh
15
+ set -eu
16
+ # Setup errors return 2; the final runner preserves its 0/1/2 result.
17
+ trap 'code=$?; if [ "$code" -ne 0 ]; then exit 2; fi' 0
18
+ cd -- "$(dirname -- "$0")"
19
+ ROOT=$(pwd -P)
20
+ case "$ROOT" in
21
+ *','* | *':'* | *'\n'*) echo 'Extract into a directory whose path uses letters, spaces, numbers, dashes or underscores.' >&2; exit 2 ;;
22
+ esac
23
+ mkdir -p .cache results
24
+ rm -f results/report.json results/junit.xml
25
+ N8N_VERSION=\${N8N_VERSION:-${n8nVersion}}
26
+ case "$N8N_VERSION" in *'\n'*) echo 'N8N_VERSION: use a single exact release.' >&2; exit 2 ;; esac
27
+ printf '%s\\n' "$N8N_VERSION" | grep -Eq '^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' || {
28
+ echo 'N8N_VERSION: choose an exact release, such as 2.41.7.' >&2; exit 2;
29
+ }
30
+ command -v docker >/dev/null 2>&1 || { echo 'Install and start Docker, then run sh run.sh again.' >&2; exit 2; }
31
+ [ -f workflow.json ] && [ -f case.json ] || { echo 'Keep workflow.json and case.json beside run.sh.' >&2; exit 2; }
32
+ BASE="ghcr.io/n8n-io/n8n:$N8N_VERSION"
33
+ IMAGE="n8n-check-local:0.1.6-$N8N_VERSION"
34
+ # Docker obtains the official image on first use. This bootstrap sees only .cache.
35
+ # Its Node runtime downloads and verifies the fixed runner release on Mac/Linux.
36
+ docker run --rm -i --user "$(id -u):$(id -g)" \\
37
+ --mount "type=bind,source=$ROOT/.cache,target=/cache" \\
38
+ --entrypoint node "$BASE" --input-type=module - <<'NODE'
39
+ import { readFile, writeFile, rename } from 'node:fs/promises';
40
+ import { createHash } from 'node:crypto';
41
+ const file = '/cache/runner.tgz';
42
+ const expected = '${RUNNER_SHA256}';
43
+ const valid = data => createHash('sha256').update(data).digest('hex') === expected;
44
+ let cached;
45
+ try { cached = await readFile(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
46
+ if (!cached || !valid(cached)) {
47
+ console.log('Downloading n8n-check v0.1.6...');
48
+ const response = await fetch('${RUNNER_URL}', { signal: AbortSignal.timeout(120000) });
49
+ if (!response.ok) throw new Error('Runner download HTTP ' + response.status);
50
+ const data = Buffer.from(await response.arrayBuffer());
51
+ if (!valid(data)) throw new Error('Runner SHA256 mismatch; download stopped.');
52
+ await writeFile(file + '.partial', data);
53
+ await rename(file + '.partial', file);
54
+ }
55
+ console.log('Runner SHA256 verified.');
56
+ NODE
57
+ cat > .cache/Dockerfile <<'DOCKERFILE'
58
+ ARG N8N_VERSION
59
+ FROM ghcr.io/n8n-io/n8n:\${N8N_VERSION}
60
+ USER root
61
+ ADD runner.tgz /opt/n8n-check/
62
+ USER node
63
+ WORKDIR /work
64
+ ENTRYPOINT ["node", "/opt/n8n-check/package/bin/n8n-check.mjs"]
65
+ DOCKERFILE
66
+ # Only the verified archive and generated Dockerfile enter the build context.
67
+ printf '*\\n!runner.tgz\\n!Dockerfile\\n' > .cache/.dockerignore
68
+ docker build --network none --build-arg "N8N_VERSION=$N8N_VERSION" --tag "$IMAGE" .cache
69
+ printf '\\nRunning checks with n8n %s; results: %s/results\\n' "$N8N_VERSION" "$ROOT"
70
+ trap - 0
71
+ set +e
72
+ docker run --rm --network none --user "$(id -u):$(id -g)" \\
73
+ --mount "type=bind,source=$ROOT/workflow.json,target=/inputs/workflow.json,readonly" \\
74
+ --mount "type=bind,source=$ROOT/case.json,target=/inputs/case.json,readonly" \\
75
+ --mount "type=bind,source=$ROOT/results,target=/results" \\
76
+ "$IMAGE" /inputs/workflow.json /inputs/case.json --out /results
77
+ CODE=$?
78
+ case "$CODE" in 0|1|2) exit "$CODE" ;; *) exit 2 ;; esac
79
+ `,
80
+ 'README.md': `# Run your n8n regression check locally
81
+
82
+ 1. Install and start Docker (Docker Desktop on macOS; Docker Engine/Desktop on Linux).
83
+ 2. Extract all four files into a dedicated directory.
84
+ 3. Open a terminal in that directory and run:
85
+
86
+ \`\`\`sh
87
+ sh run.sh
88
+ \`\`\`
89
+
90
+ Selected n8n release: **${n8nVersion}**. To test another exact release:
91
+
92
+ \`\`\`sh
93
+ N8N_VERSION=2.41.7 sh run.sh
94
+ \`\`\`
95
+
96
+ The first run downloads the official n8n image and n8n-check v0.1.6. Docker supplies Node. The runner archive stays in .cache and its SHA256 is checked on every run. Subsequent runs reuse Docker layers and the verified archive. An uncached n8n version needs internet access. Choose a published version supported by your workflow's nodes and the runner (Node 24+).
97
+
98
+ The workflow executes with Docker --network none: HTTP mocks use loopback, and external networking is disabled. workflow.json and case.json are mounted read-only; results/ is writable. Execution uses your local UID/GID and a fresh n8n database. Use trusted workflows and synthetic fixtures; workflow code executes in the container. Inline values and assertion differences can appear in local reports.
99
+
100
+ Results: results/report.json and results/junit.xml. Exit codes: 0 passed, 1 regression, 2 setup error. Each attempt clears the previous two report files; setup errors appear in the terminal. Save reports before rerunning when comparing versions.
101
+
102
+ Keep Docker running and allow it access to this directory. Windows users can run the bundle inside WSL with Docker integration. Directory paths may contain spaces; choose paths free of commas, colons and line breaks for Docker bind mounts.
103
+
104
+ Edit case.json to add fixtures, HTTP contracts or assertions, then rerun the same command. The bundle contains the original workflow export and the case selected in the builder; review their contents before sharing.
105
+
106
+ Runner: ${RUNNER_URL}
107
+ SHA256: ${RUNNER_SHA256}
108
+ Documentation: https://github.com/blucca/n8n-check
109
+ `,
110
+ };
111
+ }
112
+
113
+ function crc32(bytes) {
114
+ let crc = 0xffffffff;
115
+ for (const byte of bytes) {
116
+ crc ^= byte;
117
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
118
+ }
119
+ return (crc ^ 0xffffffff) >>> 0;
120
+ }
121
+
122
+ export function buildLocalBundle(options) {
123
+ const encoder = new TextEncoder();
124
+ const files = Object.entries(localBundleFiles(options)).map(([name, text]) => ({ name: encoder.encode(name), data: encoder.encode(text) }));
125
+ const size = files.reduce((sum, file) => sum + 30 + file.name.length + file.data.length + 46 + file.name.length, 22);
126
+ if (size > 0xffffffff) throw new Error('Bundle exceeds the ZIP size limit.');
127
+ const zip = new Uint8Array(size);
128
+ const view = new DataView(zip.buffer);
129
+ let offset = 0;
130
+ const u16 = (at, value) => view.setUint16(at, value, true);
131
+ const u32 = (at, value) => view.setUint32(at, value, true);
132
+ for (const file of files) {
133
+ file.offset = offset;
134
+ file.crc = crc32(file.data);
135
+ u32(offset, 0x04034b50); u16(offset + 4, 20); u16(offset + 6, 0x800);
136
+ u16(offset + 12, 0x21); // Fixed 1980-01-01, deterministic exports.
137
+ u32(offset + 14, file.crc); u32(offset + 18, file.data.length); u32(offset + 22, file.data.length);
138
+ u16(offset + 26, file.name.length);
139
+ zip.set(file.name, offset + 30); zip.set(file.data, offset + 30 + file.name.length);
140
+ offset += 30 + file.name.length + file.data.length;
141
+ }
142
+ const centralOffset = offset;
143
+ for (const file of files) {
144
+ u32(offset, 0x02014b50); u16(offset + 4, 20); u16(offset + 6, 20); u16(offset + 8, 0x800);
145
+ u16(offset + 14, 0x21); u32(offset + 16, file.crc);
146
+ u32(offset + 20, file.data.length); u32(offset + 24, file.data.length);
147
+ u16(offset + 28, file.name.length); u32(offset + 42, file.offset);
148
+ zip.set(file.name, offset + 46);
149
+ offset += 46 + file.name.length;
150
+ }
151
+ u32(offset, 0x06054b50); u16(offset + 8, files.length); u16(offset + 10, files.length);
152
+ u32(offset + 12, offset - centralOffset); u32(offset + 16, centralOffset);
153
+ return zip;
154
+ }
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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[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
+ }
@@ -0,0 +1,3 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ export const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -0,0 +1,140 @@
1
+ const randomUUID = () => globalThis.crypto.randomUUID();
2
+
3
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value);
4
+ const ensure = (condition, message) => { if (!condition) throw new Error(message); };
5
+ export function validateCase(spec) {
6
+ ensure(plain(spec) && spec.version === 1, 'Case version must be 1');
7
+ ensure(typeof spec.name === 'string' && spec.name.length, 'Case name is required');
8
+ ensure(plain(spec.input) && typeof spec.input.node === 'string', 'input.node is required');
9
+ ensure(spec.stopAfter === undefined || (typeof spec.stopAfter === 'string' && spec.stopAfter.length > 0), 'stopAfter must be a non-empty node name');
10
+ ensure(Array.isArray(spec.input.items) && spec.input.items.every(plain), 'input.items must be an array of JSON objects');
11
+ ensure(Array.isArray(spec.mocks), 'mocks must be an array (use [] for local-only workflows)');
12
+ const names = new Set();
13
+ for (const mock of spec.mocks) {
14
+ ensure(typeof mock.node === 'string' && !names.has(mock.node), 'Each mock must name a unique HTTP Request node');
15
+ names.add(mock.node);
16
+ ensure(typeof mock.url === 'string' && mock.url.startsWith('/') && !mock.url.startsWith('//'), `Mock ${mock.node}: url must be a local path, optionally with n8n {{ expressions }}`);
17
+ ensure(Array.isArray(mock.routes) && mock.routes.length, `Mock ${mock.node}: routes are required`);
18
+ const routes = new Set();
19
+ for (const route of mock.routes) {
20
+ ensure(typeof route.method === 'string' && /^[A-Z]+$/.test(route.method), 'Route method must be uppercase');
21
+ ensure(typeof route.path === 'string' && route.path.startsWith('/'), 'Route path must start with /');
22
+ const key = `${route.method} ${route.path}`;
23
+ ensure(!routes.has(key), `Duplicate route: ${key}`); routes.add(key);
24
+ ensure(Array.isArray(route.responses) && route.responses.length, `Route ${key}: responses are required`);
25
+ for (const response of route.responses) {
26
+ ensure(plain(response) && Number.isInteger(response.status) && response.status >= 200 && response.status <= 599, `Route ${key}: response status must be 200–599`);
27
+ ensure(Object.hasOwn(response, 'json'), `Route ${key}: response.json is required`);
28
+ ensure(response.headers === undefined || (plain(response.headers) && Object.values(response.headers).every(v => typeof v === 'string')), `Route ${key}: headers must contain strings`);
29
+ }
30
+ ensure(plain(route.expect) && Number.isInteger(route.expect.count) && route.expect.count >= 0, `Route ${key}: expect.count must be a non-negative integer`);
31
+ ensure(route.expect.bodies === undefined || (Array.isArray(route.expect.bodies) && route.expect.bodies.length === route.expect.count), `Route ${key}: expect.bodies length must match expect.count`);
32
+ }
33
+ }
34
+ ensure(Array.isArray(spec.assertions) && spec.assertions.length, 'At least one output assertion is required');
35
+ for (const assertion of spec.assertions) {
36
+ ensure(plain(assertion) && typeof assertion.node === 'string' && assertion.node.length > 0, 'Each assertion needs a non-empty node name');
37
+ ensure(assertion.equals !== undefined || assertion.count !== undefined, 'Each assertion needs equals or count');
38
+ ensure(assertion.equals === undefined || Array.isArray(assertion.equals), 'assertion.equals must be an array');
39
+ ensure(assertion.count === undefined || (Number.isInteger(assertion.count) && assertion.count >= 0), 'assertion.count must be a non-negative integer');
40
+ ensure(assertion.pluck === undefined || (typeof assertion.pluck === 'string' && assertion.pluck.split('.').every(part => part.length > 0)), 'assertion.pluck must be a non-empty dot-separated path');
41
+ ensure(assertion.pluck === undefined || Array.isArray(assertion.equals), 'assertion.pluck requires equals');
42
+ ensure(assertion.output === undefined || (Number.isInteger(assertion.output) && assertion.output >= 0), 'assertion.output must be a non-negative integer');
43
+ }
44
+ ensure(spec.timeoutMs === undefined || (Number.isInteger(spec.timeoutMs) && spec.timeoutMs >= 1000 && spec.timeoutMs <= 600000), 'timeoutMs must be 1000–600000');
45
+ ensure(spec.maxRequests === undefined || (Number.isInteger(spec.maxRequests) && spec.maxRequests > 0 && spec.maxRequests <= 10000), 'maxRequests must be 1–10000');
46
+ return spec;
47
+ }
48
+
49
+ const edges = connections => Object.entries(connections ?? {}).flatMap(([from, types]) =>
50
+ Object.entries(types).flatMap(([type, branches]) => branches.flatMap(branch => branch.map(edge => ({ from, type, ...edge })))));
51
+
52
+ /** Select the execution graph; sibling branches remain reachable after a stop. */
53
+ export function executionSlice(source, inputNode, stopAfter) {
54
+ const byName = new Map(source.nodes.map(node => [node.name, node]));
55
+ ensure(byName.has(inputNode), `Input node not found: ${inputNode}`);
56
+ const links = edges(source.connections);
57
+ const reachable = (start, stop) => {
58
+ const found = new Set([start]);
59
+ for (const name of found) if (name !== stop) for (const edge of links) if (edge.from === name) found.add(edge.node);
60
+ return found;
61
+ };
62
+ if (stopAfter !== undefined) {
63
+ ensure(typeof stopAfter === 'string' && stopAfter.length > 0, 'stopAfter must be a non-empty node name');
64
+ ensure(byName.has(stopAfter), `stopAfter node not found: ${stopAfter}`);
65
+ ensure(reachable(inputNode).has(stopAfter), `stopAfter node "${stopAfter}" must be reachable from input "${inputNode}".`);
66
+ const afterStop = new Set(links.filter(edge => edge.from === stopAfter).map(edge => edge.node));
67
+ for (const name of afterStop) for (const edge of links) if (edge.from === name) afterStop.add(edge.node);
68
+ ensure(!afterStop.has(stopAfter), `stopAfter node "${stopAfter}" lies inside a loop. Choose a node after the loop's completed output.`);
69
+ }
70
+ const kept = reachable(inputNode, stopAfter);
71
+ for (const edge of links) if (kept.has(edge.from) && edge.from !== stopAfter) {
72
+ ensure(byName.has(edge.node), `Connection from ${edge.from} references missing node: ${edge.node}`);
73
+ }
74
+ return { kept, links };
75
+ }
76
+
77
+ export function prepareWorkflow(source, spec, mockBase) {
78
+ validateCase(spec);
79
+ if (Array.isArray(source)) {
80
+ ensure(source.length === 1, 'Supply one workflow export; received an array with multiple workflows');
81
+ source = source[0];
82
+ }
83
+ ensure(plain(source) && Array.isArray(source.nodes) && plain(source.connections), 'Expected an n8n workflow export with nodes and connections');
84
+ const workflow = structuredClone(source);
85
+ const byName = new Map(workflow.nodes.map(node => [node.name, node]));
86
+ ensure(byName.size === workflow.nodes.length, 'Workflow node names must be unique');
87
+ const { kept, links } = executionSlice(workflow, spec.input.node, spec.stopAfter);
88
+ for (const edge of links) {
89
+ if (kept.has(edge.node) && edge.node !== spec.input.node && !kept.has(edge.from)) {
90
+ throw new Error(`Slice needs incoming ${edge.type} connection from "${edge.from}" to "${edge.node}". Move input.node earlier or provide a self-contained workflow slice.`);
91
+ }
92
+ }
93
+ const changes = [{ node: spec.input.node, change: 'replace with fixture JSON items' }];
94
+ if (spec.stopAfter !== undefined) changes.push({ node: spec.stopAfter, change: 'stop this branch after the node; remove outgoing connections; retain independently reachable branches' });
95
+ const fixture = byName.get(spec.input.node);
96
+ const fixtureNode = {
97
+ id: fixture.id || randomUUID(), name: fixture.name, position: fixture.position || [0, 0],
98
+ type: 'n8n-nodes-base.code', typeVersion: 2,
99
+ parameters: { mode: 'runOnceForAllItems', jsCode: `return JSON.parse(${JSON.stringify(JSON.stringify(spec.input.items))}).map(json => ({json}));` },
100
+ };
101
+ workflow.nodes = workflow.nodes.filter(node => kept.has(node.name)).map(node => node.name === fixture.name ? fixtureNode : node);
102
+ workflow.connections = Object.fromEntries(Object.entries(workflow.connections).filter(([name]) => kept.has(name) && name !== spec.stopAfter).map(([name, types]) => [name,
103
+ Object.fromEntries(Object.entries(types).map(([type, branches]) => [type, branches.map(branch => branch.filter(edge => kept.has(edge.node)))])),
104
+ ]));
105
+ for (const edge of links) ensure(!(kept.has(edge.from) && edge.node === fixture.name), `Input node "${fixture.name}" lies inside a loop. Choose a fixture boundary before the loop.`);
106
+ let triggerName = 'n8n-check start';
107
+ while (byName.has(triggerName)) triggerName += '_';
108
+ workflow.nodes.unshift({ id: randomUUID(), name: triggerName, type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [-400, 0], parameters: {} });
109
+ workflow.connections[triggerName] = { main: [[{ node: fixture.name, type: 'main', index: 0 }]] };
110
+ const mockNames = new Set(spec.mocks.map(mock => mock.node));
111
+ for (let index = 0; index < spec.mocks.length; index++) {
112
+ const mock = spec.mocks[index];
113
+ const node = workflow.nodes.find(n => n.name === mock.node);
114
+ ensure(node?.type === 'n8n-nodes-base.httpRequest', `Mock node must be an HTTP Request node inside the slice: ${mock.node}`);
115
+ delete node.credentials;
116
+ node.parameters.authentication = 'none';
117
+ delete node.parameters.genericAuthType;
118
+ delete node.parameters.nodeCredentialType;
119
+ node.parameters.url = `${mock.url.includes('{{') ? '=' : ''}${mockBase}/${index}${mock.url}`;
120
+ changes.push({ node: node.name, change: 'route URL to local mock; remove credential references; authentication=none' });
121
+ }
122
+ for (const node of workflow.nodes) {
123
+ ensure(!node.credentials || !Object.keys(node.credentials).length, `Node "${node.name}" uses credentials. Replace this boundary with fixture input or an HTTP mock.`);
124
+ ensure(node.type !== 'n8n-nodes-base.httpRequest' || mockNames.has(node.name), `HTTP Request node "${node.name}" needs a mock entry`);
125
+ for (const match of JSON.stringify(node.parameters).matchAll(/(?:\$\(|\$items\(|\$node\[)\s*(?:\\?")([^"\\]+)(?:\\?")|(?:\$\(|\$items\(|\$node\[)\s*'([^']+)'/g)) {
126
+ const dependency = match[1] ?? match[2];
127
+ ensure(kept.has(dependency), `Node "${node.name}" references "${dependency}" outside the slice. Move input.node earlier or provide a self-contained workflow slice.`);
128
+ }
129
+ }
130
+ for (const assertion of spec.assertions) ensure(kept.has(assertion.node), `Assertion node outside the slice: ${assertion.node}`);
131
+ // Build a fresh workflow record: ownership, pinData, staticData and production settings stay in the source.
132
+ return {
133
+ workflow: {
134
+ id: randomUUID().replaceAll('-', '').slice(0, 16), name: `n8n-check: ${spec.name}`, active: false,
135
+ nodes: workflow.nodes, connections: workflow.connections,
136
+ settings: { ...(source.settings?.timezone ? { timezone: source.settings.timezone } : {}), executionOrder: source.settings?.executionOrder || 'v1', executionTimeout: Math.ceil((spec.timeoutMs ?? 60000) / 1000), saveExecutionProgress: true },
137
+ },
138
+ changes, omittedNodes: source.nodes.filter(node => !kept.has(node.name)).map(node => node.name),
139
+ };
140
+ }