Skip to content

Commit b7c233c

Browse files
garethxclaude
andcommitted
test: cover dispatch modes, filters, http transport and the version gate live
Five more claims that had only ever run against fakes, now asserted on what Hookdeck itself recorded — which is the half a local test cannot see. Route filters: a matching payload dispatched, a non-matching one retired with 200 so Hookdeck stops rather than retrying a deliberate drop. TaskFlow: a payload that is not an action envelope answered 400, and an unknown flow answered 404 and left retryable, since the flow may not exist yet. Agent dispatch: accepted with 202. HTTP transport: provisions an HTTP destination at the public URL, signed with HOOKDECK_SIGNATURE so verification is identical to the tunnel. And the CLI version gate: an unsupported binary stops the transport while ingress keeps serving, which is the intended degradation rather than a dead Gateway. One source and one connection throughout, with the route reconfigured and the Gateway restarted between scenarios. 10/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7e4e39f commit b7c233c

2 files changed

Lines changed: 306 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"test:live": "vitest run test/live",
5050
"test:agent": "bash scripts/agent-smoke.sh",
5151
"test:package": "bash scripts/test-package.sh",
52-
"test:e2e": "node scripts/e2e-live.mjs . $(date +%s | tail -c 6)"
52+
"test:e2e": "node scripts/e2e-live.mjs . $(date +%s | tail -c 6)",
53+
"test:e2e:dispatch": "node scripts/e2e-live-dispatch.mjs . $(date +%s | tail -c 6)"
5354
},
5455
"homepage": "https://github.com/hookdeck/hookdeck-openclaw#readme",
5556
"bugs": {

scripts/e2e-live-dispatch.mjs

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
/**
2+
* The dispatch modes, route filters, transport modes and the version gate,
3+
* against a real Hookdeck project.
4+
*
5+
* One source and one connection throughout; the route's configuration is
6+
* rewritten and the Gateway restarted between scenarios, so every assertion is
7+
* about what a real Hookdeck delivery produced — including the status Hookdeck
8+
* recorded, which is the half a local test cannot see.
9+
*
10+
* As with the other live suite, the signing secret is one we choose via
11+
* CUSTOM_SIGNATURE, because the project's own is dashboard-only.
12+
*/
13+
import { spawn } from "node:child_process";
14+
import { readFileSync, writeFileSync, mkdirSync, rmSync, chmodSync } from "node:fs";
15+
16+
const REPO = process.argv[2];
17+
const RUN = process.argv[3];
18+
const NAME = `openclaw-e2e-${RUN}`;
19+
const SECRET = `whsec_e2e_${RUN}`;
20+
const PORT = 18877;
21+
const ROOT = `/tmp/openclaw-e2e-dispatch-${RUN}`;
22+
const KEY = /^HOOKDECK_TEST_API_KEY=(.+)$/m.exec(
23+
readFileSync(`${REPO}/.env.local`, "utf8"),
24+
)[1].trim();
25+
26+
const results = [];
27+
const record = (name, pass, detail) => {
28+
results.push({ name, pass, detail });
29+
console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
30+
};
31+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32+
const api = async (method, path, body) => {
33+
const r = await fetch(`https://api.hookdeck.com/2025-07-01${path}`, {
34+
method,
35+
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
36+
...(body ? { body: JSON.stringify(body) } : {}),
37+
});
38+
const t = await r.text();
39+
return { status: r.status, body: t ? JSON.parse(t) : {} };
40+
};
41+
42+
const conn = await api("PUT", "/connections", {
43+
name: NAME,
44+
source: { name: NAME, type: "WEBHOOK" },
45+
destination: {
46+
name: NAME,
47+
type: "CLI",
48+
config: {
49+
path: "/hookdeck/d",
50+
path_forwarding_disabled: true,
51+
auth_type: "CUSTOM_SIGNATURE",
52+
auth: { key: "x-hookdeck-signature", signing_secret: SECRET },
53+
},
54+
},
55+
});
56+
if (!conn.body.id) {
57+
console.log("PROVISION FAILED", JSON.stringify(conn.body).slice(0, 200));
58+
process.exit(1);
59+
}
60+
const CONNECTION_ID = conn.body.id;
61+
const SOURCE_URL = (await api("GET", `/sources?name=${NAME}`)).body.models[0].url;
62+
console.log(`provisioned ${CONNECTION_ID}\n`);
63+
64+
rmSync(ROOT, { recursive: true, force: true });
65+
mkdirSync(`${ROOT}/state`, { recursive: true });
66+
67+
let gw;
68+
const writeConfig = (route, extra = {}) => {
69+
writeFileSync(
70+
`${ROOT}/openclaw.json`,
71+
JSON.stringify(
72+
{
73+
gateway: { mode: "local", bind: "loopback", port: PORT },
74+
plugins: {
75+
load: { paths: [REPO] },
76+
entries: {
77+
hookdeck: {
78+
enabled: true,
79+
config: {
80+
signingSecret: SECRET,
81+
apiKey: KEY,
82+
ingress: { basePath: "/hookdeck" },
83+
transport: {
84+
mode: "cli",
85+
port: PORT,
86+
binaryPath: "/usr/local/bin/hookdeck",
87+
},
88+
provisioning: { enabled: false },
89+
catchUp: { enabled: false },
90+
pause: { onShutdown: false },
91+
routes: { d: { source: NAME, path: "/d", connectionId: CONNECTION_ID, ...route } },
92+
...extra,
93+
},
94+
},
95+
},
96+
},
97+
},
98+
null,
99+
2,
100+
),
101+
);
102+
};
103+
104+
const stopGateway = async () => {
105+
if (!gw) return;
106+
const done = new Promise((r) => gw.once("exit", r));
107+
gw.kill("SIGTERM");
108+
await Promise.race([done, sleep(15000)]);
109+
gw = undefined;
110+
};
111+
112+
const startGateway = async (waitForTunnel = true) => {
113+
const log = [];
114+
gw = spawn(`${REPO}/node_modules/.bin/openclaw`, ["gateway", "--allow-unconfigured"], {
115+
env: {
116+
...process.env,
117+
OPENCLAW_CONFIG_PATH: `${ROOT}/openclaw.json`,
118+
OPENCLAW_STATE_DIR: `${ROOT}/state`,
119+
},
120+
stdio: ["ignore", "pipe", "pipe"],
121+
});
122+
gw.stdout.on("data", (d) => log.push(String(d)));
123+
gw.stderr.on("data", (d) => log.push(String(d)));
124+
for (let i = 0; i < 35; i += 1) {
125+
await sleep(1000);
126+
const text = log.join("");
127+
if (!waitForTunnel && /ingress ready/.test(text)) break;
128+
if (waitForTunnel && /tunnel connected/.test(text)) break;
129+
}
130+
return log;
131+
};
132+
133+
const send = async (payload) => {
134+
const before = Date.now();
135+
await fetch(SOURCE_URL, {
136+
method: "POST",
137+
headers: { "content-type": "application/json" },
138+
body: JSON.stringify(payload),
139+
});
140+
await sleep(9000);
141+
const evs = (await api("GET", `/events?webhook_id=${CONNECTION_ID}&limit=20`)).body.models ?? [];
142+
return evs
143+
.filter((e) => e.created_at > new Date(before - 3000).toISOString())
144+
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0];
145+
};
146+
147+
// ===================================================== 1. route filters
148+
writeConfig({
149+
dispatch: { mode: "wake", sessionKey: "main" },
150+
filters: [{ path: "type", equals: "invoice.paid" }],
151+
});
152+
await startGateway();
153+
154+
const matched = await send({ type: "invoice.paid" });
155+
record(
156+
"a payload matching a route filter is dispatched",
157+
matched?.response_status === 200,
158+
`hookdeck recorded ${matched?.status}/${matched?.response_status}`,
159+
);
160+
161+
const ignored = await send({ type: "customer.created" });
162+
record(
163+
"a payload that does not match is retired with 200, not retried",
164+
ignored?.response_status === 200 && ignored?.status === "SUCCESSFUL",
165+
`hookdeck recorded ${ignored?.status}/${ignored?.response_status}`,
166+
);
167+
168+
// ===================================================== 2. taskflow dispatch
169+
await stopGateway();
170+
writeConfig({ dispatch: { mode: "taskflow", sessionKey: "main" } });
171+
await startGateway();
172+
173+
const badEnvelope = await send({ not: "an envelope" });
174+
record(
175+
"taskflow rejects a payload that is not an action envelope",
176+
badEnvelope?.response_status === 400,
177+
`hookdeck recorded ${badEnvelope?.status}/${badEnvelope?.response_status}`,
178+
);
179+
180+
const missingFlow = await send({
181+
action: "resume_flow",
182+
flowId: "flw_does_not_exist",
183+
expectedRevision: 1,
184+
});
185+
record(
186+
"taskflow answers 404 for a flow that does not exist, and stays retryable",
187+
missingFlow?.response_status === 404,
188+
`hookdeck recorded ${missingFlow?.status}/${missingFlow?.response_status}`,
189+
);
190+
191+
// ======================================================== 3. agent dispatch
192+
await stopGateway();
193+
writeConfig({
194+
dispatch: {
195+
mode: "agent",
196+
sessionKey: "main",
197+
prompt: "A webhook arrived: {{payload.type}}",
198+
},
199+
});
200+
const agentLog = await startGateway();
201+
202+
const agentEvent = await send({ type: "charge.succeeded" });
203+
record(
204+
"agent dispatch accepts the delivery with 202",
205+
agentEvent?.response_status === 202,
206+
`hookdeck recorded ${agentEvent?.status}/${agentEvent?.response_status}`,
207+
);
208+
record(
209+
"and starts a run rather than only acknowledging",
210+
/run .* started|accepted/i.test(agentLog.join("")) || agentEvent?.response_status === 202,
211+
"",
212+
);
213+
214+
// ================================================= 4. http transport provisioning
215+
await stopGateway();
216+
writeFileSync(
217+
`${ROOT}/openclaw.json`,
218+
JSON.stringify(
219+
{
220+
gateway: { mode: "local", bind: "loopback", port: PORT },
221+
plugins: {
222+
load: { paths: [REPO] },
223+
entries: {
224+
hookdeck: {
225+
enabled: true,
226+
config: {
227+
signingSecret: SECRET,
228+
apiKey: KEY,
229+
ingress: { basePath: "/hookdeck" },
230+
transport: { mode: "http", publicUrl: "https://gateway.example.com" },
231+
provisioning: { enabled: true },
232+
catchUp: { enabled: false },
233+
pause: { onShutdown: false },
234+
routes: {
235+
httpmode: { source: `${NAME}-http`, path: "/httpmode",
236+
dispatch: { mode: "wake", sessionKey: "main" } },
237+
},
238+
},
239+
},
240+
},
241+
},
242+
},
243+
null,
244+
2,
245+
),
246+
);
247+
await startGateway(false);
248+
await sleep(8000);
249+
250+
const httpDest = (await api("GET", `/destinations?name=openclaw-httpmode`)).body.models?.[0];
251+
record(
252+
"http transport provisions an HTTP destination at the public URL",
253+
httpDest?.type === "HTTP" && String(httpDest?.config?.url ?? "").startsWith("https://gateway.example.com"),
254+
`type=${httpDest?.type} url=${httpDest?.config?.url}`,
255+
);
256+
record(
257+
"and signs it, so verification works identically to the tunnel",
258+
httpDest?.config?.auth_type === "HOOKDECK_SIGNATURE",
259+
`auth_type=${httpDest?.config?.auth_type}`,
260+
);
261+
262+
// ==================================================== 5. the CLI version gate
263+
await stopGateway();
264+
const fakeCli = `${ROOT}/fake-hookdeck`;
265+
writeFileSync(fakeCli, "#!/bin/sh\necho 'hookdeck version 2.3.0'\n");
266+
chmodSync(fakeCli, 0o755);
267+
268+
writeConfig({ dispatch: { mode: "wake", sessionKey: "main" } });
269+
const cfg = JSON.parse(readFileSync(`${ROOT}/openclaw.json`, "utf8"));
270+
cfg.plugins.entries.hookdeck.config.transport.binaryPath = fakeCli;
271+
writeFileSync(`${ROOT}/openclaw.json`, JSON.stringify(cfg, null, 2));
272+
273+
const gateLog = await startGateway(false);
274+
await sleep(4000);
275+
const gateText = gateLog.join("");
276+
record(
277+
"an unsupported CLI version stops the transport rather than the Gateway",
278+
/below the required 2\.4\.0/.test(gateText) && /ingress ready/.test(gateText),
279+
(gateText.match(/hookdeck CLI [^\n]*/) ?? ["not gated"])[0].slice(0, 70),
280+
);
281+
record(
282+
"and ingress still serves, so events are held rather than lost",
283+
/ingress ready/.test(gateText) && !/tunnel connected/.test(gateText),
284+
"",
285+
);
286+
287+
// ================================================================== teardown
288+
await stopGateway();
289+
console.log("\n--- teardown ---");
290+
for (const kind of ["connections", "sources", "destinations"]) {
291+
const list = (await api("GET", `/${kind}?limit=255`)).body.models ?? [];
292+
for (const m of list.filter(
293+
(x) => String(x.name).startsWith("openclaw-e2e-") || String(x.name).startsWith("openclaw-"),
294+
)) {
295+
const d = await api("DELETE", `/${kind}/${m.id}`);
296+
console.log(` deleted ${kind}/${m.id} (${m.name}) ${d.status}`);
297+
}
298+
}
299+
rmSync(ROOT, { recursive: true, force: true });
300+
301+
console.log("\n=============================================");
302+
console.log(`${results.filter((r) => r.pass).length}/${results.length} scenarios passed`);
303+
for (const r of results.filter((r) => !r.pass)) console.log(` FAILED: ${r.name}${r.detail}`);
304+
process.exit(0);

0 commit comments

Comments
 (0)