fix(e2e): keep cross-os response reads timed

This commit is contained in:
Vincent Koc 2026-06-19 06:31:27 +02:00
parent ea0b0ad0a0
commit a619518ebe
No known key found for this signature in database
2 changed files with 80 additions and 17 deletions

View file

@ -2554,6 +2554,7 @@ async function configureDiscordSmoke(params) {
export async function readBoundedCrossOsResponseText(
response: Response,
maxChars = CROSS_OS_FETCH_BODY_MAX_CHARS,
options: { signal?: AbortSignal | null } = {},
): Promise<string> {
if (!response.body) {
return "";
@ -2563,10 +2564,11 @@ export async function readBoundedCrossOsResponseText(
const decoder = new TextDecoder();
let text = "";
let truncated = false;
let aborted = false;
try {
while (text.length <= maxChars) {
const { done, value } = await reader.read();
const { done, value } = await readCrossOsResponseChunk(reader, options.signal);
if (done) {
text += decoder.decode();
break;
@ -2579,8 +2581,11 @@ export async function readBoundedCrossOsResponseText(
break;
}
}
} catch (error) {
aborted = options.signal?.aborted === true;
throw error;
} finally {
if (truncated) {
if (truncated || aborted) {
await reader.cancel().catch(() => undefined);
} else {
reader.releaseLock();
@ -2590,6 +2595,36 @@ export async function readBoundedCrossOsResponseText(
return truncated ? `${text}\n[truncated]` : text;
}
function readCrossOsResponseChunk(
reader: ReadableStreamDefaultReader<Uint8Array>,
signal?: AbortSignal | null,
): Promise<ReadableStreamReadResult<Uint8Array>> {
if (!signal) {
return reader.read();
}
if (signal.aborted) {
throw crossOsAbortReason(signal);
}
return new Promise((resolveRead, rejectRead) => {
const onAbort = () => rejectRead(crossOsAbortReason(signal));
signal.addEventListener("abort", onAbort, { once: true });
reader
.read()
.then(resolveRead, rejectRead)
.finally(() => {
signal.removeEventListener("abort", onAbort);
});
});
}
function crossOsAbortReason(signal: AbortSignal): Error {
const reason = signal.reason;
if (reason instanceof Error) {
return reason;
}
return new Error(typeof reason === "string" ? reason : "The operation was aborted.");
}
export function dashboardHtmlMarkerStatus(html: string): {
app: boolean;
ready: boolean;
@ -2647,11 +2682,12 @@ async function waitForDiscordMessage(params) {
let response;
let text;
try {
const init = buildDiscordFetchInit(params.token);
response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
buildDiscordFetchInit(params.token),
init,
);
text = await readBoundedCrossOsResponseText(response);
text = await readBoundedCrossOsResponseText(response, undefined, { signal: init.signal });
} catch {
await sleep(2_000);
continue;
@ -2680,20 +2716,21 @@ export function buildDiscordFetchInit(token, init = {}) {
}
async function postDiscordMessage(params) {
const init = buildDiscordFetchInit(params.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: params.content,
flags: 4096,
}),
});
const response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages`,
buildDiscordFetchInit(params.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: params.content,
flags: 4096,
}),
}),
init,
);
const text = await readBoundedCrossOsResponseText(response);
const text = await readBoundedCrossOsResponseText(response, undefined, { signal: init.signal });
if (!response.ok) {
throw new Error(`Failed to post Discord smoke message: ${text}`);
}
@ -3525,10 +3562,11 @@ async function runDashboardSmoke(params) {
attempt += 1;
logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`);
try {
const signal = AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS);
const response = await fetch(dashboardUrl, {
signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS),
signal,
});
const html = await readBoundedCrossOsResponseText(response);
const html = await readBoundedCrossOsResponseText(response, undefined, { signal });
const markers = dashboardHtmlMarkerStatus(html);
const assetUrls = resolveDashboardAssetUrls(dashboardUrl, html);
if (response.ok && markers.ready) {

View file

@ -166,6 +166,31 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
expect(CROSS_OS_FETCH_BODY_MAX_CHARS).toBeGreaterThan(1024);
});
it("keeps cross-OS fetch timeouts active while reading response bodies", async () => {
let canceled = false;
const abortController = new AbortController();
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("partial"));
},
cancel() {
canceled = true;
},
}),
);
const text = readBoundedCrossOsResponseText(response, 1024, {
signal: abortController.signal,
});
await delay(0);
abortController.abort(new Error("cross-os body timed out"));
await expect(text).rejects.toThrow("cross-os body timed out");
expect(canceled).toBe(true);
});
it("requires dashboard root markers and same-origin asset URLs", () => {
const html = [
"<title>OpenClaw Control</title>",