Merge branch 'main' into meow/workboard-modal-drawer-a11y

This commit is contained in:
Val Alexander
2026-06-03 06:10:10 -05:00
committed by GitHub
11 changed files with 441 additions and 72 deletions
+2
View File
@@ -61,6 +61,8 @@ Docs: https://docs.openclaw.ai
- Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through.
- Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open.
- Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup.
- Release/CI/E2E: exit Telegram credential proof wrappers promptly after forwarded shutdown signals while keeping the descendant force-kill guard armed.
- Release/CI/E2E: reject oversized ClickClack fixture request bodies before release journey smokes can accumulate unbounded payloads.
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
- Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind.
- Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue.
@@ -48,6 +48,8 @@ const bundleMcpThreadConfig = {
fingerprint: undefined,
} satisfies CodexBundleMcpThreadConfig;
const HARNESS_REQUEST_TIMEOUT_MS = 15_000;
function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: string }> {
return writes.map((write) => JSON.parse(write) as { id?: number; method?: string });
}
@@ -105,7 +107,7 @@ function startThreadWithHarness(
async function answerInitialize(harness: ClientHarness): Promise<void> {
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1), {
interval: 1,
timeout: 5_000,
timeout: HARNESS_REQUEST_TIMEOUT_MS,
});
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.125.0 (macOS; test)" } });
@@ -120,7 +122,7 @@ async function waitForRequest(
expect(readHarnessMessages(harness.writes).some((write) => write.method === method)).toBe(
true,
),
{ interval: 1, timeout: 5_000 },
{ interval: 1, timeout: HARNESS_REQUEST_TIMEOUT_MS },
);
const request = readHarnessMessages(harness.writes).find((write) => write.method === method);
if (!request) {
@@ -238,9 +240,10 @@ describe("startCodexAttemptThread", () => {
harness: retained,
skipStartSpy: true,
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
const threadStart = await waitForThreadStart(retained);
await expect(run).rejects.toThrow("codex app-server startup timed out");
await rejected;
expect(retained.process.stdin.destroyed).toBe(false);
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
@@ -249,11 +252,18 @@ describe("startCodexAttemptThread", () => {
});
it("closes the shared app-server when startup times out during initialize", async () => {
const { harness, run } = startThreadWithHarness(100);
const { harness, run } = startThreadWithHarness(2_000);
const runError = run.then(
() => undefined,
(error: unknown) => error,
);
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
const initialize = await waitForRequest(harness, "initialize");
expect(initialize.id).toBeDefined();
await expect(run).rejects.toThrow("codex app-server startup timed out");
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("codex app-server startup timed out");
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
@@ -270,19 +280,29 @@ describe("startCodexAttemptThread", () => {
abandonSignal?: AbortSignal;
}
| undefined;
let resolveFactoryDone: () => void = () => undefined;
const factoryDone = new Promise<void>((resolve) => {
resolveFactoryDone = resolve;
});
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
attemptClientFactory:
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
try {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
} finally {
resolveFactoryDone();
}
},
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await expect(run).rejects.toThrow("codex app-server startup timed out");
await rejected;
await factoryDone;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
@@ -296,7 +316,7 @@ describe("startCodexAttemptThread", () => {
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
const abortController = new AbortController();
const { harness, run } = startThreadWithHarness(5_000, abortController.signal);
const { harness, run } = startThreadWithHarness(30_000, abortController.signal);
const runError = run.then(
() => undefined,
(error: unknown) => error,
@@ -408,9 +408,10 @@ describe("CodexAppServerClient", () => {
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// Simulate the child process closing its pipe: stdin emits an asynchronous
// EPIPE error before the transport observes a process exit.
const pipeError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
harness.process.stdin.emit("error", pipeError);
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
@@ -43,7 +43,9 @@ export function createClientHarness() {
const result = destroyStdin(error);
if (!exitEmitted) {
exitEmitted = true;
queueMicrotask(emitProcessExit);
// Let stdin surface pipe errors before the harness emits the fake child exit.
// Otherwise close-reason tests can race EPIPE against a synthetic clean exit.
setImmediate(emitProcessExit);
}
return result;
}) as typeof stdin.destroy;
@@ -4,6 +4,7 @@ import http from "node:http";
import { readPositiveIntEnv } from "../env-limits.mjs";
const port = readPositiveIntEnv("CLICKCLACK_FIXTURE_PORT", 44181);
const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024);
const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token";
const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json";
const workspace = {
@@ -86,21 +87,65 @@ function checkAuth(req, res) {
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
let bytes = 0;
let settled = false;
req.setEncoding("utf8");
req.on("data", (chunk) => {
if (settled) {
return;
}
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > requestMaxBytes) {
settled = true;
body = "";
req.resume();
reject(requestBodyTooLargeError());
return;
}
body += chunk;
});
req.on("end", () => {
if (settled) {
return;
}
settled = true;
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
req.on("error", reject);
req.on("error", (error) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
}
function requestBodyTooLargeError() {
return Object.assign(new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`), {
code: "ETOOBIG",
});
}
function isRequestBodyTooLargeError(error) {
return error instanceof Error && error.code === "ETOOBIG";
}
function handleRequestError(res, error) {
if (res.headersSent) {
res.destroy();
return;
}
if (isRequestBodyTooLargeError(error)) {
json(res, 413, { error: error.message });
return;
}
json(res, 500, { error: String(error instanceof Error ? error.message : error) });
}
function createMessage({ body, author = humanUser, parentMessageId }) {
messageSeq += 1;
const id = `msg_${messageSeq}`;
@@ -171,8 +216,8 @@ function broadcast(event) {
}
}
const server = http.createServer((req, res) => {
void (async () => {
async function handleRequest(req, res) {
try {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!checkAuth(req, res)) {
return;
@@ -244,7 +289,13 @@ const server = http.createServer((req, res) => {
return;
}
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
})();
} catch (error) {
handleRequestError(res, error);
}
}
const server = http.createServer((req, res) => {
void handleRequest(req, res);
});
server.on("upgrade", (req, socket) => {
+34 -4
View File
@@ -20,7 +20,7 @@ type RunCommandOptions = {
const DEFAULT_OUTPUT_LIMIT = 128 * 1024;
const DEFAULT_FETCH_BODY_LIMIT = 1024 * 1024;
const KILL_GRACE_MS = 5_000;
const KILL_GRACE_MS = readKillGraceMs();
const SIGNAL_EXIT_CODES = {
SIGHUP: 129,
SIGINT: 130,
@@ -30,11 +30,38 @@ const ACTIVE_CHILD_TREE_KILLERS = new Set<(signal: NodeJS.Signals) => void>();
let forwardedSignalExitCode: number | undefined;
let forwardedSignalForceKillTimer: NodeJS.Timeout | undefined;
function readKillGraceMs() {
const raw = process.env.OPENCLAW_QA_CREDENTIAL_KILL_GRACE_MS?.trim();
if (!raw) {
return 5_000;
}
if (!/^\d+$/u.test(raw)) {
throw new Error(`OPENCLAW_QA_CREDENTIAL_KILL_GRACE_MS must be a non-negative integer; got: ${raw}`);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`OPENCLAW_QA_CREDENTIAL_KILL_GRACE_MS must be a non-negative integer; got: ${raw}`);
}
return parsed;
}
function finishForwardedSignalIfIdle() {
if (forwardedSignalExitCode === undefined || ACTIVE_CHILD_TREE_KILLERS.size > 0) {
return;
}
if (forwardedSignalForceKillTimer) {
clearTimeout(forwardedSignalForceKillTimer);
forwardedSignalForceKillTimer = undefined;
}
process.exit(forwardedSignalExitCode);
}
for (const signal of Object.keys(SIGNAL_EXIT_CODES) as Array<keyof typeof SIGNAL_EXIT_CODES>) {
process.on(signal, () => {
forwardedSignalExitCode ??= SIGNAL_EXIT_CODES[signal];
if (ACTIVE_CHILD_TREE_KILLERS.size === 0) {
process.exit(forwardedSignalExitCode);
finishForwardedSignalIfIdle();
return;
}
const activeKillers = Array.from(ACTIVE_CHILD_TREE_KILLERS);
for (const killChildTree of activeKillers) {
@@ -156,7 +183,7 @@ export function runCommand(
return;
}
if (forwardedSignalExitCode !== undefined) {
activeChildTree.unregister();
activeChildTree.unregister({ finishForwardedSignal: !childProcessTreeMayStillExist(child) });
return;
}
if (timedOutError && killTimer && childProcessTreeMayStillExist(child)) {
@@ -218,8 +245,11 @@ function registerActiveChildProcessTree(child: ReturnType<typeof spawn>) {
ACTIVE_CHILD_TREE_KILLERS.add(killChildTree);
return {
killChildTree,
unregister: () => {
unregister: (options: { finishForwardedSignal?: boolean } = {}) => {
ACTIVE_CHILD_TREE_KILLERS.delete(killChildTree);
if (options.finishForwardedSignal ?? true) {
finishForwardedSignalIfIdle();
}
},
};
}
+1 -1
View File
@@ -1384,7 +1384,7 @@ describe("Code Mode", () => {
tools: {
codeMode: {
enabled: true,
timeoutMs: 100,
timeoutMs: 500,
},
},
} as never;
+2 -2
View File
@@ -11,11 +11,11 @@ type FixtureLogEntry = {
};
const activeRuns: PtyRun[] = [];
const STARTUP_TIMEOUT_MS = 10_000;
const STARTUP_TIMEOUT_MS = 20_000;
const OUTPUT_TIMEOUT_MS = 2_000;
const EXIT_TIMEOUT_MS = 4_000;
const TEST_TIMEOUT_MS = 5_000;
const STARTUP_TEST_TIMEOUT_MS = 10_000;
const STARTUP_TEST_TIMEOUT_MS = 25_000;
async function readFixtureLog(logPath: string): Promise<FixtureLogEntry[]> {
try {
@@ -1,5 +1,8 @@
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
const browserFixturePath = "scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs";
@@ -57,6 +60,45 @@ async function listen(server: Server): Promise<string> {
return `http://127.0.0.1:${address.port}`;
}
async function allocatePort(): Promise<number> {
const server = createServer();
const url = await listen(server);
await new Promise<void>((resolve) => server.close(() => resolve()));
return Number(new URL(url).port);
}
async function waitForOutput(
child: ReturnType<typeof spawn>,
matches: (text: string) => boolean,
getOutput: () => string,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < 3_000) {
if (matches(getOutput())) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`timed out waiting for fixture output. Output: ${getOutput()}`);
}
async function stopChild(child: ReturnType<typeof spawn>): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 1_000);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
describe("e2e helper numeric env limits", () => {
it("rejects loose Browser CDP fixture ports", async () => {
const result = await runScriptAsync(browserFixturePath, [], { FIXTURE_PORT: "18080http" });
@@ -74,6 +116,49 @@ describe("e2e helper numeric env limits", () => {
expect(result.stderr).toContain("invalid CLICKCLACK_FIXTURE_PORT: 44181tcp");
});
it("rejects oversized ClickClack fixture request bodies", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-clickclack-fixture-"));
const port = await allocatePort();
const child = spawn(process.execPath, [clickclackFixturePath], {
env: {
...process.env,
CLICKCLACK_FIXTURE_PORT: String(port),
CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES: "16",
CLICKCLACK_FIXTURE_STATE: path.join(tempDir, "state.json"),
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.stderr.on("data", (chunk) => {
output += chunk;
});
try {
await waitForOutput(
child,
(text) => text.includes(`clickclack fixture listening on ${port}`),
() => output,
);
const response = await fetch(`http://127.0.0.1:${port}/fixture/inbound`, {
body: JSON.stringify({ body: "x".repeat(64) }),
headers: { "content-type": "application/json" },
method: "POST",
});
const body = await response.json();
expect(response.status).toBe(413);
expect(body).toEqual({ error: "ClickClack fixture request body exceeded 16 bytes" });
} finally {
await stopChild(child);
fs.rmSync(tempDir, { force: true, recursive: true });
}
});
it("rejects loose Open WebUI HTTP probe timeouts", () => {
const result = runScript(httpProbePath, ["http://127.0.0.1:9"], {
OPENCLAW_HTTP_PROBE_TIMEOUT_MS: "8000ms",
+152 -21
View File
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -53,6 +54,24 @@ async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
throw new Error(`process still alive: ${pid}`);
}
async function waitForExit(
child: ReturnType<typeof spawn>,
timeoutMs: number,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
if (child.exitCode !== null || child.signalCode !== null) {
return { code: child.exitCode, signal: child.signalCode as NodeJS.Signals | null };
}
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`process did not exit within ${timeoutMs}ms`));
}, timeoutMs);
child.once("exit", (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
});
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
@@ -147,36 +166,86 @@ setInterval(() => {}, 1000);
},
);
it.runIf(process.platform !== "win32")("kills timed-out child process groups", async () => {
const dir = makeTempDir("openclaw-telegram-credential-tree-timeout-");
const childPidPath = path.join(dir, "child.pid");
let childPid: number | undefined;
try {
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`,
"setInterval(() => {}, 1000);",
].join("");
const runPromise = runCommand(process.execPath, ["-e", parentScript], dir, {
timeoutKillGraceMs: 25,
timeoutMs: 100,
});
await waitForFile(childPidPath, 2_000);
childPid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10);
await expect(runPromise).rejects.toMatchObject({
code: "ETIMEDOUT",
message: expect.stringContaining("timed out after 100ms"),
});
await waitForDead(childPid, 2_000);
} finally {
if (childPid !== undefined && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
}
});
it.runIf(process.platform !== "win32")(
"kills timed-out child process groups",
"exits promptly after forwarded SIGTERM children exit cleanly",
async () => {
const dir = makeTempDir("openclaw-telegram-credential-tree-timeout-");
const dir = makeTempDir("openclaw-telegram-credential-signal-");
const runnerPath = path.join(dir, "runner.mjs");
const readyPath = path.join(dir, "ready.txt");
const childPidPath = path.join(dir, "child.pid");
const ioModuleUrl = new URL(
"../../scripts/e2e/telegram-user-credential-io.ts",
import.meta.url,
).href;
const childScript = [
"const fs = require('node:fs');",
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
`fs.writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("");
writeFileSync(
runnerPath,
[
`import { runCommand } from ${JSON.stringify(ioModuleUrl)};`,
`await runCommand(process.execPath, ['-e', ${JSON.stringify(childScript)}], undefined, { timeoutMs: 30_000 });`,
"",
].join("\n"),
"utf8",
);
const runner = spawn(process.execPath, ["--import", "tsx", runnerPath], {
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let childPid: number | undefined;
try {
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`,
"setInterval(() => {}, 1000);",
].join("");
const runPromise = runCommand(process.execPath, ["-e", parentScript], dir, {
timeoutKillGraceMs: 25,
timeoutMs: 100,
});
await waitForFile(childPidPath, 2_000);
await waitForFile(readyPath, 2_000);
childPid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10);
const startedAt = Date.now();
runner.kill("SIGTERM");
const exit = await waitForExit(runner, 2_000);
await expect(runPromise).rejects.toMatchObject({
code: "ETIMEDOUT",
message: expect.stringContaining("timed out after 100ms"),
});
expect(exit).toEqual({ code: 143, signal: null });
expect(Date.now() - startedAt).toBeLessThan(1_500);
await waitForDead(childPid, 2_000);
} finally {
if (runner.exitCode === null && runner.signalCode === null) {
runner.kill("SIGKILL");
}
if (childPid !== undefined && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
@@ -184,6 +253,68 @@ setInterval(() => {}, 1000);
},
);
it.runIf(process.platform !== "win32")(
"keeps the forwarded signal force-kill armed while grandchildren survive",
async () => {
const dir = makeTempDir("openclaw-telegram-credential-grandchild-signal-");
const runnerPath = path.join(dir, "runner.mjs");
const readyPath = path.join(dir, "ready.txt");
const grandchildPidPath = path.join(dir, "grandchild.pid");
const ioModuleUrl = new URL(
"../../scripts/e2e/telegram-user-credential-io.ts",
import.meta.url,
).href;
const grandchildScript = [
"const fs = require('node:fs');",
`fs.writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("");
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const grandchild = spawn(process.execPath, ['-e', ${JSON.stringify(grandchildScript)}], { stdio: 'ignore' });`,
`fs.writeFileSync(${JSON.stringify(readyPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("");
writeFileSync(
runnerPath,
[
`import { runCommand } from ${JSON.stringify(ioModuleUrl)};`,
`await runCommand(process.execPath, ['-e', ${JSON.stringify(parentScript)}], undefined, { timeoutMs: 30_000 });`,
"",
].join("\n"),
"utf8",
);
const runner = spawn(process.execPath, ["--import", "tsx", runnerPath], {
env: {
...process.env,
OPENCLAW_QA_CREDENTIAL_KILL_GRACE_MS: "100",
},
stdio: ["ignore", "pipe", "pipe"],
});
let grandchildPid: number | undefined;
try {
await waitForFile(readyPath, 2_000);
await waitForFile(grandchildPidPath, 2_000);
grandchildPid = Number.parseInt(readFileSync(grandchildPidPath, "utf8"), 10);
runner.kill("SIGTERM");
const exit = await waitForExit(runner, 2_000);
expect(exit).toEqual({ code: 143, signal: null });
await waitForDead(grandchildPid, 2_000);
} finally {
if (runner.exitCode === null && runner.signalCode === null) {
runner.kill("SIGKILL");
}
if (grandchildPid !== undefined && isProcessAlive(grandchildPid)) {
process.kill(grandchildPid, "SIGKILL");
}
}
},
);
it("aborts broker fetches that never return", async () => {
let signal: AbortSignal | undefined;
await expect(
+69 -22
View File
@@ -108,6 +108,35 @@ async function controlUiEventPayloads(
}, event);
}
async function waitForControlUiChatSendPhases(
page: Page,
runId: string,
phases: string[],
): Promise<void> {
await page.waitForFunction(
({ expectedPhases, expectedRunId }) => {
const app = document.querySelector("openclaw-app") as
| (Element & { eventLogBuffer?: unknown[] })
| null;
const observedPhases = new Set(
(app?.eventLogBuffer ?? []).flatMap((entry) => {
const candidate = entry as {
event?: unknown;
payload?: { phase?: unknown; runId?: unknown };
};
return candidate.event === "control-ui.chat.send" &&
candidate.payload?.runId === expectedRunId &&
typeof candidate.payload.phase === "string"
? [candidate.payload.phase]
: [];
}),
);
return expectedPhases.every((phase) => observedPhases.has(phase));
},
{ expectedPhases: phases, expectedRunId: runId },
);
}
function chatSessionListResponse() {
return {
count: 2,
@@ -344,6 +373,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
await waitForControlUiChatSendPhases(page, runId, ["ack"]);
await gateway.emitGatewayEvent("chat", {
deltaText: "First token visible.",
message: {
@@ -357,28 +387,45 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
state: "delta",
});
await page.getByText("First token visible.").waitFor({ timeout: 10_000 });
await page.waitForFunction((expectedRunId) => {
const app = document.querySelector("openclaw-app") as
| (Element & { eventLogBuffer?: unknown[] })
| null;
return (app?.eventLogBuffer ?? []).some((entry) => {
const candidate = entry as {
event?: unknown;
payload?: { phase?: unknown; runId?: unknown };
};
return (
candidate.event === "control-ui.chat.send" &&
candidate.payload?.phase === "first-assistant-visible" &&
candidate.payload.runId === expectedRunId
);
});
}, runId);
const firstOutputEvents = await controlUiEventPayloads(page, "control-ui.chat.send");
expect(
firstOutputEvents.some(
(payload) => payload.phase === "first-assistant-visible" && payload.runId === runId,
),
).toBe(true);
await waitForControlUiChatSendPhases(page, runId, [
"pending-visible",
"request-start",
"ack",
"first-assistant-visible",
]);
const sendTimingEvents = (await controlUiEventPayloads(page, "control-ui.chat.send")).filter(
(payload) => payload.runId === runId,
);
const sendTimingByPhase = new Map(
sendTimingEvents.map((payload) => [payload.phase, payload]),
);
expect(sendTimingEvents.map((payload) => payload.phase)).toEqual(
expect.arrayContaining([
"pending-visible",
"request-start",
"ack",
"first-assistant-visible",
]),
);
const ackTiming = sendTimingByPhase.get("ack");
expect(ackTiming).toMatchObject({
ackStatus: "started",
runId,
sendState: "sending",
sessionKey: "global",
});
expect(ackTiming?.requestDurationMs).toEqual(expect.any(Number));
const firstVisibleTiming = sendTimingByPhase.get("first-assistant-visible");
expect(firstVisibleTiming).toMatchObject({
ackStatus: "started",
eventState: "delta",
runId,
sendState: "sending",
sessionKey: "global",
});
expect(firstVisibleTiming?.ackToFirstAssistantEventMs).toEqual(expect.any(Number));
expect(firstVisibleTiming?.firstAssistantPaintMs).toEqual(expect.any(Number));
expect(firstVisibleTiming?.requestToFirstAssistantEventMs).toEqual(expect.any(Number));
await gateway.resolveDeferred("chat.startup", {
agentsList: {
agents: [{ id: "ops", name: "OpenClaw" }],