Skip to content

Commit 5bf1f16

Browse files
committed
fix(e2e): bound ClawHub preflight waits
1 parent 101c834 commit 5bf1f16

2 files changed

Lines changed: 169 additions & 10 deletions

File tree

scripts/e2e/lib/plugins/assertions.mjs

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,44 @@ import path from "node:path";
44

55
const command = process.argv[2];
66
const scratchRoot = process.env.OPENCLAW_PLUGINS_TMP_DIR || os.tmpdir();
7+
const CLAWHUB_PREFLIGHT_TIMEOUT_MS = readPositiveInt(
8+
process.env.OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS,
9+
30_000,
10+
);
711
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
812
const scratchFile = (name) => path.join(scratchRoot, name);
913

14+
function readPositiveInt(raw, fallback) {
15+
const parsed = Number.parseInt(String(raw || ""), 10);
16+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
17+
}
18+
19+
function createTimeoutError(label, timeoutMs) {
20+
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
21+
error.code = "ETIMEDOUT";
22+
return error;
23+
}
24+
25+
async function withTimeout(label, timeoutMs, run) {
26+
const controller = new AbortController();
27+
const timeoutError = createTimeoutError(label, timeoutMs);
28+
let timeout;
29+
const timeoutPromise = new Promise((_, reject) => {
30+
timeout = setTimeout(() => {
31+
controller.abort(timeoutError);
32+
reject(timeoutError);
33+
}, timeoutMs);
34+
timeout.unref?.();
35+
});
36+
try {
37+
return await Promise.race([run(controller.signal), timeoutPromise]);
38+
} finally {
39+
if (timeout) {
40+
clearTimeout(timeout);
41+
}
42+
}
43+
}
44+
1045
function resolveHomePath(value) {
1146
if (value === "~") {
1247
return process.env.HOME;
@@ -420,12 +455,18 @@ function assertGitPlugin() {
420455
}
421456
assertRealPathInside(installPath, dependencyPackagePath, "git plugin installed dependency");
422457
fs.writeFileSync(scratchFile("plugins-git-install-path.txt"), installPath, "utf8");
423-
fs.writeFileSync(scratchFile("plugins-git-install-parent.txt"), path.dirname(installPath), "utf8");
458+
fs.writeFileSync(
459+
scratchFile("plugins-git-install-parent.txt"),
460+
path.dirname(installPath),
461+
"utf8",
462+
);
424463
}
425464

426465
function assertGitPluginRemoved() {
427466
const installPath = fs.readFileSync(scratchFile("plugins-git-install-path.txt"), "utf8").trim();
428-
const installParent = fs.readFileSync(scratchFile("plugins-git-install-parent.txt"), "utf8").trim();
467+
const installParent = fs
468+
.readFileSync(scratchFile("plugins-git-install-parent.txt"), "utf8")
469+
.trim();
429470
assertPluginRemoved({
430471
pluginId: "demo-plugin-git",
431472
listFile: scratchFile("plugins-git-uninstalled.json"),
@@ -597,7 +638,10 @@ function assertNpmPlugin() {
597638
}
598639

599640
function assertNpmPluginUpdateUnchanged() {
600-
assertUpdateOutput(scratchFile("plugins-npm-update.log"), "demo-plugin-npm is up to date (0.0.1).");
641+
assertUpdateOutput(
642+
scratchFile("plugins-npm-update.log"),
643+
"demo-plugin-npm is up to date (0.0.1).",
644+
);
601645
assertNpmPlugin();
602646
}
603647

@@ -748,16 +792,31 @@ async function assertClawHubPreflight() {
748792
process.env.CLAWHUB_TOKEN ||
749793
process.env.CLAWHUB_AUTH_TOKEN ||
750794
"";
751-
const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, {
752-
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
753-
});
795+
const preflightUrl = `${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`;
796+
const response = await withTimeout(
797+
`ClawHub package preflight for ${packageName}`,
798+
CLAWHUB_PREFLIGHT_TIMEOUT_MS,
799+
(signal) =>
800+
fetch(preflightUrl, {
801+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
802+
signal,
803+
}),
804+
);
754805
if (!response.ok) {
755-
const body = await response.text().catch(() => "");
806+
const body = await withTimeout(
807+
`ClawHub package preflight response for ${packageName}`,
808+
CLAWHUB_PREFLIGHT_TIMEOUT_MS,
809+
() => response.text().catch(() => ""),
810+
);
756811
throw new Error(
757812
`ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`,
758813
);
759814
}
760-
const detail = await response.json();
815+
const detail = await withTimeout(
816+
`ClawHub package preflight response for ${packageName}`,
817+
CLAWHUB_PREFLIGHT_TIMEOUT_MS,
818+
() => response.json(),
819+
);
761820
const family = detail.package?.family;
762821
if (family !== "code-plugin" && family !== "bundle-plugin") {
763822
throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`);
@@ -834,7 +893,9 @@ function assertClawHubInstalled() {
834893

835894
function assertClawHubRemoved() {
836895
const pluginId = process.env.CLAWHUB_PLUGIN_ID;
837-
const installPath = fs.readFileSync(scratchFile("plugins-clawhub-install-path.txt"), "utf8").trim();
896+
const installPath = fs
897+
.readFileSync(scratchFile("plugins-clawhub-install-path.txt"), "utf8")
898+
.trim();
838899
const list = readJson(scratchFile("plugins-clawhub-uninstalled.json"));
839900
if ((list.plugins || []).some((entry) => entry.id === pluginId)) {
840901
throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`);

test/scripts/plugins-assertions.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { spawnSync } from "node:child_process";
1+
import { spawn, spawnSync } from "node:child_process";
22
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { createServer } from "node:http";
34
import { tmpdir } from "node:os";
45
import path from "node:path";
56
import { describe, expect, it } from "vitest";
@@ -11,6 +12,41 @@ function writeJson(filePath: string, value: unknown) {
1112
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1213
}
1314

15+
function runAssertionAsync(args: string[], env: NodeJS.ProcessEnv) {
16+
return new Promise<{ status: number | null; stdout: string; stderr: string }>(
17+
(resolve, reject) => {
18+
const child = spawn(process.execPath, [ASSERTIONS_SCRIPT, ...args], {
19+
env: { ...process.env, ...env },
20+
stdio: ["ignore", "pipe", "pipe"],
21+
});
22+
let stdout = "";
23+
let stderr = "";
24+
const timeout = setTimeout(() => {
25+
child.kill("SIGKILL");
26+
reject(new Error(`assertion helper did not exit: ${args.join(" ")}`));
27+
}, 2_000);
28+
timeout.unref();
29+
30+
child.stdout.setEncoding("utf8");
31+
child.stderr.setEncoding("utf8");
32+
child.stdout.on("data", (chunk) => {
33+
stdout += chunk;
34+
});
35+
child.stderr.on("data", (chunk) => {
36+
stderr += chunk;
37+
});
38+
child.on("error", (error) => {
39+
clearTimeout(timeout);
40+
reject(error);
41+
});
42+
child.on("close", (status) => {
43+
clearTimeout(timeout);
44+
resolve({ status, stdout, stderr });
45+
});
46+
},
47+
);
48+
}
49+
1450
describe("plugins Docker assertions", () => {
1551
it("keeps sweep artifact paths aligned with the assertion scratch root", () => {
1652
const scripts = [
@@ -138,4 +174,66 @@ describe("plugins Docker assertions", () => {
138174
rmSync(root, { force: true, recursive: true });
139175
}
140176
});
177+
178+
it("times out stalled ClawHub package metadata requests", async () => {
179+
const server = createServer((_request, _response) => {});
180+
await new Promise<void>((resolve) => {
181+
server.listen(0, "127.0.0.1", resolve);
182+
});
183+
184+
try {
185+
const address = server.address();
186+
if (!address || typeof address === "string") {
187+
throw new Error("expected TCP server address");
188+
}
189+
const result = await runAssertionAsync(["clawhub-preflight"], {
190+
CLAWHUB_PLUGIN_ID: "openclaw-kitchen-sink-fixture",
191+
CLAWHUB_PLUGIN_SPEC: "clawhub:@openclaw/kitchen-sink",
192+
OPENCLAW_CLAWHUB_URL: `http://127.0.0.1:${address.port}`,
193+
OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS: "25",
194+
});
195+
196+
expect(result.status).not.toBe(0);
197+
expect(result.stderr).toContain(
198+
"ClawHub package preflight for @openclaw/kitchen-sink timed out after 25ms",
199+
);
200+
} finally {
201+
await new Promise<void>((resolve) => {
202+
server.close(() => resolve());
203+
});
204+
}
205+
});
206+
207+
it("times out stalled ClawHub package metadata bodies", async () => {
208+
const server = createServer((_request, response) => {
209+
response.writeHead(200, { "content-type": "application/json" });
210+
response.flushHeaders();
211+
response.write("{");
212+
});
213+
await new Promise<void>((resolve) => {
214+
server.listen(0, "127.0.0.1", resolve);
215+
});
216+
217+
try {
218+
const address = server.address();
219+
if (!address || typeof address === "string") {
220+
throw new Error("expected TCP server address");
221+
}
222+
const result = await runAssertionAsync(["clawhub-preflight"], {
223+
CLAWHUB_PLUGIN_ID: "openclaw-kitchen-sink-fixture",
224+
CLAWHUB_PLUGIN_SPEC: "clawhub:@openclaw/kitchen-sink",
225+
OPENCLAW_CLAWHUB_URL: `http://127.0.0.1:${address.port}`,
226+
OPENCLAW_PLUGINS_E2E_CLAWHUB_PREFLIGHT_TIMEOUT_MS: "75",
227+
});
228+
229+
expect(result.status).not.toBe(0);
230+
expect(result.stderr).toContain(
231+
"ClawHub package preflight response for @openclaw/kitchen-sink timed out after 75ms",
232+
);
233+
} finally {
234+
await new Promise<void>((resolve) => {
235+
server.close(() => resolve());
236+
});
237+
}
238+
});
141239
});

0 commit comments

Comments
 (0)