@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 +13 -9
- package/examples/vapi-tool-contract/README.md +67 -0
- package/examples/vapi-tool-contract/backend-503.case.json +113 -0
- package/examples/vapi-tool-contract/empty-orders.case.json +113 -0
- package/examples/vapi-tool-contract/observed-results.json +234 -0
- package/examples/vapi-tool-contract/success.case.json +118 -0
- package/examples/vapi-tool-contract/workflow.json +143 -0
- package/examples/vapi-tool-contract/wrong-id.json +143 -0
- package/package.json +1 -1
- package/src/local-bundle.mjs +154 -0
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
+
}
|