@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,307 @@
1
+ {
2
+ "recordedAt": "2026-10-06T20:17:00Z",
3
+ "sourceCommit": "77e496f65105d5633374a65f63da990ab76ff4de",
4
+ "runUrl": "https://github.com/blucca/n8n-check/actions/runs/37524798259",
5
+ "sourceIssue": "https://github.com/n8n-io/n8n/issues/39395",
6
+ "inputs": {
7
+ "examples/propfind-body/workflow.json": "f365021255ae250b287848de1bb14c47ed2a244127957268044643ab956156e8",
8
+ "examples/propfind-body/case.json": "e37304adb5eacfad5ca773a77510505a8c3c89f1be451854d3b61e63a7029add"
9
+ },
10
+ "runs": [
11
+ {
12
+ "name": "Issue 39395 - PROPFIND transmits its XML body",
13
+ "startedAt": "2026-10-06T20:14:31.865Z",
14
+ "n8nVersion": "2.38.4",
15
+ "runnerVersion": "0.1.4",
16
+ "nodeVersion": "v26.7.0",
17
+ "network": {
18
+ "mode": "loopback-only",
19
+ "enforced": true,
20
+ "interfaces": [
21
+ "lo"
22
+ ]
23
+ },
24
+ "status": "failed",
25
+ "exitCode": 1,
26
+ "checks": [
27
+ {
28
+ "name": "n8n execution succeeds",
29
+ "passed": true,
30
+ "actual": {
31
+ "cliExitCode": 0,
32
+ "lastNode": "HTTP Request",
33
+ "executionFound": true
34
+ }
35
+ },
36
+ {
37
+ "name": "HTTP Request: output 0",
38
+ "passed": true,
39
+ "expected": [
40
+ {
41
+ "received": true
42
+ }
43
+ ],
44
+ "actual": [
45
+ {
46
+ "received": true
47
+ }
48
+ ]
49
+ },
50
+ {
51
+ "name": "mock: all requests match routes",
52
+ "passed": true,
53
+ "actual": []
54
+ },
55
+ {
56
+ "name": "mock: request limits and server",
57
+ "passed": true,
58
+ "actual": []
59
+ },
60
+ {
61
+ "name": "HTTP Request: PROPFIND /dav request count",
62
+ "passed": true,
63
+ "expected": 1,
64
+ "actual": 1
65
+ },
66
+ {
67
+ "name": "HTTP Request: PROPFIND /dav request bodies",
68
+ "passed": false,
69
+ "expected": [
70
+ "<?xml version=\"1.0\"?>\n<invalid>\n<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\" xmlns:nc=\"http://nextcloud.org/ns\">\n <d:prop>\n <d:creationdate />\n <nc:upload_time />\n <d:getlastmodified />\n <d:getetag />\n <d:getcontenttype />\n <d:resourcetype />\n <d:quota-used-bytes />\n <d:quota-available-bytes />\n <d:displayname />\n <nc:has-preview />\n <oc:id />\n <oc:fileid />\n <oc:permissions />\n <oc:size />\n <oc:favorite />\n <oc:tags />\n <oc:comments-unread />\n <oc:owner-id />\n <oc:owner-display-name />\n <oc:share-types />\n </d:prop>\n</d:propfind>"
71
+ ],
72
+ "actual": [
73
+ null
74
+ ]
75
+ }
76
+ ],
77
+ "requests": [
78
+ {
79
+ "node": "HTTP Request",
80
+ "method": "PROPFIND",
81
+ "path": "/dav",
82
+ "body": null,
83
+ "matched": true,
84
+ "status": 200,
85
+ "response": {
86
+ "received": true
87
+ }
88
+ }
89
+ ]
90
+ },
91
+ {
92
+ "name": "Issue 39395 - PROPFIND transmits its XML body",
93
+ "startedAt": "2026-10-06T20:14:26.062Z",
94
+ "n8nVersion": "2.41.7",
95
+ "runnerVersion": "0.1.4",
96
+ "nodeVersion": "v26.7.0",
97
+ "network": {
98
+ "mode": "loopback-only",
99
+ "enforced": true,
100
+ "interfaces": [
101
+ "lo"
102
+ ]
103
+ },
104
+ "status": "passed",
105
+ "exitCode": 0,
106
+ "checks": [
107
+ {
108
+ "name": "n8n execution succeeds",
109
+ "passed": true,
110
+ "actual": {
111
+ "cliExitCode": 0,
112
+ "lastNode": "HTTP Request",
113
+ "executionFound": true
114
+ }
115
+ },
116
+ {
117
+ "name": "HTTP Request: output 0",
118
+ "passed": true,
119
+ "expected": [
120
+ {
121
+ "received": true
122
+ }
123
+ ],
124
+ "actual": [
125
+ {
126
+ "received": true
127
+ }
128
+ ]
129
+ },
130
+ {
131
+ "name": "mock: all requests match routes",
132
+ "passed": true,
133
+ "actual": []
134
+ },
135
+ {
136
+ "name": "mock: request limits and server",
137
+ "passed": true,
138
+ "actual": []
139
+ },
140
+ {
141
+ "name": "HTTP Request: PROPFIND /dav request count",
142
+ "passed": true,
143
+ "expected": 1,
144
+ "actual": 1
145
+ },
146
+ {
147
+ "name": "HTTP Request: PROPFIND /dav request bodies",
148
+ "passed": true,
149
+ "expected": [
150
+ "<?xml version=\"1.0\"?>\n<invalid>\n<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\" xmlns:nc=\"http://nextcloud.org/ns\">\n <d:prop>\n <d:creationdate />\n <nc:upload_time />\n <d:getlastmodified />\n <d:getetag />\n <d:getcontenttype />\n <d:resourcetype />\n <d:quota-used-bytes />\n <d:quota-available-bytes />\n <d:displayname />\n <nc:has-preview />\n <oc:id />\n <oc:fileid />\n <oc:permissions />\n <oc:size />\n <oc:favorite />\n <oc:tags />\n <oc:comments-unread />\n <oc:owner-id />\n <oc:owner-display-name />\n <oc:share-types />\n </d:prop>\n</d:propfind>"
151
+ ],
152
+ "actual": [
153
+ "<?xml version=\"1.0\"?>\n<invalid>\n<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\" xmlns:nc=\"http://nextcloud.org/ns\">\n <d:prop>\n <d:creationdate />\n <nc:upload_time />\n <d:getlastmodified />\n <d:getetag />\n <d:getcontenttype />\n <d:resourcetype />\n <d:quota-used-bytes />\n <d:quota-available-bytes />\n <d:displayname />\n <nc:has-preview />\n <oc:id />\n <oc:fileid />\n <oc:permissions />\n <oc:size />\n <oc:favorite />\n <oc:tags />\n <oc:comments-unread />\n <oc:owner-id />\n <oc:owner-display-name />\n <oc:share-types />\n </d:prop>\n</d:propfind>"
154
+ ]
155
+ }
156
+ ],
157
+ "requests": [
158
+ {
159
+ "node": "HTTP Request",
160
+ "method": "PROPFIND",
161
+ "path": "/dav",
162
+ "body": "<?xml version=\"1.0\"?>\n<invalid>\n<d:propfind xmlns:d=\"DAV:\" xmlns:oc=\"http://owncloud.org/ns\" xmlns:nc=\"http://nextcloud.org/ns\">\n <d:prop>\n <d:creationdate />\n <nc:upload_time />\n <d:getlastmodified />\n <d:getetag />\n <d:getcontenttype />\n <d:resourcetype />\n <d:quota-used-bytes />\n <d:quota-available-bytes />\n <d:displayname />\n <nc:has-preview />\n <oc:id />\n <oc:fileid />\n <oc:permissions />\n <oc:size />\n <oc:favorite />\n <oc:tags />\n <oc:comments-unread />\n <oc:owner-id />\n <oc:owner-display-name />\n <oc:share-types />\n </d:prop>\n</d:propfind>",
163
+ "matched": true,
164
+ "status": 200,
165
+ "response": {
166
+ "received": true
167
+ }
168
+ }
169
+ ]
170
+ }
171
+ ],
172
+ "additionalRuntimeSmoke": {
173
+ "name": "Two render requests preserve object options",
174
+ "startedAt": "2026-10-06T20:12:16.116Z",
175
+ "n8nVersion": "2.37.9",
176
+ "runnerVersion": "0.1.4",
177
+ "nodeVersion": "v26.5.1",
178
+ "network": {
179
+ "mode": "loopback-only",
180
+ "enforced": true,
181
+ "interfaces": [
182
+ "lo"
183
+ ]
184
+ },
185
+ "status": "passed",
186
+ "exitCode": 0,
187
+ "checks": [
188
+ {
189
+ "name": "n8n execution succeeds",
190
+ "passed": true,
191
+ "actual": {
192
+ "cliExitCode": 0,
193
+ "lastNode": "Render",
194
+ "executionFound": true
195
+ }
196
+ },
197
+ {
198
+ "name": "Render: output 0",
199
+ "passed": true,
200
+ "expected": [
201
+ {
202
+ "id": "render-42"
203
+ },
204
+ {
205
+ "id": "render-43"
206
+ }
207
+ ],
208
+ "actual": [
209
+ {
210
+ "id": "render-42"
211
+ },
212
+ {
213
+ "id": "render-43"
214
+ }
215
+ ]
216
+ },
217
+ {
218
+ "name": "mock: all requests match routes",
219
+ "passed": true,
220
+ "actual": []
221
+ },
222
+ {
223
+ "name": "mock: request limits and server",
224
+ "passed": true,
225
+ "actual": []
226
+ },
227
+ {
228
+ "name": "Render: POST /renders request count",
229
+ "passed": true,
230
+ "expected": 2,
231
+ "actual": 2
232
+ },
233
+ {
234
+ "name": "Render: POST /renders request bodies",
235
+ "passed": true,
236
+ "expected": [
237
+ {
238
+ "shortId": 42,
239
+ "renderOptions": {
240
+ "color": "#112233",
241
+ "fontSize": 31
242
+ }
243
+ },
244
+ {
245
+ "shortId": 43,
246
+ "renderOptions": {
247
+ "color": "#aabbcc",
248
+ "fontSize": 47
249
+ }
250
+ }
251
+ ],
252
+ "actual": [
253
+ {
254
+ "shortId": 42,
255
+ "renderOptions": {
256
+ "color": "#112233",
257
+ "fontSize": 31
258
+ }
259
+ },
260
+ {
261
+ "shortId": 43,
262
+ "renderOptions": {
263
+ "color": "#aabbcc",
264
+ "fontSize": 47
265
+ }
266
+ }
267
+ ]
268
+ }
269
+ ],
270
+ "requests": [
271
+ {
272
+ "node": "Render",
273
+ "method": "POST",
274
+ "path": "/renders",
275
+ "body": {
276
+ "shortId": 42,
277
+ "renderOptions": {
278
+ "color": "#112233",
279
+ "fontSize": 31
280
+ }
281
+ },
282
+ "matched": true,
283
+ "status": 200,
284
+ "response": {
285
+ "id": "render-42"
286
+ }
287
+ },
288
+ {
289
+ "node": "Render",
290
+ "method": "POST",
291
+ "path": "/renders",
292
+ "body": {
293
+ "shortId": 43,
294
+ "renderOptions": {
295
+ "color": "#aabbcc",
296
+ "fontSize": 47
297
+ }
298
+ },
299
+ "matched": true,
300
+ "status": 200,
301
+ "response": {
302
+ "id": "render-43"
303
+ }
304
+ }
305
+ ]
306
+ }
307
+ }
@@ -0,0 +1,32 @@
1
+ # Save as .github/workflows/n8n-upgrade.yml in your own repository.
2
+ # Replace both releases and both file paths with your project's values.
3
+ name: n8n upgrade contract
4
+ on: [push, pull_request, workflow_dispatch]
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ contract:
9
+ name: n8n ${{ matrix.n8n }}
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 15
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ n8n: ['2.38.4', '2.41.7']
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ persist-credentials: false
20
+ - uses: blucca/n8n-check@v0.1.4
21
+ with:
22
+ n8n-version: ${{ matrix.n8n }}
23
+ workflow: workflows/workflow.json
24
+ case: tests/workflow.case.json
25
+ out: results/contract
26
+ - uses: actions/upload-artifact@v4
27
+ if: always()
28
+ with:
29
+ name: n8n-contract-${{ matrix.n8n }}
30
+ path: |
31
+ results/contract/report.json
32
+ results/contract/junit.xml
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@blucca/n8n-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.7",
4
+ "description": "Run n8n workflow regression checks with fixtures, local HTTP mocks and JUnit reports.",
5
+ "type": "module",
6
+ "bin": {
7
+ "n8n-check": "bin/n8n-check.mjs"
8
+ },
9
+ "exports": "./src/runner.mjs",
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "examples",
14
+ "Dockerfile",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test test/*.test.mjs"
20
+ },
21
+ "engines": {
22
+ "node": ">=24"
23
+ },
24
+ "license": "MIT",
25
+ "author": "blucca",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/blucca/n8n-check.git"
29
+ },
30
+ "homepage": "https://github.com/blucca/n8n-check#readme",
31
+ "bugs": "https://github.com/blucca/n8n-check/issues",
32
+ "keywords": [
33
+ "n8n",
34
+ "testing",
35
+ "workflow",
36
+ "regression",
37
+ "junit",
38
+ "mock"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
8
+ const cell = value => html(value).replace(/\|/g, '&#124;').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
+ }