Skip to content

Commit 3e275a5

Browse files
committed
fix(e2e): retry Windows kitchen sink probes
1 parent 367d584 commit 3e275a5

6 files changed

Lines changed: 89 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Docs: https://docs.openclaw.ai
1111
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
1212
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
1313
- Control UI: split large build-time runtime dependencies into stable chunks so Linux/Docker install and package builds stay below the app chunk warning threshold.
14+
- Tests: retry transient loopback HTTP resets in the kitchen-sink RPC walk so native Windows readiness probes do not fail after the gateway is already ready.
15+
- Tests: run `test:serial` through a Node wrapper so targeted serial Vitest commands work on native Windows.
16+
- Tests: normalize Vitest config path assertions so the infra config suite runs on native Windows paths.
1417
- Scripts: run the optional Discord native opus installer through the shared pnpm launcher and Windows CI coverage so native Windows installs avoid shell-mode package-manager shims.
1518
- Agents/MCP: bound bundled MCP `tools/list` catalog discovery so hung MCP servers do not block session tool materialization. (#85063) Thanks @nxmxbbd.
1619
- Scripts: run generated-module formatting through the shared pnpm launcher and Windows CI coverage so native Windows generator checks avoid shell-mode package-manager shims.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1747,7 +1747,7 @@
17471747
"test:plugins:kitchen-sink-live": "bash -lc 'if [ -x \"$HOME/.local/bin/openclaw-testbox-env\" ]; then exec \"$HOME/.local/bin/openclaw-testbox-env\" pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai; fi; exec pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai'",
17481748
"test:plugins:kitchen-sink-rpc": "node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mjs",
17491749
"test:sectriage": "OPENCLAW_GATEWAY_PROJECT_SHARDS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts",
1750-
"test:serial": "OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs",
1750+
"test:serial": "node scripts/test-projects-serial.mjs",
17511751
"test:stability:gateway": "OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts src/gateway/gateway-stability.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.logging.config.ts src/logging/diagnostic-stability-bundle.test.ts && OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.infra.config.ts src/infra/fatal-error-hooks.test.ts",
17521752
"test:cli-response:contract": "node scripts/build-all.mjs cliStartup && node scripts/test-cli-startup-bench-budget.mjs --preset response --runs 1 --warmup 0 --timeout-ms 10000 --skip-baseline",
17531753
"test:startup:bench": "node --import tsx scripts/bench-cli-startup.ts",

scripts/e2e/kitchen-sink-rpc-walk.mjs

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -292,25 +292,58 @@ async function retryRpcCall(method, params, options) {
292292
function isRetryableGatewayCallError(error) {
293293
const text = error instanceof Error ? error.message : String(error);
294294
return (
295+
isRetryableTransientNetworkError(error) ||
295296
text.includes("gateway starting") ||
296297
text.includes("gateway closed") ||
297298
text.includes("handshake timeout") ||
298-
text.includes("GatewayTransportError") ||
299-
text.includes("ECONNREFUSED") ||
300-
text.includes("fetch failed")
299+
text.includes("GatewayTransportError")
301300
);
302301
}
303302

304-
async function fetchJson(url) {
305-
const response = await fetch(url);
306-
const text = await response.text();
307-
let body = null;
308-
try {
309-
body = text ? JSON.parse(text) : null;
310-
} catch {
311-
body = text;
303+
function isRetryableTransientNetworkError(error, seen = new Set()) {
304+
if (!error || seen.has(error)) {
305+
return false;
306+
}
307+
seen.add(error);
308+
const candidate = error;
309+
const message = candidate instanceof Error ? candidate.message : String(candidate);
310+
const code = typeof candidate === "object" && candidate !== null ? candidate.code : undefined;
311+
const text = `${String(code ?? "")} ${message}`;
312+
if (
313+
/\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH)\b/iu.test(text) ||
314+
/\b(?:fetch failed|socket hang up|connection reset)\b/iu.test(text)
315+
) {
316+
return true;
317+
}
318+
if (typeof candidate === "object" && candidate !== null && "cause" in candidate) {
319+
return isRetryableTransientNetworkError(candidate.cause, seen);
320+
}
321+
return false;
322+
}
323+
324+
export async function fetchJson(url, options = {}) {
325+
const attempts = Math.max(1, options.attempts ?? 3);
326+
let lastError;
327+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
328+
try {
329+
const response = await (options.fetchImpl ?? fetch)(url);
330+
const text = await response.text();
331+
let body = null;
332+
try {
333+
body = text ? JSON.parse(text) : null;
334+
} catch {
335+
body = text;
336+
}
337+
return { ok: response.ok, status: response.status, body };
338+
} catch (error) {
339+
lastError = error;
340+
if (attempt >= attempts || !isRetryableTransientNetworkError(error)) {
341+
throw error;
342+
}
343+
await delay(options.retryDelayMs ?? 250);
344+
}
312345
}
313-
return { ok: response.ok, status: response.status, body };
346+
throw lastError ?? new Error(`fetch ${url} failed`);
314347
}
315348

316349
function configureKitchenSink(env, port) {

scripts/test-projects-serial.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
2+
process.env.OPENCLAW_VITEST_MAX_WORKERS = "1";
3+
4+
await import("./test-projects.mjs");

src/infra/vitest-config.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import baseConfig, {
77
resolveLocalVitestScheduling,
88
} from "../../vitest.config.ts";
99

10+
function normalizeConfigPath(value: unknown): string {
11+
return String(value).replaceAll("\\", "/");
12+
}
13+
1014
describe("resolveLocalVitestMaxWorkers", () => {
1115
it("uses a moderate local worker cap on larger hosts", () => {
1216
expect(
@@ -204,12 +208,16 @@ describe("base vitest config", () => {
204208

205209
it("keeps the base setup file minimal", () => {
206210
expect(baseConfig.test?.setupFiles).toHaveLength(1);
207-
expect(baseConfig.test?.setupFiles?.[0]).toMatch(/(?:^|\/)test\/setup\.ts$/u);
211+
expect(normalizeConfigPath(baseConfig.test?.setupFiles?.[0])).toMatch(
212+
/(?:^|\/)test\/setup\.ts$/u,
213+
);
208214
});
209215

210216
it("keeps the base runner non-isolated by default", () => {
211217
expect(baseConfig.test?.isolate).toBe(false);
212-
expect(baseConfig.test?.runner).toMatch(/(?:^|\/)test\/non-isolated-runner\.ts$/u);
218+
expect(normalizeConfigPath(baseConfig.test?.runner)).toMatch(
219+
/(?:^|\/)test\/non-isolated-runner\.ts$/u,
220+
);
213221
});
214222
});
215223

@@ -221,9 +229,7 @@ describe("test scripts", () => {
221229
scripts?: Record<string, string>;
222230
};
223231

224-
expect(pkg.scripts?.["test:serial"]).toBe(
225-
"OPENCLAW_TEST_PROJECTS_SERIAL=1 OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/test-projects.mjs",
226-
);
232+
expect(pkg.scripts?.["test:serial"]).toBe("node scripts/test-projects-serial.mjs");
227233
expect(pkg.scripts?.["test:fast"]).toBe(
228234
"node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts",
229235
);

test/scripts/kitchen-sink-rpc-walk.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { describe, expect, it } from "vitest";
2-
import { assertResourceCeiling, sampleProcess } from "../../scripts/e2e/kitchen-sink-rpc-walk.mjs";
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
assertResourceCeiling,
4+
fetchJson,
5+
sampleProcess,
6+
} from "../../scripts/e2e/kitchen-sink-rpc-walk.mjs";
37

48
describe("kitchen-sink RPC process sampling", () => {
59
it("samples RSS on Windows instead of silently disabling the resource guard", async () => {
@@ -47,6 +51,25 @@ describe("kitchen-sink RPC process sampling", () => {
4751
expect(sample).toEqual({ cpuPercent: 12.5, rssMiB: 256 });
4852
});
4953

54+
it("retries transient loopback fetch resets from Windows HTTP probes", async () => {
55+
const reset = new TypeError("fetch failed", {
56+
cause: Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
57+
});
58+
const fetchImpl = vi
59+
.fn()
60+
.mockRejectedValueOnce(reset)
61+
.mockResolvedValueOnce(new Response('{"status":"live"}', { status: 200 }));
62+
63+
await expect(
64+
fetchJson("http://127.0.0.1:19680/healthz", {
65+
attempts: 2,
66+
fetchImpl,
67+
retryDelayMs: 0,
68+
}),
69+
).resolves.toEqual({ ok: true, status: 200, body: { status: "live" } });
70+
expect(fetchImpl).toHaveBeenCalledTimes(2);
71+
});
72+
5073
it("fails when the sampled RSS exceeds the configured ceiling", () => {
5174
expect(() => assertResourceCeiling({ rssMiB: 2049 })).toThrow(
5275
"gateway RSS exceeded 2048 MiB: 2049 MiB",

0 commit comments

Comments
 (0)