fix(qa): Inspector collection fails when a pairing link is reused (#161540)

* fix(qa): pair Inspector pages with fresh dashboard links

Acquire a fresh single-use dashboard handoff for every Inspector page.
Cover real pairing, replay rejection, identity and receipt selection,
reload, and Chat draft preservation in a release-only browser fixture.

Real Gateway proof: 81.29s wall including prerequisites, 17.84s test body.

* fix(ci): complete Inspector pairing fixture inventories

Align private-server discovery and non-release counts with the Inspector
fixture, and include it in the frozen-target fallback command. Shorten
nearby comments to preserve the existing workflow size limit.

Workflow guards: 137 passed, 8 skipped; 85.65s wall.
Focused routing: 6 passed; 11.77s wall.

* fix(ci): include preflight manifest in trusted checkout
This commit is contained in:
Josh Avant 2026-09-30 01:48:56 -05:00 • committed by GitHub
parent 7d409f750d
commit 63144fe0c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 320 additions and 4 deletions

View file

@ -2436,6 +2436,7 @@ jobs:
ui/src/e2e/profile-page.real-gateway.e2e.test.ts \
ui/src/e2e/quota-reset-status.real-gateway.e2e.test.ts \
ui/src/e2e/logs-lifecycle.e2e.test.ts \
ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts \
ui/src/e2e/agent-file-lifecycle.real-gateway.e2e.test.ts \
ui/src/e2e/chat-collaborator-scroll.real-gateway.e2e.test.ts \
ui/src/e2e/chat-composer-websearch-kill-switch.real-gateway.e2e.test.ts \

View file

@ -30,6 +30,14 @@ read_when:
### Real-Gateway Control UI fixture lifetimes
Use `pairControlUiPage` from `ui/src/test-helpers/control-ui-browser-pairing.ts`
with the isolated Gateway's CLI runner to authenticate each new page. It obtains
and consumes a fresh `dashboard --json` browser handoff and waits for the Gateway
handshake. Dashboard pairing links are single-use; do not reuse a captured URL
for another tab or browser context. Pass this operation as `preparePage` to
`withControlUiRunInspector` so collection authenticates its own page while leaving
the caller's Chat and draft in place. Reload uses the browser's paired credential.
Use `createControlUiE2eSuite` from
`ui/src/e2e/control-ui-e2e-suite.test-support.ts` for real-Gateway browser fixtures.
`suite.define(...)` owns the native hooks. Each native `it` passes its test context

View file

@ -1569,6 +1569,7 @@ const KEEP_LARGE_NODE_TEST_RUNNER = new Set([
const RELEASE_ONLY_PLUGIN_SHARDS = new Set(["agentic-plugins"]);
const RELEASE_ONLY_TOOLING_SHARDS = new Set(["core-tooling"]);
const RELEASE_ONLY_UI_TEST_FILES = new Set([
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
"ui/src/e2e/board-fixture.e2e.test.ts",
"ui/src/e2e/chat-attachment-menu.e2e.test.ts",
"ui/src/e2e/chat-mobile-bubble-margin.e2e.test.ts",

View file

@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { EOL, tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { expectDefined } from "@openclaw/normalization-core/expect";
import { beforeAll, expect, vi } from "vitest";
@ -9,6 +9,7 @@ import { parse } from "yaml";
import { createCommandTest } from "../helpers/command-fixture.js";
import { readCiCheckoutStep, renderGitTestClock } from "./ci-checkout.test-support.js";
import { runCiGitStep, type FetchResult } from "./ci-git-owner.test-support.js";
import { runDependencyFreePreflight } from "./ci-preflight-dependencies.test-support.js";
// Each case owns its checkout and process trees. Overlap their real timeout and
// drain waits, but keep subprocess pressure bounded on the four-core CI runner.
@ -572,6 +573,68 @@ releasePolicyIt("returns 124 when the release ancestry total budget is exhausted
expect(report.commands).toEqual([]);
});
it("materializes an executable preflight manifest from the workflow revision", async ({
command,
}) => {
const root = command.createTempDir("ci-preflight-harness-");
const origin = join(root, "origin");
const workspace = join(root, "checkout");
mkdirSync(origin);
mkdirSync(workspace);
fixtureGit(origin, ["init", "--quiet"]);
for (const file of [
".github/actions/setup-node-env/action.yml",
".github/actions/git-owner/test-prerequisites.mjs",
".github/actions/git-owner/test-prerequisites.json",
"scripts/ci-build-manifest.mjs",
"scripts/lib/release-context.mjs",
"scripts/lib/release-version.mjs",
]) {
const destination = join(origin, file);
mkdirSync(dirname(destination), { recursive: true });
writeFileSync(destination, readFileSync(file));
}
fixtureGit(origin, ["add", "."]);
const tree = fixtureGit(origin, ["write-tree"]);
const revision = fixtureCommit(join(origin, ".git"), tree, undefined, "workflow fixture");
fixtureGit(origin, ["update-ref", "HEAD", revision]);
const gitConfig = join(root, "gitconfig");
writeFileSync(gitConfig, "");
const checkout = await command.run(
process.platform === "win32" ? "python" : "python3",
["-I", "-S", gitOwnerPath],
{
cwd: workspace,
env: {
...process.env,
CHECKOUT_KIND: "preflight",
CHECKOUT_REPO: "fixture/preflight",
CHECKOUT_TOKEN: "",
CHECKOUT_REF: revision,
CHECKOUT_FALLBACK_REF: revision,
WORKFLOW_SHA: revision,
GITHUB_WORKSPACE: workspace,
GITHUB_EVENT_NAME: "pull_request",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: gitConfig,
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${pathToFileURL(origin).href}.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/fixture/preflight.git",
},
},
);
expect(checkout.status, `${checkout.stdout}\n${checkout.stderr}`).toBe(0);
// Consume the exported trusted entrypoint against the real target planners.
const { result, manifest } = runDependencyFreePreflight(
pathToFileURL(join(workspace, ".ci-harness/scripts/ci-build-manifest.mjs")),
root,
process.execPath,
);
expect(result.status, result.stderr).toBe(0);
expect(manifest).toContain("run_windows=true\n");
expect(fixtureGit(workspace, ["status", "--porcelain"])).toBe("");
});
// Ask Bash to decode the source independently of the generator and fixture codec.
it("keeps exactly one byte-identical generated CI owner", () => {
const workflow = readFileSync(".github/workflows/ci.yml", "utf8");

View file

@ -105,6 +105,7 @@ describe("Control UI release-only inventories", () => {
const automationManagement =
"extensions/qa-lab/src/control-ui-automation-management.real-gateway.e2e.test.ts";
const releaseOnlyRealGateway = new Set([
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
"ui/src/e2e/cron-duration-save.real-gateway.e2e.test.ts",
"ui/src/e2e/desktop-resize.real-gateway.e2e.test.ts",
automationManagement,

View file

@ -107,6 +107,7 @@ export function assertControlUiE2eOwnership(
expect(privateServerFiles).toEqual(uiE2ePrivateServerTestFiles);
expect(helperPrivateServerFiles.toSorted()).toEqual([
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
"ui/src/e2e/agent-file-lifecycle.real-gateway.e2e.test.ts",
"ui/src/e2e/agent-switch-roster.e2e.test.ts",
"ui/src/e2e/chat-agent-avatar.real-gateway.e2e.test.ts",

View file

@ -5371,7 +5371,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
.toSorted(),
);
if (releaseTier === false) {
expect(selectedFiles).toHaveLength(uiE2eRealGatewayTestFiles.length - 11);
expect(selectedFiles).toHaveLength(uiE2eRealGatewayTestFiles.length - 12);
expect(selectedFiles).not.toContain(
"ui/src/e2e/cron-duration-save.real-gateway.e2e.test.ts",
);

View file

@ -111,6 +111,7 @@ const qaLabFiles = [
"extensions/qa-lab/src/control-ui-openclaw-delegation.real-gateway.e2e.test.ts",
] as const;
const realGatewayFiles = [
"activity-run-inspector.real-gateway",
"agent-file-lifecycle.real-gateway",
"chat-agent-avatar.real-gateway",
"chat-collaborator-scroll.real-gateway",
@ -669,7 +670,7 @@ describe("Control UI E2E resource ownership", () => {
},
]);
const parallel = result.files.filter((entry) => entry.phase === 2);
expect(parallel).toHaveLength(26);
expect(parallel).toHaveLength(27);
expect(parallel.every((entry) => entry.fileParallelism)).toBe(true);
expect(parallel.every((entry) => entry.workers === result.rootWorkers)).toBe(true);
for (const entry of parallel) {

View file

@ -20,6 +20,7 @@ const uiE2eIncludePatterns = [
// These files own their server instead of leasing the global production bundle.
// Keep any shared source-module optimizer cache under one worker.
export const uiE2ePrivateServerTestFiles = [
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
"ui/src/e2e/agent-file-lifecycle.real-gateway.e2e.test.ts",
"ui/src/e2e/agent-switch-roster.e2e.test.ts",
"ui/src/e2e/approval-bootstrap.e2e.test.ts",

View file

@ -59,6 +59,7 @@ export function isUiTestTarget(relative) {
}
export const uiE2eRealGatewayTestFiles = [
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
"ui/src/e2e/session-roster-request-rate.real-gateway.e2e.test.ts",
"ui/src/e2e/quota-reset-status.real-gateway.e2e.test.ts",
"ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts",
@ -100,6 +101,7 @@ export const uiE2eRealGatewayTestFiles = [
// Listed fixtures own their HOME, state, ports, and cleanup; UI bytes are either
// borrowed from the invocation preview or read by their prepared Gateway child.
export const uiE2ePrebuiltParallelTestFiles = [
"ui/src/e2e/activity-run-inspector.real-gateway.e2e.test.ts",
"ui/src/e2e/agent-file-lifecycle.real-gateway.e2e.test.ts",
"ui/src/e2e/chat-agent-avatar.real-gateway.e2e.test.ts",
"ui/src/e2e/chat-composer-websearch-kill-switch.real-gateway.e2e.test.ts",

View file

@ -0,0 +1,220 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { expect, it } from "vitest";
import type { AuditRunInspectResult } from "../../../packages/gateway-protocol/src/schema/audit-run.js";
import type {
ErrorShape,
GatewayFrame,
} from "../../../packages/gateway-protocol/src/schema/frames.js";
import { reserveTestPortListener } from "../../../src/test-utils/port-claims.js";
import { writeOpenAiResponsesText } from "../../../test/helpers/openai-responses-sse.ts";
import {
createOpenClawTestInstance,
type OpenClawTestInstance,
} from "../../../test/helpers/openclaw-test-instance.ts";
import { runQaGatewayFixture } from "../../../test/helpers/qa-gateway-cleanup.ts";
import { pairControlUiPage } from "../test-helpers/control-ui-browser-pairing.ts";
import { waitForControlUiGatewayReady } from "../test-helpers/control-ui-e2e-readiness.ts";
import { controlUiSessionUrl } from "../test-helpers/control-ui-e2e.ts";
import { withControlUiRunInspector } from "../test-helpers/control-ui-run-inspector.ts";
import {
createControlUiE2eContextOptions,
createControlUiE2eSuite,
} from "./control-ui-e2e-suite.test-support.ts";
const replyText = "Inspector pairing fixture completed.";
let instance: OpenClawTestInstance;
const suite = createControlUiE2eSuite({
name: "Run Inspector with real browser pairing",
startServerBeforeBrowser: true,
async startServer() {
const provider = await reserveTestPortListener({
offsets: [0],
createListener: () =>
createServer((request, response) => {
request.resume().once("end", () => {
if (request.method !== "POST" || request.url !== "/v1/responses") {
response.writeHead(404).end();
return;
}
writeOpenAiResponsesText(response, {
text: replyText,
messageId: "inspector-message",
responseId: "inspector-response",
});
});
}),
});
const cleanup = () =>
runQaGatewayFixture(
() => instance?.cleanup(),
async () => {
provider.listener.closeAllConnections();
await provider.releaseListener();
},
() => provider.claim.release(),
);
try {
instance = await createOpenClawTestInstance({
name: "inspector-pairing",
env: { OPENCLAW_TEST_MINIMAL_GATEWAY: undefined, VITEST: undefined },
config: {
gateway: { controlUi: { enabled: true } },
logging: { audit: { executionIdentity: true } },
cron: { enabled: false },
agents: {
ownership: "explicit",
defaults: {
model: "inspector-fixture/echo",
modelPolicy: { allow: ["inspector-fixture/*"] },
},
entries: { main: { identity: { name: "Inspector fixture" } } },
},
models: {
catalogRefresh: { enabled: false },
providers: {
"inspector-fixture": {
api: "openai-responses",
apiKey: "synthetic-unused-key",
baseUrl: `http://127.0.0.1:${provider.claim.port}/v1`,
models: [{ id: "echo", name: "Echo" }],
},
},
},
plugins: { allow: [] },
},
});
await instance.startGateway();
return { baseUrl: `http://127.0.0.1:${instance.port}/`, close: cleanup };
} catch (error) {
return runQaGatewayFixture(async () => {
throw error;
}, cleanup);
}
},
});
async function runCli(args: string[]): Promise<string> {
const result = await instance.cli(args);
expect(result.code, result.stderr).toBe(0);
return result.stdout;
}
async function rpc(method: string, params: Record<string, unknown>) {
return JSON.parse(
await runCli(["gateway", "call", method, "--params", JSON.stringify(params), "--json"]),
);
}
suite.define(() => {
it("pairs each Inspector page independently and retains identity across reload", async (test) => {
await suite.runScenario(test, {
retainedState: () => instance.stateDir,
run: async () => {
const sessionKey = "agent:main:inspector-pairing";
await rpc("sessions.create", { key: sessionKey, agentId: "main" });
const runId = randomUUID();
await rpc("chat.send", {
sessionKey,
message: "Complete the fixture.",
idempotencyKey: runId,
});
expect(await rpc("agent.wait", { runId, timeoutMs: 30_000 })).toMatchObject({
status: "ok",
terminalReply: { disposition: "visible", text: replyText },
});
const inspected: AuditRunInspectResult = await rpc("audit.run.inspect", { runId });
if (inspected.identity.state !== "present") {
throw new Error(`Fixture identity is ${inspected.identity.state}`);
}
const { executionId } = inspected.identity.context;
const receipt = inspected.decisionDisplays.find(
(display) =>
display.provenance.state === "verified" &&
display.provenance.producer === "run-admission",
);
if (!receipt) {
throw new Error("Fixture run admission receipt is missing");
}
const { browserUrl }: { browserUrl: string } = JSON.parse(
await runCli(["dashboard", "--json"]),
);
const context = await suite.newBrowserContext(createControlUiE2eContextOptions());
const freshContext = await suite.newBrowserContext(createControlUiE2eContextOptions());
for (const browserContext of [context, freshContext]) {
await browserContext.addInitScript(() => {
localStorage.setItem("openclaw:control-ui:community-invite", "dismissed");
});
}
const chat = await context.newPage();
// Keep this handoff only for the explicit replay rejection below.
await chat.goto(browserUrl);
await waitForControlUiGatewayReady(chat);
await chat.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
await chat.getByText(replyText, { exact: true }).waitFor();
const composer = chat.getByRole("textbox", { name: "Chat composer", exact: true });
const draft = "Keep this unsent Inspector draft";
await composer.fill(draft);
const chatUrl = chat.url();
// Explicit replay must fail even when this context already has a paired device.
const replay = await context.newPage();
const rejections: ErrorShape[] = [];
replay.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
const frame: GatewayFrame = JSON.parse(String(payload));
if (frame.type === "res" && !frame.ok && frame.error) {
rejections.push(frame.error);
}
});
});
await replay.goto(browserUrl);
await replay.getByText("Pairing link is no longer valid", { exact: false }).waitFor();
expect(rejections).toContainEqual(
expect.objectContaining({
details: expect.objectContaining({ code: "AUTH_BOOTSTRAP_TOKEN_INVALID" }),
}),
);
expect(await replay.locator("#activity-run-panel").count()).toBe(0);
await replay.close();
for (const inspectorContext of [context, freshContext]) {
await withControlUiRunInspector(
inspectorContext,
{
baseUrl: suite.server.baseUrl,
selector: { kind: "execution", id: executionId },
receipt: { id: receipt.selectorId },
preparePage: (page) => pairControlUiPage(page, runCli),
},
async (page, inspector) => {
const assertIdentity = async () => {
await inspector.waitFor({ state: "visible" });
await page.getByRole("heading", { name: "Identity and authority" }).waitFor();
expect(await inspector.getAttribute("data-execution-id")).toBe(executionId);
expect(await inspector.getAttribute("data-run-id")).toBe(runId);
const detail = inspector.locator(
'[aria-labelledby="run-inspector-receipt-detail"]',
);
await detail.waitFor({ state: "visible" });
expect(await detail.getAttribute("data-receipt-selector-id")).toBe(
receipt.selectorId,
);
expect(await detail.textContent()).toContain(receipt.decision.reasonCode);
};
await assertIdentity();
await page.reload();
await assertIdentity();
},
);
expect(context.pages()).toEqual([chat]);
expect(freshContext.pages()).toEqual([]);
expect(chat.url()).toBe(chatUrl);
expect(await composer.inputValue()).toBe(draft);
}
await chat.reload();
await chat.getByText(replyText, { exact: true }).waitFor();
},
});
}, 90_000);
});

View file

@ -0,0 +1,17 @@
import type { Page } from "playwright";
import { waitForControlUiGatewayReady } from "./control-ui-e2e-readiness.ts";
/** Acquire and consume a fresh handoff for this page; never cache a single-use dashboard URL. */
export async function pairControlUiPage(
page: Page,
runCli: (args: string[]) => Promise<string>,
): Promise<void> {
const handoff: { ok: boolean; browserUrl?: string; reason?: string } = JSON.parse(
await runCli(["dashboard", "--json"]),
);
if (!handoff.ok || !handoff.browserUrl) {
throw new Error(handoff.reason ?? "Dashboard did not issue a browser pairing link");
}
await page.goto(handoff.browserUrl);
await waitForControlUiGatewayReady(page);
}

View file

@ -17,7 +17,7 @@ export async function withControlUiRunInspector<T>(
baseUrl: string;
selector: RunInspectorSelector;
receipt?: Parameters<typeof activityRunInspectorSelectorHref>[2];
/** Install the mock Gateway or campaign-owned per-tab auth before navigation. */
/** Install a mock Gateway or use pairControlUiPage with the isolated Gateway's CLI. */
preparePage?: (page: Page) => Promise<unknown>;
},
collect: (page: Page, inspector: Locator) => Promise<T>,