@blucca/n8n-check 0.1.6 → 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.
package/README.md CHANGED
@@ -6,9 +6,11 @@ Give it a workflow export, fixture inputs and HTTP mock responses. It runs the a
6
6
 
7
7
  ## Build a check for your own workflow
8
8
 
9
- **[Open the local case builder](https://blucca.github.io/n8n-check/)** — import an n8n export, choose the fixture boundary and the output to preserve, then download a case and GitHub Actions file.
9
+ **[Open the local case builder](https://blucca.github.io/n8n-check/)** — import an n8n export, choose the fixture boundary and the output to preserve, then download a **local run pack** or GitHub Actions file.
10
10
 
11
- Exported JSON pins can prefill the input and expected items. The builder lists the nodes in the slice and identifies missing fixtures, HTTP mock contracts, and structural dependencies before you install a runtime. File contents stay in your browser tab; review the snapshots against the behavior you want to preserve. A completed case runs with the released **v0.1.6** CLI or Action. Select **Full JSON items** for a complete snapshot, **Selected field + item count** for stable IDs with changing timestamps, or **Item count** for output volume. Field paths start at the n8n item, such as `json.id`; expected values preserve order and duplicates.
11
+ **Have Docker? Download → unzip → `sh run.sh`.** The pack includes your workflow, editable case, and a pinned runner script. The first run downloads n8n and verifies the v0.1.6 runner against its SHA-256; subsequent runs reuse the local cache. Execution uses a loopback-only container with read-only input mounts, and writes `results/report.json` and `results/junit.xml`. Requires Docker with Linux containers and a shell on Linux, macOS, or WSL2. Your workflow and fixture files stay local.
12
+
13
+ Exported JSON pins can prefill the input and expected items. The builder lists the nodes in the slice and identifies missing fixtures, HTTP mock contracts, and structural dependencies before you install a runtime. File contents stay in your browser tab; review the snapshots against the behavior you want to preserve. A completed case runs with the released **v0.1.7** CLI or Action. Select **Full JSON items** for a complete snapshot, **Selected field + item count** for stable IDs with changing timestamps, or **Item count** for output volume. Field paths start at the n8n item, such as `json.id`; expected values preserve order and duplicates.
12
14
 
13
15
  For a local-first example, choose **Try the nine-row example**. Its synthetic pins create a case that passes with the fixed workflow and catches the obsolete filter in the broken workflow. The page prepares files; the n8n engine produces the execution results.
14
16
 
@@ -56,6 +58,8 @@ Each case checks request counts, bodies, and final outputs with the actual engin
56
58
 
57
59
  **[Replay invoice-history checks against real Data Tables](examples/morsof-invoice-history/):** seven batch cases from Morsof’s invoice follow-up template, plus a second execution that preserves stored IDs and preparation timestamps. Includes a version-pinned Data Table CLI adapter.
58
60
 
61
+ **[Check a Vapi tool-call response before a client demo](examples/vapi-tool-contract/):** two tool calls, two distinct result IDs. Run successful, empty, and backend-503 responses through the real n8n engine; the same contract catches a mislabelled tool result. Includes a fork-and-run Action with synthetic API responses.
62
+
59
63
  ## Add a check to GitHub Actions
60
64
 
61
65
  **One workflow file. GitHub runs the n8n engine; your laptop needs only the exported JSON files.**
@@ -75,7 +79,7 @@ jobs:
75
79
  - uses: actions/checkout@v7
76
80
  with:
77
81
  persist-credentials: false
78
- - uses: blucca/n8n-check@v0.1.6
82
+ - uses: blucca/n8n-check@v0.1.7
79
83
  with:
80
84
  workflow: workflows/render.json
81
85
  case: tests/render.case.json
@@ -103,7 +107,7 @@ Replace `workflow` and `case` with paths in your repository. The action builds t
103
107
  Set `n8n-version` to an exact official image release:
104
108
 
105
109
  ```yaml
106
- - uses: blucca/n8n-check@v0.1.6
110
+ - uses: blucca/n8n-check@v0.1.7
107
111
  with:
108
112
  n8n-version: '2.38.4'
109
113
  workflow: workflows/render.json
@@ -120,7 +124,7 @@ For local Docker runs, use `docker build --build-arg N8N_VERSION=2.38.4 -t n8n-c
120
124
  With Git and Docker installed:
121
125
 
122
126
  ```sh
123
- git clone --branch v0.1.6 https://github.com/blucca/n8n-check.git
127
+ git clone --branch v0.1.7 https://github.com/blucca/n8n-check.git
124
128
  cd n8n-check
125
129
  docker build -t n8n-check .
126
130
 
@@ -148,12 +152,12 @@ The complete change in `Render.parameters.jsonBody`:
148
152
 
149
153
  Both example inputs use an object for `styling`. The broken expression converts that object to `[object Object]`; the fixed expression preserves the object. The workflow export remains unchanged on disk.
150
154
 
151
- ## Install the CLI from GitHub
155
+ ## Install the CLI from npm
152
156
 
153
157
  Node.js 24+ and an installed n8n CLI are required for this route. The runner has zero npm dependencies; n8n is installed separately under its own license.
154
158
 
155
159
  ```sh
156
- npm install --global https://github.com/blucca/n8n-check/releases/download/v0.1.6/blucca-n8n-check-0.1.6.tgz
160
+ npm install --global @blucca/n8n-check
157
161
  n8n-check --help
158
162
 
159
163
  # Trusted local development, using your existing n8n installation:
@@ -163,7 +167,7 @@ n8n-check workflow.json case.json --allow-network --out results
163
167
  n8n-check workflow.json case.json --n8n /path/to/n8n --allow-network
164
168
  ```
165
169
 
166
- The GitHub release package works independently of npm registry availability. Docker is the simplest route to a pinned runtime and loopback-only networking. Default runtime: **n8n 2.41.7**; local CLI installation: Node.js **24+**. The [version matrix](examples/version-matrix/) also exercises 2.38.4, and the JSON request/output case passes on 2.37.9. The CLI uses the official [workflow import and execute commands](https://docs.n8n.io/hosting/cli-commands/).
170
+ The official package is [@blucca/n8n-check on npm](https://www.npmjs.com/package/@blucca/n8n-check). For a reproducible CLI install, use `npm install --global @blucca/n8n-check@0.1.7`. A standalone `.tgz` is also available in the [GitHub release](https://github.com/blucca/n8n-check/releases/tag/v0.1.7). Docker is the simplest route to a pinned runtime and loopback-only networking. Default runtime: **n8n 2.41.7**; local CLI installation: Node.js **24+**. The [version matrix](examples/version-matrix/) also exercises 2.38.4, and the JSON request/output case passes on 2.37.9. The CLI uses the official [workflow import and execute commands](https://docs.n8n.io/hosting/cli-commands/).
167
171
 
168
172
  ## Your first case
169
173
 
@@ -174,7 +178,7 @@ For your own export, replace **all three node-name fields** below: `input.node`
174
178
  Save the JSON below as `case.json`. If you installed the CLI globally, download the example workflow into the same directory, then run:
175
179
 
176
180
  ```sh
177
- curl --fail --location https://raw.githubusercontent.com/blucca/n8n-check/v0.1.6/examples/object-body/fixed.json \
181
+ curl --fail --location https://raw.githubusercontent.com/blucca/n8n-check/v0.1.7/examples/object-body/fixed.json \
178
182
  --output first-case.workflow.json
179
183
  n8n-check first-case.workflow.json case.json --allow-network --out results/first-case
180
184
  ```
@@ -0,0 +1,67 @@
1
+ # Vapi tool calls: preserve the caller, surface the backend failure
2
+
3
+ **Two tool calls in one request. Two different customers. Every response keeps its own `toolCallId`.**
4
+
5
+ This synthetic backend check looks up customer orders through a real n8n HTTP Request node. It checks outgoing customer/ID pairs, response serialization, and the complete Vapi `results` envelope.
6
+
7
+ | Scenario | Backend responses | Expected tool response |
8
+ | --- | --- | --- |
9
+ | `success` | Two HTTP 200 responses with different orders | Two distinct IDs and the correct serialized order data |
10
+ | `backend-503` | Customer A succeeds; customer B returns 503 | A retains its result; B receives an explicit `error` string |
11
+ | `empty-orders` | Customer B returns `{"orders":[]}` | B receives the successful string `"{\"orders\":[]}"` |
12
+ | `wrong-id.json` with the unchanged success case | Both backend requests succeed | **FAIL**: the second result incorrectly repeats the first tool-call ID |
13
+
14
+ ## The workflow
15
+
16
+ `Vapi webhook → Split tool calls → Lookup orders → Build Vapi response → Respond to Vapi`
17
+
18
+ - [`workflow.json`](workflow.json) is an importable synthetic workflow with a Webhook input and Respond to Webhook configured for HTTP 200.
19
+ - Each case injects the Webhook node's `body.message` JSON and stops after **Build Vapi response**. The real Split, HTTP Request, and response-building nodes execute; the fixture replaces webhook delivery and the response node sits beyond this test boundary.
20
+ - HTTP Request uses **Include Response Headers and Status** and **Never Error** to preserve the backend's status for response mapping. Local HTTP mocks return the declared 200/503 responses.
21
+ - Response construction follows n8n item pairing back to each split tool call. Each route checks exactly one request and its exact customer/`toolCallId` body.
22
+ - Success uses `JSON.stringify` for a single-line `result`. HTTP 503 maps to `error`, preserving the sibling success. Empty order data stays an explicit successful result.
23
+
24
+ ## Run from your browser
25
+
26
+ 1. [Fork n8n-check](https://github.com/blucca/n8n-check/fork), then enable **Actions** in your fork.
27
+ 2. Open **Try a Vapi tool-call contract → Run workflow**.
28
+ 3. Choose `backend-503` with `workflow` to check a successful result beside an explicit tool error. Choose `success` with `wrong-id` to see the regression turn the job red.
29
+ 4. Read the check summary; download `vapi-tool-contract-results` for JSON/JUnit.
30
+
31
+ This Action runs the selected case with the released **v0.1.7** runner and **n8n 2.41.7**, using loopback-only Docker networking and synthetic data. GitHub Actions usage follows your account plan. [Action source](../../.github/workflows/try-vapi-tool-contract.yml).
32
+
33
+ ## Run with real n8n
34
+
35
+ From the repository root, with Node.js 24+ and n8n installed:
36
+
37
+ ```sh
38
+ # Optional: select an existing installation.
39
+ # export N8N_BINARY=/absolute/path/to/n8n
40
+ for scenario in success backend-503 empty-orders; do
41
+ node bin/n8n-check.mjs examples/vapi-tool-contract/workflow.json \
42
+ "examples/vapi-tool-contract/$scenario.case.json" \
43
+ --allow-network --out "temp/vapi-tool-contract/$scenario" || exit $?
44
+ done
45
+
46
+ # Negative control: expect exit 1 and a failed Build Vapi response assertion.
47
+ node bin/n8n-check.mjs examples/vapi-tool-contract/wrong-id.json \
48
+ examples/vapi-tool-contract/success.case.json \
49
+ --allow-network --out temp/vapi-tool-contract/wrong-id
50
+ ```
51
+
52
+ The bundled source and fixtures use synthetic values. The local command enables host networking; the runner redirects both HTTP routes to its loopback mock. Use the repository's [Docker route](../../README.md#try-a-failure-then-its-fix) for an isolated runtime. Run local CLI cases sequentially: n8n's task broker uses a shared default port.
53
+
54
+ Recorded execution: **n8n 2.41.7, n8n-check 0.1.6**, three passing cases, each with eight checks and two HTTP requests. The wrong-ID control retains successful execution and both correct backend requests while failing the output assertion. [`observed-results.json`](observed-results.json) contains compact check results, mock traces, and the actual emitted envelopes.
55
+
56
+ **[Completed GitHub Actions run](https://github.com/blucca/n8n-check/actions/runs/37558788945):** `backend-503`, 8/8 checks, two HTTP requests, loopback-only networking. The result preserves customer A’s order and returns customer B’s tool error.
57
+
58
+ ## Verified Vapi contract and scope
59
+
60
+ Official references, read 2026-10-07:
61
+
62
+ - [Custom tools — request and response format](https://docs.vapi.ai/tools/custom-tools): `message.toolCallList[].function.name` and object-valued `function.arguments`; `results[]` associates each response with `toolCallId`; `result` and `error` are single-line strings. Each entry uses one of those fields. Multiple results may appear in any order. The server returns HTTP 200 for successful and tool-error envelopes.
63
+ - [Server URL events — tool calls](https://docs.vapi.ai/server-url/events): documents tool-call delivery and response correlation. Its introductory flattened `name`/`parameters` example is a separate input shape; this fixture follows the custom-tools function/arguments example above.
64
+
65
+ This pack covers one tool, `lookupOrders`, a non-empty batch with unique call IDs, object-valued arguments, synthetic customer IDs, and backend HTTP responses containing JSON. The assertions preserve this sample's response order as well as the Vapi ID mapping. HTTP error handling here covers returned statuses; transport timeouts/DNS errors, malformed tool arguments, webhook authentication/delivery, and actual HTTP 200 response delivery require additional integration cases. The sample's Webhook response configuration is supplied for importing and subsequent endpoint testing.
66
+
67
+ Voice/audio, Twilio delivery, ElevenLabs speech, model decisions, live Vapi acceptance, and production credentials are separate live-agent acceptance work. A buyer's export, tool schema, example call trace, and agreed behavior provide the next test inputs.
@@ -0,0 +1,113 @@
1
+ {
2
+ "version": 1,
3
+ "name": "Vapi tool contract: backend-503",
4
+ "input": {
5
+ "node": "Vapi webhook",
6
+ "items": [
7
+ {
8
+ "body": {
9
+ "message": {
10
+ "type": "tool-calls",
11
+ "toolCallList": [
12
+ {
13
+ "id": "call-order-A",
14
+ "type": "function",
15
+ "function": {
16
+ "name": "lookupOrders",
17
+ "arguments": {
18
+ "customerId": "customer-A"
19
+ }
20
+ }
21
+ },
22
+ {
23
+ "id": "call-order-B",
24
+ "type": "function",
25
+ "function": {
26
+ "name": "lookupOrders",
27
+ "arguments": {
28
+ "customerId": "customer-B"
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
34
+ }
35
+ }
36
+ ]
37
+ },
38
+ "stopAfter": "Build Vapi response",
39
+ "mocks": [
40
+ {
41
+ "node": "Lookup orders",
42
+ "url": "/lookup/{{ $json.customerId }}",
43
+ "routes": [
44
+ {
45
+ "method": "POST",
46
+ "path": "/lookup/customer-A",
47
+ "responses": [
48
+ {
49
+ "status": 200,
50
+ "json": {
51
+ "orders": [
52
+ {
53
+ "id": "order-A-101",
54
+ "status": "shipped"
55
+ }
56
+ ]
57
+ }
58
+ }
59
+ ],
60
+ "expect": {
61
+ "count": 1,
62
+ "bodies": [
63
+ {
64
+ "customerId": "customer-A",
65
+ "toolCallId": "call-order-A"
66
+ }
67
+ ]
68
+ }
69
+ },
70
+ {
71
+ "method": "POST",
72
+ "path": "/lookup/customer-B",
73
+ "responses": [
74
+ {
75
+ "status": 503,
76
+ "json": {
77
+ "message": "upstream overloaded"
78
+ }
79
+ }
80
+ ],
81
+ "expect": {
82
+ "count": 1,
83
+ "bodies": [
84
+ {
85
+ "customerId": "customer-B",
86
+ "toolCallId": "call-order-B"
87
+ }
88
+ ]
89
+ }
90
+ }
91
+ ]
92
+ }
93
+ ],
94
+ "assertions": [
95
+ {
96
+ "node": "Build Vapi response",
97
+ "equals": [
98
+ {
99
+ "results": [
100
+ {
101
+ "toolCallId": "call-order-A",
102
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
103
+ },
104
+ {
105
+ "toolCallId": "call-order-B",
106
+ "error": "Order service temporarily unavailable. Please try again later."
107
+ }
108
+ ]
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
@@ -0,0 +1,113 @@
1
+ {
2
+ "version": 1,
3
+ "name": "Vapi tool contract: empty-orders",
4
+ "input": {
5
+ "node": "Vapi webhook",
6
+ "items": [
7
+ {
8
+ "body": {
9
+ "message": {
10
+ "type": "tool-calls",
11
+ "toolCallList": [
12
+ {
13
+ "id": "call-order-A",
14
+ "type": "function",
15
+ "function": {
16
+ "name": "lookupOrders",
17
+ "arguments": {
18
+ "customerId": "customer-A"
19
+ }
20
+ }
21
+ },
22
+ {
23
+ "id": "call-order-B",
24
+ "type": "function",
25
+ "function": {
26
+ "name": "lookupOrders",
27
+ "arguments": {
28
+ "customerId": "customer-B"
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
34
+ }
35
+ }
36
+ ]
37
+ },
38
+ "stopAfter": "Build Vapi response",
39
+ "mocks": [
40
+ {
41
+ "node": "Lookup orders",
42
+ "url": "/lookup/{{ $json.customerId }}",
43
+ "routes": [
44
+ {
45
+ "method": "POST",
46
+ "path": "/lookup/customer-A",
47
+ "responses": [
48
+ {
49
+ "status": 200,
50
+ "json": {
51
+ "orders": [
52
+ {
53
+ "id": "order-A-101",
54
+ "status": "shipped"
55
+ }
56
+ ]
57
+ }
58
+ }
59
+ ],
60
+ "expect": {
61
+ "count": 1,
62
+ "bodies": [
63
+ {
64
+ "customerId": "customer-A",
65
+ "toolCallId": "call-order-A"
66
+ }
67
+ ]
68
+ }
69
+ },
70
+ {
71
+ "method": "POST",
72
+ "path": "/lookup/customer-B",
73
+ "responses": [
74
+ {
75
+ "status": 200,
76
+ "json": {
77
+ "orders": []
78
+ }
79
+ }
80
+ ],
81
+ "expect": {
82
+ "count": 1,
83
+ "bodies": [
84
+ {
85
+ "customerId": "customer-B",
86
+ "toolCallId": "call-order-B"
87
+ }
88
+ ]
89
+ }
90
+ }
91
+ ]
92
+ }
93
+ ],
94
+ "assertions": [
95
+ {
96
+ "node": "Build Vapi response",
97
+ "equals": [
98
+ {
99
+ "results": [
100
+ {
101
+ "toolCallId": "call-order-A",
102
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
103
+ },
104
+ {
105
+ "toolCallId": "call-order-B",
106
+ "result": "{\"orders\":[]}"
107
+ }
108
+ ]
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
@@ -0,0 +1,234 @@
1
+ {
2
+ "n8nVersion": "2.41.7",
3
+ "runnerVersion": "0.1.6",
4
+ "networkMode": "host-network",
5
+ "boundary": "Fixture at Vapi webhook; real Split tool calls, Lookup orders HTTP Request and Build Vapi response; stop before Respond to Vapi. Local synthetic HTTP mocks.",
6
+ "runs": [
7
+ {
8
+ "scenario": "success",
9
+ "workflow": "workflow.json",
10
+ "case": "success.case.json",
11
+ "startedAt": "2026-10-07T01:44:22.166Z",
12
+ "status": "passed",
13
+ "exitCode": 0,
14
+ "checksPassed": 8,
15
+ "checksTotal": 8,
16
+ "failedChecks": [],
17
+ "requests": [
18
+ {
19
+ "node": "Lookup orders",
20
+ "method": "POST",
21
+ "path": "/lookup/customer-A",
22
+ "body": {
23
+ "customerId": "customer-A",
24
+ "toolCallId": "call-order-A"
25
+ },
26
+ "status": 200
27
+ },
28
+ {
29
+ "node": "Lookup orders",
30
+ "method": "POST",
31
+ "path": "/lookup/customer-B",
32
+ "body": {
33
+ "customerId": "customer-B",
34
+ "toolCallId": "call-order-B"
35
+ },
36
+ "status": 200
37
+ }
38
+ ],
39
+ "actualResponse": {
40
+ "results": [
41
+ {
42
+ "toolCallId": "call-order-A",
43
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
44
+ },
45
+ {
46
+ "toolCallId": "call-order-B",
47
+ "result": "{\"orders\":[{\"id\":\"order-B-202\",\"status\":\"processing\"}]}"
48
+ }
49
+ ]
50
+ }
51
+ },
52
+ {
53
+ "scenario": "backend-503",
54
+ "workflow": "workflow.json",
55
+ "case": "backend-503.case.json",
56
+ "startedAt": "2026-10-07T01:44:47.031Z",
57
+ "status": "passed",
58
+ "exitCode": 0,
59
+ "checksPassed": 8,
60
+ "checksTotal": 8,
61
+ "failedChecks": [],
62
+ "requests": [
63
+ {
64
+ "node": "Lookup orders",
65
+ "method": "POST",
66
+ "path": "/lookup/customer-A",
67
+ "body": {
68
+ "customerId": "customer-A",
69
+ "toolCallId": "call-order-A"
70
+ },
71
+ "status": 200
72
+ },
73
+ {
74
+ "node": "Lookup orders",
75
+ "method": "POST",
76
+ "path": "/lookup/customer-B",
77
+ "body": {
78
+ "customerId": "customer-B",
79
+ "toolCallId": "call-order-B"
80
+ },
81
+ "status": 503
82
+ }
83
+ ],
84
+ "actualResponse": {
85
+ "results": [
86
+ {
87
+ "toolCallId": "call-order-A",
88
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
89
+ },
90
+ {
91
+ "toolCallId": "call-order-B",
92
+ "error": "Order service temporarily unavailable. Please try again later."
93
+ }
94
+ ]
95
+ }
96
+ },
97
+ {
98
+ "scenario": "empty-orders",
99
+ "workflow": "workflow.json",
100
+ "case": "empty-orders.case.json",
101
+ "startedAt": "2026-10-07T01:44:29.107Z",
102
+ "status": "passed",
103
+ "exitCode": 0,
104
+ "checksPassed": 8,
105
+ "checksTotal": 8,
106
+ "failedChecks": [],
107
+ "requests": [
108
+ {
109
+ "node": "Lookup orders",
110
+ "method": "POST",
111
+ "path": "/lookup/customer-A",
112
+ "body": {
113
+ "customerId": "customer-A",
114
+ "toolCallId": "call-order-A"
115
+ },
116
+ "status": 200
117
+ },
118
+ {
119
+ "node": "Lookup orders",
120
+ "method": "POST",
121
+ "path": "/lookup/customer-B",
122
+ "body": {
123
+ "customerId": "customer-B",
124
+ "toolCallId": "call-order-B"
125
+ },
126
+ "status": 200
127
+ }
128
+ ],
129
+ "actualResponse": {
130
+ "results": [
131
+ {
132
+ "toolCallId": "call-order-A",
133
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
134
+ },
135
+ {
136
+ "toolCallId": "call-order-B",
137
+ "result": "{\"orders\":[]}"
138
+ }
139
+ ]
140
+ }
141
+ },
142
+ {
143
+ "scenario": "wrong-id",
144
+ "workflow": "wrong-id.json",
145
+ "case": "success.case.json",
146
+ "startedAt": "2026-10-07T01:44:56.484Z",
147
+ "status": "failed",
148
+ "exitCode": 1,
149
+ "checksPassed": 7,
150
+ "checksTotal": 8,
151
+ "failedChecks": [
152
+ {
153
+ "name": "Build Vapi response: output 0",
154
+ "passed": false,
155
+ "expected": [
156
+ {
157
+ "results": [
158
+ {
159
+ "toolCallId": "call-order-A",
160
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
161
+ },
162
+ {
163
+ "toolCallId": "call-order-B",
164
+ "result": "{\"orders\":[{\"id\":\"order-B-202\",\"status\":\"processing\"}]}"
165
+ }
166
+ ]
167
+ }
168
+ ],
169
+ "actual": [
170
+ {
171
+ "results": [
172
+ {
173
+ "toolCallId": "call-order-A",
174
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
175
+ },
176
+ {
177
+ "toolCallId": "call-order-A",
178
+ "result": "{\"orders\":[{\"id\":\"order-B-202\",\"status\":\"processing\"}]}"
179
+ }
180
+ ]
181
+ }
182
+ ]
183
+ }
184
+ ],
185
+ "requests": [
186
+ {
187
+ "node": "Lookup orders",
188
+ "method": "POST",
189
+ "path": "/lookup/customer-A",
190
+ "body": {
191
+ "customerId": "customer-A",
192
+ "toolCallId": "call-order-A"
193
+ },
194
+ "status": 200
195
+ },
196
+ {
197
+ "node": "Lookup orders",
198
+ "method": "POST",
199
+ "path": "/lookup/customer-B",
200
+ "body": {
201
+ "customerId": "customer-B",
202
+ "toolCallId": "call-order-B"
203
+ },
204
+ "status": 200
205
+ }
206
+ ],
207
+ "actualResponse": {
208
+ "results": [
209
+ {
210
+ "toolCallId": "call-order-A",
211
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
212
+ },
213
+ {
214
+ "toolCallId": "call-order-A",
215
+ "result": "{\"orders\":[{\"id\":\"order-B-202\",\"status\":\"processing\"}]}"
216
+ }
217
+ ]
218
+ }
219
+ }
220
+ ],
221
+ "ci": {
222
+ "url": "https://github.com/blucca/n8n-check/actions/runs/37558788945",
223
+ "sourceCommit": "fe78330",
224
+ "scenario": "backend-503",
225
+ "workflow": "workflow.json",
226
+ "n8nVersion": "2.41.7",
227
+ "runnerVersion": "0.1.6",
228
+ "networkMode": "loopback-only",
229
+ "checksPassed": 8,
230
+ "checksTotal": 8,
231
+ "requests": 2,
232
+ "exitCode": 0
233
+ }
234
+ }
@@ -0,0 +1,118 @@
1
+ {
2
+ "version": 1,
3
+ "name": "Vapi tool contract: success",
4
+ "input": {
5
+ "node": "Vapi webhook",
6
+ "items": [
7
+ {
8
+ "body": {
9
+ "message": {
10
+ "type": "tool-calls",
11
+ "toolCallList": [
12
+ {
13
+ "id": "call-order-A",
14
+ "type": "function",
15
+ "function": {
16
+ "name": "lookupOrders",
17
+ "arguments": {
18
+ "customerId": "customer-A"
19
+ }
20
+ }
21
+ },
22
+ {
23
+ "id": "call-order-B",
24
+ "type": "function",
25
+ "function": {
26
+ "name": "lookupOrders",
27
+ "arguments": {
28
+ "customerId": "customer-B"
29
+ }
30
+ }
31
+ }
32
+ ]
33
+ }
34
+ }
35
+ }
36
+ ]
37
+ },
38
+ "stopAfter": "Build Vapi response",
39
+ "mocks": [
40
+ {
41
+ "node": "Lookup orders",
42
+ "url": "/lookup/{{ $json.customerId }}",
43
+ "routes": [
44
+ {
45
+ "method": "POST",
46
+ "path": "/lookup/customer-A",
47
+ "responses": [
48
+ {
49
+ "status": 200,
50
+ "json": {
51
+ "orders": [
52
+ {
53
+ "id": "order-A-101",
54
+ "status": "shipped"
55
+ }
56
+ ]
57
+ }
58
+ }
59
+ ],
60
+ "expect": {
61
+ "count": 1,
62
+ "bodies": [
63
+ {
64
+ "customerId": "customer-A",
65
+ "toolCallId": "call-order-A"
66
+ }
67
+ ]
68
+ }
69
+ },
70
+ {
71
+ "method": "POST",
72
+ "path": "/lookup/customer-B",
73
+ "responses": [
74
+ {
75
+ "status": 200,
76
+ "json": {
77
+ "orders": [
78
+ {
79
+ "id": "order-B-202",
80
+ "status": "processing"
81
+ }
82
+ ]
83
+ }
84
+ }
85
+ ],
86
+ "expect": {
87
+ "count": 1,
88
+ "bodies": [
89
+ {
90
+ "customerId": "customer-B",
91
+ "toolCallId": "call-order-B"
92
+ }
93
+ ]
94
+ }
95
+ }
96
+ ]
97
+ }
98
+ ],
99
+ "assertions": [
100
+ {
101
+ "node": "Build Vapi response",
102
+ "equals": [
103
+ {
104
+ "results": [
105
+ {
106
+ "toolCallId": "call-order-A",
107
+ "result": "{\"orders\":[{\"id\":\"order-A-101\",\"status\":\"shipped\"}]}"
108
+ },
109
+ {
110
+ "toolCallId": "call-order-B",
111
+ "result": "{\"orders\":[{\"id\":\"order-B-202\",\"status\":\"processing\"}]}"
112
+ }
113
+ ]
114
+ }
115
+ ]
116
+ }
117
+ ]
118
+ }
@@ -0,0 +1,143 @@
1
+ {
2
+ "name": "Vapi tool-call contract \u2014 synthetic order lookup",
3
+ "nodes": [
4
+ {
5
+ "id": "vapi-webhook",
6
+ "name": "Vapi webhook",
7
+ "type": "n8n-nodes-base.webhook",
8
+ "typeVersion": 2.1,
9
+ "position": [
10
+ 0,
11
+ 0
12
+ ],
13
+ "parameters": {
14
+ "httpMethod": "POST",
15
+ "path": "vapi-tool-contract",
16
+ "responseMode": "responseNode",
17
+ "options": {}
18
+ }
19
+ },
20
+ {
21
+ "id": "split-tool-calls",
22
+ "name": "Split tool calls",
23
+ "type": "n8n-nodes-base.code",
24
+ "typeVersion": 2,
25
+ "position": [
26
+ 220,
27
+ 0
28
+ ],
29
+ "parameters": {
30
+ "mode": "runOnceForAllItems",
31
+ "jsCode": "const message = $input.first().json.body.message;\nif (message.type !== 'tool-calls' || !Array.isArray(message.toolCallList) || !message.toolCallList.length) throw new Error('Expected a non-empty tool-calls message');\nconst seen = new Set();\nreturn message.toolCallList.map(call => {\n if (typeof call.id !== 'string' || !call.id || seen.has(call.id)) throw new Error('Each tool call needs a unique ID');\n seen.add(call.id);\n if (call.function?.name !== 'lookupOrders' || typeof call.function.arguments?.customerId !== 'string' || !/^[a-zA-Z0-9-]+$/.test(call.function.arguments.customerId)) throw new Error('Expected lookupOrders with a customerId');\n return {json: {toolCallId: call.id, customerId: call.function.arguments.customerId}, pairedItem: {item: 0}};\n});"
32
+ }
33
+ },
34
+ {
35
+ "id": "lookup-orders",
36
+ "name": "Lookup orders",
37
+ "type": "n8n-nodes-base.httpRequest",
38
+ "typeVersion": 4.2,
39
+ "position": [
40
+ 440,
41
+ 0
42
+ ],
43
+ "parameters": {
44
+ "method": "POST",
45
+ "url": "=https://orders.example.invalid/lookup/{{ $json.customerId }}",
46
+ "sendBody": true,
47
+ "specifyBody": "json",
48
+ "jsonBody": "={{ {customerId: $json.customerId, toolCallId: $json.toolCallId} }}",
49
+ "options": {
50
+ "timeout": 5000,
51
+ "response": {
52
+ "response": {
53
+ "fullResponse": true,
54
+ "neverError": true,
55
+ "responseFormat": "json"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ },
61
+ {
62
+ "id": "build-vapi-response",
63
+ "name": "Build Vapi response",
64
+ "type": "n8n-nodes-base.code",
65
+ "typeVersion": 2,
66
+ "position": [
67
+ 660,
68
+ 0
69
+ ],
70
+ "parameters": {
71
+ "mode": "runOnceForAllItems",
72
+ "jsCode": "const results = $input.all().map((item, index) => {\n const { toolCallId } = $('Split tool calls').itemMatching(index).json;\n if (item.json.statusCode === 503) return {toolCallId, error: 'Order service temporarily unavailable. Please try again later.'};\n if (item.json.statusCode !== 200) return {toolCallId, error: 'Order lookup failed.'};\n if (!Array.isArray(item.json.body?.orders)) return {toolCallId, error: 'Order service returned an invalid response.'};\n return {toolCallId, result: JSON.stringify({orders: item.json.body.orders})};\n});\nreturn [{json: {results}}];"
73
+ }
74
+ },
75
+ {
76
+ "id": "respond-to-vapi",
77
+ "name": "Respond to Vapi",
78
+ "type": "n8n-nodes-base.respondToWebhook",
79
+ "typeVersion": 1.4,
80
+ "position": [
81
+ 880,
82
+ 0
83
+ ],
84
+ "parameters": {
85
+ "respondWith": "json",
86
+ "responseBody": "={{ $json }}",
87
+ "options": {
88
+ "responseCode": 200
89
+ }
90
+ }
91
+ }
92
+ ],
93
+ "connections": {
94
+ "Vapi webhook": {
95
+ "main": [
96
+ [
97
+ {
98
+ "node": "Split tool calls",
99
+ "type": "main",
100
+ "index": 0
101
+ }
102
+ ]
103
+ ]
104
+ },
105
+ "Split tool calls": {
106
+ "main": [
107
+ [
108
+ {
109
+ "node": "Lookup orders",
110
+ "type": "main",
111
+ "index": 0
112
+ }
113
+ ]
114
+ ]
115
+ },
116
+ "Lookup orders": {
117
+ "main": [
118
+ [
119
+ {
120
+ "node": "Build Vapi response",
121
+ "type": "main",
122
+ "index": 0
123
+ }
124
+ ]
125
+ ]
126
+ },
127
+ "Build Vapi response": {
128
+ "main": [
129
+ [
130
+ {
131
+ "node": "Respond to Vapi",
132
+ "type": "main",
133
+ "index": 0
134
+ }
135
+ ]
136
+ ]
137
+ }
138
+ },
139
+ "settings": {
140
+ "executionOrder": "v1"
141
+ },
142
+ "active": false
143
+ }
@@ -0,0 +1,143 @@
1
+ {
2
+ "name": "Vapi tool-call contract \u2014 synthetic order lookup \u2014 wrong ID control",
3
+ "nodes": [
4
+ {
5
+ "id": "vapi-webhook",
6
+ "name": "Vapi webhook",
7
+ "type": "n8n-nodes-base.webhook",
8
+ "typeVersion": 2.1,
9
+ "position": [
10
+ 0,
11
+ 0
12
+ ],
13
+ "parameters": {
14
+ "httpMethod": "POST",
15
+ "path": "vapi-tool-contract",
16
+ "responseMode": "responseNode",
17
+ "options": {}
18
+ }
19
+ },
20
+ {
21
+ "id": "split-tool-calls",
22
+ "name": "Split tool calls",
23
+ "type": "n8n-nodes-base.code",
24
+ "typeVersion": 2,
25
+ "position": [
26
+ 220,
27
+ 0
28
+ ],
29
+ "parameters": {
30
+ "mode": "runOnceForAllItems",
31
+ "jsCode": "const message = $input.first().json.body.message;\nif (message.type !== 'tool-calls' || !Array.isArray(message.toolCallList) || !message.toolCallList.length) throw new Error('Expected a non-empty tool-calls message');\nconst seen = new Set();\nreturn message.toolCallList.map(call => {\n if (typeof call.id !== 'string' || !call.id || seen.has(call.id)) throw new Error('Each tool call needs a unique ID');\n seen.add(call.id);\n if (call.function?.name !== 'lookupOrders' || typeof call.function.arguments?.customerId !== 'string' || !/^[a-zA-Z0-9-]+$/.test(call.function.arguments.customerId)) throw new Error('Expected lookupOrders with a customerId');\n return {json: {toolCallId: call.id, customerId: call.function.arguments.customerId}, pairedItem: {item: 0}};\n});"
32
+ }
33
+ },
34
+ {
35
+ "id": "lookup-orders",
36
+ "name": "Lookup orders",
37
+ "type": "n8n-nodes-base.httpRequest",
38
+ "typeVersion": 4.2,
39
+ "position": [
40
+ 440,
41
+ 0
42
+ ],
43
+ "parameters": {
44
+ "method": "POST",
45
+ "url": "=https://orders.example.invalid/lookup/{{ $json.customerId }}",
46
+ "sendBody": true,
47
+ "specifyBody": "json",
48
+ "jsonBody": "={{ {customerId: $json.customerId, toolCallId: $json.toolCallId} }}",
49
+ "options": {
50
+ "timeout": 5000,
51
+ "response": {
52
+ "response": {
53
+ "fullResponse": true,
54
+ "neverError": true,
55
+ "responseFormat": "json"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ },
61
+ {
62
+ "id": "build-vapi-response",
63
+ "name": "Build Vapi response",
64
+ "type": "n8n-nodes-base.code",
65
+ "typeVersion": 2,
66
+ "position": [
67
+ 660,
68
+ 0
69
+ ],
70
+ "parameters": {
71
+ "mode": "runOnceForAllItems",
72
+ "jsCode": "const results = $input.all().map((item, index) => {\n const { toolCallId } = $('Split tool calls').itemMatching(index).json;\n if (item.json.statusCode === 503) return {toolCallId, error: 'Order service temporarily unavailable. Please try again later.'};\n if (item.json.statusCode !== 200) return {toolCallId, error: 'Order lookup failed.'};\n if (!Array.isArray(item.json.body?.orders)) return {toolCallId, error: 'Order service returned an invalid response.'};\n return {toolCallId, result: JSON.stringify({orders: item.json.body.orders})};\n});\nresults[1].toolCallId = results[0].toolCallId;\nreturn [{json: {results}}];"
73
+ }
74
+ },
75
+ {
76
+ "id": "respond-to-vapi",
77
+ "name": "Respond to Vapi",
78
+ "type": "n8n-nodes-base.respondToWebhook",
79
+ "typeVersion": 1.4,
80
+ "position": [
81
+ 880,
82
+ 0
83
+ ],
84
+ "parameters": {
85
+ "respondWith": "json",
86
+ "responseBody": "={{ $json }}",
87
+ "options": {
88
+ "responseCode": 200
89
+ }
90
+ }
91
+ }
92
+ ],
93
+ "connections": {
94
+ "Vapi webhook": {
95
+ "main": [
96
+ [
97
+ {
98
+ "node": "Split tool calls",
99
+ "type": "main",
100
+ "index": 0
101
+ }
102
+ ]
103
+ ]
104
+ },
105
+ "Split tool calls": {
106
+ "main": [
107
+ [
108
+ {
109
+ "node": "Lookup orders",
110
+ "type": "main",
111
+ "index": 0
112
+ }
113
+ ]
114
+ ]
115
+ },
116
+ "Lookup orders": {
117
+ "main": [
118
+ [
119
+ {
120
+ "node": "Build Vapi response",
121
+ "type": "main",
122
+ "index": 0
123
+ }
124
+ ]
125
+ ]
126
+ },
127
+ "Build Vapi response": {
128
+ "main": [
129
+ [
130
+ {
131
+ "node": "Respond to Vapi",
132
+ "type": "main",
133
+ "index": 0
134
+ }
135
+ ]
136
+ ]
137
+ }
138
+ },
139
+ "settings": {
140
+ "executionOrder": "v1"
141
+ },
142
+ "active": false
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blucca/n8n-check",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Run n8n workflow regression checks with fixtures, local HTTP mocks and JUnit reports.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }