let playwright;
try {
playwright = await import("playwright");
} catch (error) {
const fallback = process.env.OPENAI4S_PLAYWRIGHT_MODULE;
if (!fallback) throw error;
playwright = await import(fallback);
}
const { chromium } = playwright;
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { authenticate, waitUntil } from "./browser_auth.mjs";
const baseUrl = process.env.OPENAI4S_BROWSER_URL || "http://127.0.0.1:8760/";
const executablePath = process.env.OPENAI4S_BROWSER_EXECUTABLE || undefined;
const browser = await chromium.launch({ headless: true, executablePath });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
// Log in before anything else. The daemon requires a token by default now, so
// without this every check below fails on a 401 and reports it as a product
// failure. Doing it through the query-string bootstrap means the harness also
// exercises the 303 and the cookie hand-off a real browser goes through.
const accessToken = await authenticate(page, baseUrl);
const pageErrors = [];
const workbenchSockets = [];
const workbenchEvents = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
page.on("websocket", (socket) => {
if (!/\/api\/v1\/ws(?:\?|$)/.test(socket.url())) return;
workbenchSockets.push(socket.url());
socket.on("framereceived", (frame) => {
try {
const text = typeof frame.payload === "string" ? frame.payload : frame.payload.toString("utf8");
const event = JSON.parse(text);
if (event && typeof event === "object") workbenchEvents.push(event);
} catch {}
});
});
async function api(path, { method = "GET", data } = {}) {
const response = await page.request.fetch(new URL(`api/v1${path}`, baseUrl).toString(), {
method,
data,
headers: data === undefined ? undefined : { "Content-Type": "application/json" },
});
if (!response.ok()) {
throw new Error(`${method} ${path} returned HTTP ${response.status()}: ${await response.text()}`);
}
return response.json();
}
// The trusted-capture guard refuses every external workspace mutation while a
// background execution is registered (409 trusted_capture_busy), and the
// permission-resume section spawns one whose exit nothing here can await —
// there is no REST projection of the background registry. The refusal IS the
// contract, so the first mutation after that section retries admission with a
// bounded budget instead of racing the job's exit; any other failure, or a
// job that never drains, still fails the run loudly.
async function apiRetryWhileCaptureBusy(path, { method = "GET", data } = {}) {
const deadline = Date.now() + 20000;
for (;;) {
const response = await page.request.fetch(new URL(`api/v1${path}`, baseUrl).toString(), {
method,
data,
headers: data === undefined ? undefined : { "Content-Type": "application/json" },
});
if (response.ok()) return response.json();
const body = await response.text();
const busy = response.status() === 409 && body.includes("trusted_capture_busy");
if (!busy || Date.now() >= deadline) {
throw new Error(`${method} ${path} returned HTTP ${response.status()}: ${body}`);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
async function requireOne(selector, message = selector) {
const count = await page.locator(selector).count();
if (count !== 1) throw new Error(`expected one ${message}, found ${count}`);
}
async function ensureDockOpen() {
if (await page.locator("#rightdock.collapsed").count()) {
await page.locator(".nb-tray").click();
}
await page.locator("#rightdock:not(.collapsed)").waitFor({ state: "visible" });
}
async function waitWorkbenchIdle(timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const loading = await page.evaluate(() => !!(window.S && window.S._workbenchLoading));
if (!loading) return;
await page.waitForTimeout(50);
}
}
async function dumpBranchForkState(frameId) {
const snapshot = await page.evaluate(async (fid) => {
const S = window.S || {};
let fetched = null;
let fetchError = null;
try {
const response = await fetch(`/api/v1/frames/${encodeURIComponent(fid)}/branches`);
fetched = { status: response.status, body: await response.json() };
} catch (error) {
fetchError = String(error && error.message ? error.message : error);
}
const timeline = document.getElementById("dock-timeline");
return {
activeTab: S.activeTab,
currentId: S.currentId,
workbenchLoading: S._workbenchLoading,
workbenchReq: S._workbenchReq,
loadWorkbenchStateType: typeof window.loadWorkbenchState,
renderActionTimelineType: typeof window.renderActionTimeline,
branchState: S.branchState,
dockTimelineHidden: timeline ? timeline.classList.contains("hidden") : null,
dockTimelineChildCount: timeline ? timeline.children.length : null,
branchNameTexts: Array.from(document.querySelectorAll(".branch-name")).map((node) => node.textContent),
workbenchEmpty: Array.from(document.querySelectorAll(".workbench-empty")).map((node) => node.textContent),
fetched,
fetchError,
};
}, frameId);
let apiBranches;
try {
apiBranches = await api(`/frames/${encodeURIComponent(frameId)}/branches`);
} catch (error) {
apiBranches = { error: String(error && error.message ? error.message : error) };
}
return { snapshot, apiBranches };
}
// C1/C3/C7 correctness cases use real persisted Cells/Artifacts. REST faults
// and anonymous event interleavings are explicitly injected UI fault fixtures.
async function correctnessScenes(projectId) {
const made = await api("/frames", { method: "POST", data: { project_id: projectId } });
const fid = made.id || made.frame_id;
await page.evaluate(async ({ fid, projectId }) => window.openConversation(fid, projectId), { fid, projectId });
await ensureDockOpen();
let autoFigureName = "";
async function execute(revision) {
const code = [
"from pathlib import Path", "import struct, zlib",
"def png_chunk(kind, data):", " return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff)",
`rgb = bytes([${revision === 1 ? "255, 0, 0" : "0, 0, 255"}])`,
"image_bytes = bytes([137,80,78,71,13,10,26,10]) + png_chunk(b'IHDR', struct.pack('>2I5B', 1,1,8,2,0,0,0)) + png_chunk(b'IDAT', zlib.compress(bytes([0]) + rgb)) + png_chunk(b'IEND', b'')",
"Path('history.png').write_bytes(image_bytes)",
`Path('history.csv').write_text('revision,value\\n${revision},${revision * 11}\\n'.replace('\\\\n', '\\n'))`,
"Path('left').mkdir(exist_ok=True)", "Path('right').mkdir(exist_ok=True)",
`Path('left/same.csv').write_text('side,value\\nleft,${revision}\\n'.replace('\\\\n', '\\n'))`,
`Path('right/same.csv').write_text('side,value\\nright,${revision}\\n'.replace('\\\\n', '\\n'))`,
...(revision === 1 ? ["import matplotlib; matplotlib.use('Agg')", "import matplotlib.pyplot as plt", "plt.figure(); plt.plot([0, 1], [1, 0])"] : [`Path(${JSON.stringify(autoFigureName)}).write_bytes(image_bytes)`]),
`print('correctness revision ${revision}')`,
].join("\n");
const result = await api(`/frames/${fid}/kernel/execute`, { method: "POST", data: { language: "python", code, wait: true } });
assert.ok(!result.error, JSON.stringify(result));
const log = await api(`/frames/${fid}/execution-log`);
const entry = log.entries.find((row) => row.source.includes(`correctness revision ${revision}`));
assert.ok(entry && !entry.error, JSON.stringify(entry));
const outputs = entry.output_artifacts || [];
for (const name of ["history.png", "history.csv", "left/same.csv", "right/same.csv"]) {
assert.equal(outputs.filter((row) => row.filename === name).length, 1, `exact output binding ${name}`);
}
return entry;
}
const first = await execute(1);
const v1 = first.output_artifacts.find((row) => row.filename === "history.csv");
assert.equal(first.figures.length, 1, "real matplotlib figure captured by the daemon");
autoFigureName = first.figures[0];
const image1 = first.output_artifacts.find((row) => row.filename === autoFigureName);
const originalImage = await (await page.request.get(new URL(image1.url, baseUrl).toString())).body();
await page.evaluate(async (a) => {
await window.openViewer({ id: a.artifact_id, filename: a.filename, version_id: a.version_id });
window.openViewer({ id: a.artifact_id, filename: a.filename, content_type: "text/csv" });
}, v1);
assert.equal(await page.locator("#dock-tabs .dock-tab").filter({ hasText: "history.csv" }).count(), 2);
await page.locator("#dock-tabs .dock-tab").filter({ hasText: v1.version_id }).click();
await page.locator("#dock-viewer table.sheet").waitFor();
assert.match(await page.locator("#dock-viewer").innerText(), /11/);
const metadataRequests = [];
const recordMetadata = (request) => { if (/\/(lineage|environment)(?:\?|$)/.test(request.url()) && request.url().includes(v1.artifact_id)) metadataRequests.push(request.url()); };
page.on("request", recordMetadata);
await page.locator('[data-f16-provenance="1"]').click();
await page.locator(".prov-subtab").filter({ hasText: "Environment" }).click();
await waitUntil("exact environment read", async () => metadataRequests.some((url) => url.includes("/environment?version=")));
assert.ok(metadataRequests.every((url) => new URL(url).searchParams.get("version") === v1.version_id));
await page.locator('[data-f16-provenance="back"]').click();
const second = await execute(2);
const v2 = second.output_artifacts.find((row) => row.filename === "history.csv");
assert.equal(v1.artifact_id, v2.artifact_id);
assert.notEqual(v1.version_id, v2.version_id);
await waitUntil("new persisted Artifact event", async () => workbenchEvents.some((event) =>
event.type === "artifact_created" && JSON.stringify(event).includes(v2.version_id)));
const pinned = await page.evaluate(() => window.S.dockArtifact);
assert.equal(pinned.version_id, v1.version_id);
assert.equal(pinned._exactVersion, true);
assert.match(await page.locator("#dock-viewer").innerText(), /11/);
assert.doesNotMatch(await page.locator("#dock-viewer table.sheet").innerText(), /22/);
page.off("request", recordMetadata);
const latestTab = page.locator("#dock-tabs .dock-tab").filter({ hasText: "history.csv" }).filter({ hasNotText: v1.version_id });
await latestTab.click();
await waitUntil("latest sheet v2", async () => (await page.locator("#dock-viewer").innerText()).includes("22"));
const latestReads = [];
const recordLatest = (request) => { if (new URL(request.url()).pathname === `/api/v1/artifacts/${v1.artifact_id}`) latestReads.push(request.url()); };
page.on("request", recordLatest);
// Reopening rebuilds the Notebook through the real execution-log projection.
await page.reload({ waitUntil: "networkidle" });
await ensureDockOpen();
await page.evaluate(() => window.setActiveTab("notebook"));
const cell1 = page.locator(`.notebook-cell[data-producing-cell="${first.producing_cell_id}"]`).first();
await cell1.waitFor();
await cell1.locator("img.nbc-fig").waitFor();
assert.equal(await cell1.locator("img.nbc-fig").getAttribute("src"), image1.url);
assert.ok(await cell1.locator("img.nbc-fig").evaluate((node) => node.complete && node.naturalWidth > 0));
assert.match(await cell1.innerText(), /11/);
assert.doesNotMatch(await cell1.locator("table.nbc-table").first().innerText(), /22/);
for (const out of first.output_artifacts) {
const response = await page.request.get(new URL(out.url, baseUrl).toString());
assert.equal(response.status(), 200);
const link = cell1.locator(`a[download="${out.filename}"]`);
assert.ok(await link.count(), `download link ${out.filename}`);
assert.equal(await link.first().getAttribute("href"), out.url);
if (out.filename === autoFigureName) assert.deepEqual(await response.body(), originalImage);
if (out.filename.endsWith("same.csv")) assert.match(await response.text(), new RegExp(out.filename.split("/")[0] + ",1"));
}
// The conversation's separate latest-file thumbnail also reads the head.
// Measure that normal page load so only additional failure fallback fails.
const normalLatestReads = latestReads.length;
latestReads.length = 0;
await page.route(`**${v1.url}*`, (route) => route.fulfill({ status: 404, contentType: "application/json", body: '{"error":"fixture missing version"}' }));
try {
await page.reload({ waitUntil: "networkidle" }); await ensureDockOpen();
await page.evaluate(() => window.setActiveTab("notebook"));
const failedCell = page.locator(`.notebook-cell[data-producing-cell="${first.producing_cell_id}"]`).first();
await failedCell.locator(".nbc-artifact-error").waitFor();
assert.equal(latestReads.length, normalLatestReads, "historical failure adds no latest request beyond normal thumbnails");
const failedRead = page.waitForResponse((response) => new URL(response.url()).pathname === v1.url && response.status() === 404);
await failedCell.locator(".nbc-artifact-error button").click();
await failedRead;
assert.equal(latestReads.length, normalLatestReads, "retry requests only the exact failed version");
} finally { await page.unroute(`**${v1.url}*`); page.off("request", recordLatest); }
console.log("C1 browser: real two-version Cells, reopen, exact figures/tables/downloads, same basename, fixed/latest tabs and 404 without latest passed");
// C3: failures remain visible; only GETs occur during recovery. Anonymous
// chunks intentionally have no message_id/turn_id, matching the legacy wire.
let mode = "503", gets = 0, actions = 0, streamRunning = false;
const messagesUrl = `**/api/v1/frames/${fid}/messages?*`;
const historyFixture = () => ({ messages: [{ role: "assistant", content: "confirmed history", seq: 1 },
...(mode === "terminal" ? [{ role: "assistant", content: "anonymous stream survives", seq: 2 }] : [])], has_earlier: false });
await page.route(`**/api/v1/frames/${fid}/status`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ running: streamRunning, status: streamRunning ? "running" : "done" }) }));
const countActions = (request) => { if (!["GET", "HEAD"].includes(request.method())) actions++; };
page.on("request", countActions);
await page.route(messagesUrl, (route) => {
gets++;
return route.fulfill({ status: mode === "503" ? 503 : mode === "401" ? 401 : 200,
contentType: "application/json", body: JSON.stringify(mode === "503" || mode === "401" ? { error: "injected history fault", code: mode === "401" ? "unauthorized" : "unavailable", request_id: "browser-history" } : mode === "malformed" ? {} : historyFixture()) });
});
try {
for (const failure of ["503", "401", "malformed"]) {
mode = failure;
await page.evaluate(async ({ fid, projectId }) => window.openConversation(fid, projectId), { fid, projectId });
await page.locator('.history-load-status[data-history-state="partial"], .history-load-status[data-history-state="error"]').waitFor();
assert.equal(await page.locator("#messages .empty-session").count(), 0);
assert.match(await page.locator(".history-load-status").innerText(), failure === "401" ? /access|认证|权限/i : failure === "malformed" ? /Invalid|格式/i : /unavailable|不可用/i);
}
mode = "ok";
await page.locator(".history-load-status button").click();
await waitUntil("history retry recovered", async () => (await page.locator("#messages").innerText()).includes("confirmed history") && !(await page.locator(".history-load-status").count()));
await page.route(`**/api/v1/frames/${fid}/steps*`, (route) => route.fulfill({ status: 503, contentType: "application/json", body: '{"error":"steps unavailable"}' }));
streamRunning = true;
await page.evaluate((fid) => {
window.onEvent({ type: "text_chunk", root_frame_id: fid, chunk: "anonymous stream survives" });
window.onEvent({ type: "replay_begin", root_frame_id: fid, gap: true });
window.onEvent({ type: "replay_end", root_frame_id: fid });
}, fid);
await page.locator('.history-load-status[data-history-state="partial"]').waitFor();
assert.match(await page.locator("#messages").innerText(), /confirmed history/);
assert.match(await page.locator("#messages").innerText(), /anonymous stream survives/);
assert.equal(await page.evaluate(() => window.S._replayGap), fid);
await page.unroute(`**/api/v1/frames/${fid}/steps*`);
mode = "terminal";
streamRunning = false;
await page.evaluate((fid) => window.onEvent({ type: "frame_update", root_frame_id: fid, frame_id: fid, status: "done" }), fid);
await waitUntil("terminal GET aligns anonymous history", async () => !(await page.locator(".history-load-status").count()));
await page.evaluate((fid) => window.onEvent({ type: "replay_end", root_frame_id: fid }), fid);
await waitUntil("complete recovery clears gap", async () => !(await page.evaluate(() => window.S._replayGap)));
assert.equal((await page.locator("#messages").innerText()).split("anonymous stream survives").length - 1, 1);
// A lost terminal event is recoverable from a current stopped status GET.
streamRunning = true;
await page.evaluate((fid) => {
window.onEvent({ type: "text_chunk", root_frame_id: fid, chunk: "anonymous stream survives" });
window.onEvent({ type: "replay_begin", root_frame_id: fid, gap: true });
window.onEvent({ type: "replay_end", root_frame_id: fid });
}, fid);
await page.locator('.history-load-status[data-history-state="partial"]').waitFor();
streamRunning = false;
await page.locator(".history-load-status button").click();
await waitUntil("lost terminal restored using GET", async () => !(await page.locator(".history-load-status").count()));
assert.equal((await page.locator("#messages").innerText()).split("anonymous stream survives").length - 1, 1);
assert.equal(actions, 0, "history retries may not execute or submit work");
assert.ok(gets < 20, `recovery GET storm: ${gets}`);
} finally { await page.unroute(messagesUrl); await page.unroute(`**/api/v1/frames/${fid}/status`); await page.unroute(`**/api/v1/frames/${fid}/steps*`); page.off("request", countActions); }
console.log("C3 browser: 503/401/malformed → visible failure → GET retry; steps gap and anonymous stream terminal alignment; zero execution requests passed");
async function showJson(filename, raw) {
const uploaded = await api("/uploads", { method: "POST", data: { frame_id: fid, project_id: projectId, filename, content_base64: Buffer.from(raw).toString("base64") } });
const versions = await api(`/artifacts/${uploaded.artifact_id}/versions`);
const row = versions.versions[0];
await page.evaluate(async (a) => window.openViewer(a), { id: uploaded.artifact_id, filename, version_id: row.version_id });
return `/api/v1/artifacts/versions/${row.version_id}`;
}
await showJson("sparse.json", JSON.stringify([{ a: 1 }, { a: 2, late: 3, zero: 0, bool: false, unsafe: "
" }]));
await page.locator("#dock-viewer table.sheet").waitFor();
assert.deepEqual(await page.locator("#dock-viewer th").allTextContents(), ["a", "late", "zero", "bool", "unsafe"]);
assert.deepEqual((await page.locator("#dock-viewer tr").nth(2).locator("td").allTextContents()).slice(0, 4), ["2", "3", "0", "false"]);
assert.equal(await page.locator("#dock-viewer td img").count(), 0);
const mixed = JSON.stringify({ rows: [{ a: 1 }, null, [2], false, ""] });
await showJson("mixed.json", mixed);
await page.locator("#dock-viewer .renderer-source").waitFor();
assert.equal(await page.locator("#dock-viewer .renderer-source").textContent(), mixed);
const large = JSON.stringify({ items: [{ a: "safe words ".repeat(31000) }, null], tail: "END-OF-FULL-JSON" });
const largeUrl = await showJson("large-mixed.json", large);
await page.locator('#dock-viewer button[aria-expanded="false"]').waitFor();
assert.equal((await page.locator("#dock-viewer .renderer-source").textContent()).length, 300000);
let expansionGets = 0;
const countExpansion = (request) => { if (request.url().includes(largeUrl)) expansionGets++; };
page.on("request", countExpansion);
await page.locator('#dock-viewer button[aria-expanded="false"]').press("Enter");
assert.equal(await page.locator("#dock-viewer .renderer-source").textContent(), large);
assert.equal(expansionGets, 0, "expand uses the text already fetched");
page.off("request", countExpansion);
const fullUrl = await page.locator("#dock-viewer a[download]").last().getAttribute("href");
assert.equal(await (await page.request.get(new URL(fullUrl, baseUrl).toString())).text(), large);
assert.equal(await page.evaluate(() => window.__jsonXss), undefined);
const rows = Array.from({ length: 5001 }, (_, i) => i === 5000 ? { afterLimit: "late" } : { a: i });
await showJson("row-cap.json", JSON.stringify(rows));
await page.locator("#dock-viewer table.sheet").waitFor();
assert.deepEqual(await page.locator("#dock-viewer th").allTextContents(), ["a", "afterLimit"]);
assert.equal(await page.locator("#dock-viewer table.sheet tr").count(), 5001);
assert.match(await page.locator("#dock-viewer").innerText(), /5[,.]?001|5001/);
console.log("C7 browser: sparse fields, 0/false, HTML as text, mixed raw JSON, 300k preview/full expansion/download and row-5001 schema passed");
// C5 uses a controlled doctor response to exercise presentation and faults;
// the passive GET, settings navigation/save and permission routes stay real.
let checks = 0, checkMode = "ok";
const checksUrl = "**/api/v1/diagnostics/checks";
const checkFixture = { status: "fail", request_id: "browser-checks", checks: [
{ name: "model", status: "fail", detail: "Model is not configured.", remedy: "Choose a model in existing settings.", facts: Object.fromEntries(Array.from({ length: 22 }, (_, i) => [`key${i}`, "
".repeat(30)])) },
{ name: "connectors", status: "warn", detail: "Connector fixture unavailable.", remedy: "Review network settings." },
{ name: "unknown-fixture", status: "fail", detail: "", remedy: "https://example.invalid/do-not-open" },
{ name: "data", status: "ok", detail: "Legacy optional fields omitted." },
] };
await page.route(checksUrl, async (route) => {
checks++;
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ status: checkMode === "403" ? 403 : 200, contentType: "application/json", body: JSON.stringify(checkMode === "403" ? { error: "operator only", code: "forbidden" } : checkFixture) });
});
try {
await page.evaluate(() => window.openCust("general"));
await page.locator("[data-diagnostics-run]").waitFor();
assert.equal(checks, 0, "opening diagnostics must be passive");
await page.locator("[data-diagnostics-run]").press("Enter");
assert.ok(await page.locator("[data-diagnostics-run]").isDisabled());
await page.locator('[data-diagnostics-results="current"]').waitFor();
assert.equal(checks, 1);
const model = page.locator('[data-diagnostic-check="model"]');
assert.match(await model.locator("[data-diagnostic-remedy]").innerText(), /Choose a model/);
await model.locator("summary").press("Enter");
assert.equal(await model.locator("dt").count(), 20);
assert.ok((await model.locator("dd").allTextContents()).every((text) => text.length <= 500));
assert.equal(await page.locator("[data-diagnostics-results] img, [data-diagnostics-results] script").count(), 0);
assert.equal(await page.locator('[data-diagnostic-check="unknown-fixture"] button, [data-diagnostic-check="unknown-fixture"] a').count(), 0);
const received = await page.locator("[data-diagnostics-results] time").getAttribute("datetime");
await model.locator('[data-diagnostic-setting="models"]').click();
await page.locator('.cust-tab[data-tab="models"].active').waitFor();
await page.locator('.cust-tab[data-tab="general"]').click();
assert.equal(await page.locator("[data-diagnostics-results] time").getAttribute("datetime"), received);
await page.locator('[data-diagnostic-setting="network"]').click();
await page.locator('.cust-tab[data-tab="network"].active').waitFor();
const configSaved = page.waitForResponse((response) => response.url().endsWith("/api/v1/network/status") && response.request().method() === "PUT");
await page.locator("#cust .toggle").first().click();
assert.ok((await configSaved).ok());
await page.locator('.cust-tab[data-tab="general"]').click();
await page.locator("[data-diagnostics-stale]").waitFor();
assert.equal(checks, 1, "configuration changes do not run checks automatically");
checkMode = "403";
await page.locator("[data-diagnostics-run]").click();
await page.locator("[data-diagnostics-error]").waitFor();
await page.locator('[data-diagnostics-results="previous"]').waitFor();
assert.equal(await page.locator("[data-diagnostics-results] time").getAttribute("datetime"), received);
assert.equal(await page.evaluate(() => window.__diagXss), undefined);
// Boot the same real settings surface with its persisted Chinese locale.
await page.locator("#cust-close").click();
const priorLang = await page.evaluate(() => localStorage.getItem("os-lang"));
try {
await page.evaluate(() => localStorage.setItem("os-lang", "zh"));
await page.reload({ waitUntil: "networkidle" });
await page.evaluate(() => window.openCust("general"));
await page.locator("[data-diagnostics-run]").waitFor();
assert.equal(await page.locator("[data-diagnostics-run]").innerText(), "运行检查");
assert.equal(checks, 2, "Chinese settings opening is also passive");
checkMode = "ok";
await page.locator("[data-diagnostics-run]").press("Enter");
await page.locator('[data-diagnostics-results="current"]').waitFor();
assert.match(await page.locator("[data-diagnostics-results]").innerText(), /本次获取时间|下一步/);
assert.equal(checks, 3);
} finally {
await page.evaluate((lang) => lang === null ? localStorage.removeItem("os-lang") : localStorage.setItem("os-lang", lang), priorLang);
await page.reload({ waitUntil: "networkidle" });
await page.evaluate(() => window.openCust("general"));
}
} finally {
await page.unroute(checksUrl);
await page.locator("#cust-close").click();
await api("/network/status", { method: "PUT", data: { enabled: false } });
}
console.log("C5 browser: passive open, keyboard checks, remedy/settings, facts limits, safe text, retained results, config invalidation and 403 passed");
}
// UI5-F1: New session publishes the created id before it opens the frame, and
// the open treated the new frame as its own predecessor -- the dock kept the
// previous session's cells and merged the new session's in beside them.
async function newSessionNotebookScene(projectId) {
const made = await api("/frames", { method: "POST", data: { project_id: projectId } });
const fromId = made.id || made.frame_id;
for (const code of ["print('new-session scene A1')", "print('new-session scene A2')"]) {
const result = await api(`/frames/${fromId}/kernel/execute`, { method: "POST", data: { language: "python", code, wait: true } });
assert.ok(!result.error, JSON.stringify(result));
}
await page.evaluate(async ({ fid, projectId }) => window.openConversation(fid, projectId), { fid: fromId, projectId });
await ensureDockOpen();
await page.evaluate(() => window.setActiveTab("notebook"));
const dockCells = page.locator("#dock-notebook .notebook-cell");
await waitUntil("the previous session's two cells", async () => (await dockCells.count()) === 2);
await page.locator("#new-session").click();
let newId = "";
await waitUntil("New session opened", async () => {
newId = (new URL(page.url()).pathname.match(/\/frames\/([^/]+)/) || [])[1] || "";
return !!newId && newId !== fromId;
});
await waitWorkbenchIdle();
await page.waitForTimeout(1500);
assert.equal((await api(`/frames/${newId}/execution-log`)).entries.length, 0);
assert.equal(await dockCells.count(), 0, "a new session's Notebook shows no cells of the previous one");
const own = await api(`/frames/${newId}/kernel/execute`, { method: "POST", data: { language: "python", code: "print('new-session scene B1')", wait: true } });
assert.ok(!own.error, JSON.stringify(own));
await waitUntil("the new session's own cell", async () => (await dockCells.count()) >= 1);
await page.waitForTimeout(1000);
const texts = await dockCells.allInnerTexts();
assert.equal(texts.length, 1, `exactly the new session's cell, got ${JSON.stringify(texts)}`);
assert.match(texts[0], /new-session scene B1/);
console.log("UI5-F1 browser: New session starts with an empty Notebook and shows only its own cell");
}
function queueTickets(snapshot) {
return [snapshot?.owner, ...(snapshot?.queue || [])].filter(Boolean);
}
function executionEvents(executionId) {
return workbenchEvents.filter((event) =>
event && event.execution_id === executionId &&
["execution_state", "execution_ticket_state"].includes(event.type),
);
}
try {
const response = await page.goto(baseUrl, { waitUntil: "networkidle" });
if (!response || !response.ok()) {
throw new Error(`workbench returned HTTP ${response?.status() ?? "unknown"}`);
}
await page.locator("#dashboard").waitFor({ state: "visible" });
// === CONTRACT_GLOBALS BEGIN (generated by scripts/extract_webui_contract.mjs; do not edit by hand) ===
// F-01: per-name typeof preamble for the window-export contract.
// app.js is a classic script (`const S` is not window.S) and workbench CSP
// has no unsafe-eval, so names are unrolled as `typeof ident`.
const CONTRACT_GLOBALS = Object.freeze([
"ACTION_TIMELINE_OVERSCAN",
"ACTION_TIMELINE_OVERVIEW_WIDTH",
"ACTION_TIMELINE_PAGE_SIZE",
"ACTION_TIMELINE_ROW_HEIGHT",
"S",
"actionTimelineEntryKey",
"actionTimelineOverviewVisualExtent",
"actionTimelineSelectionOverlaps",
"actionTimelineSpan",
"admissionSettled",
"annotationIsHeld",
"annotationStatus",
"buildExecutedCodeView",
"buildStepCard",
"commitActionTimelineOverviewSelection",
"custTab",
"execSourcesState",
"fetchAllMessages",
"fetchOlderMessages",
"fetchRecentMessages",
"forgetAdmission",
"highlightTraceback",
"loadAnnotations",
"loadEarlierActionTimeline",
"mergeDelegationChildEvent",
"notebookExportLink",
"onEvent",
"openAnnotations",
"openConversation",
"openCust",
"openKetcher",
"openPinPop",
"openViewer",
"outstandingAdmissions",
"parseTable",
"reconcileLastAdmission",
"rememberAdmission",
"renderActionTimeline",
"renderAttachmentProblems",
"renderComposerRefChips",
"renderDelegationPanel",
"renderMd",
"renderMessageRefChips",
"renderPins",
"renderRefProblems",
"renderSheet",
"sanitizeActionTimeline",
"searchResultHttpUrl",
"selectExecFrame",
"send",
"setActiveTab",
"steerDelegationChild",
"t",
"telemetryRow",
"timelineOverviewTimeToX",
"toggleActionTimelineTurn",
"updateActionTimelineLedger",
]);
if (!CONTRACT_GLOBALS.length) throw new Error("CONTRACT_GLOBALS is empty");
const kinds = await page.evaluate(() => ({
ACTION_TIMELINE_OVERSCAN: typeof ACTION_TIMELINE_OVERSCAN,
ACTION_TIMELINE_OVERVIEW_WIDTH: typeof ACTION_TIMELINE_OVERVIEW_WIDTH,
ACTION_TIMELINE_PAGE_SIZE: typeof ACTION_TIMELINE_PAGE_SIZE,
ACTION_TIMELINE_ROW_HEIGHT: typeof ACTION_TIMELINE_ROW_HEIGHT,
S: typeof S,
actionTimelineEntryKey: typeof actionTimelineEntryKey,
actionTimelineOverviewVisualExtent: typeof actionTimelineOverviewVisualExtent,
actionTimelineSelectionOverlaps: typeof actionTimelineSelectionOverlaps,
actionTimelineSpan: typeof actionTimelineSpan,
admissionSettled: typeof admissionSettled,
annotationIsHeld: typeof annotationIsHeld,
annotationStatus: typeof annotationStatus,
buildExecutedCodeView: typeof buildExecutedCodeView,
buildStepCard: typeof buildStepCard,
commitActionTimelineOverviewSelection: typeof commitActionTimelineOverviewSelection,
custTab: typeof custTab,
execSourcesState: typeof execSourcesState,
fetchAllMessages: typeof fetchAllMessages,
fetchOlderMessages: typeof fetchOlderMessages,
fetchRecentMessages: typeof fetchRecentMessages,
forgetAdmission: typeof forgetAdmission,
highlightTraceback: typeof highlightTraceback,
loadAnnotations: typeof loadAnnotations,
loadEarlierActionTimeline: typeof loadEarlierActionTimeline,
mergeDelegationChildEvent: typeof mergeDelegationChildEvent,
notebookExportLink: typeof notebookExportLink,
onEvent: typeof onEvent,
openAnnotations: typeof openAnnotations,
openConversation: typeof openConversation,
openCust: typeof openCust,
openKetcher: typeof openKetcher,
openPinPop: typeof openPinPop,
openViewer: typeof openViewer,
outstandingAdmissions: typeof outstandingAdmissions,
parseTable: typeof parseTable,
reconcileLastAdmission: typeof reconcileLastAdmission,
rememberAdmission: typeof rememberAdmission,
renderActionTimeline: typeof renderActionTimeline,
renderAttachmentProblems: typeof renderAttachmentProblems,
renderComposerRefChips: typeof renderComposerRefChips,
renderDelegationPanel: typeof renderDelegationPanel,
renderMd: typeof renderMd,
renderMessageRefChips: typeof renderMessageRefChips,
renderPins: typeof renderPins,
renderRefProblems: typeof renderRefProblems,
renderSheet: typeof renderSheet,
sanitizeActionTimeline: typeof sanitizeActionTimeline,
searchResultHttpUrl: typeof searchResultHttpUrl,
selectExecFrame: typeof selectExecFrame,
send: typeof send,
setActiveTab: typeof setActiveTab,
steerDelegationChild: typeof steerDelegationChild,
t: typeof t,
telemetryRow: typeof telemetryRow,
timelineOverviewTimeToX: typeof timelineOverviewTimeToX,
toggleActionTimelineTurn: typeof toggleActionTimelineTurn,
updateActionTimelineLedger: typeof updateActionTimelineLedger,
}));
for (const name of CONTRACT_GLOBALS) {
if (!Object.prototype.hasOwnProperty.call(kinds, name) || kinds[name] === "undefined") {
throw new Error(`missing contract global: ${name}`);
}
}
for (const name of Object.keys(kinds)) {
if (!CONTRACT_GLOBALS.includes(name)) {
throw new Error(`typeof map has extra name not in CONTRACT_GLOBALS: ${name}`);
}
}
// === CONTRACT_GLOBALS END ===
for (const selector of [
"#dash-projects",
"#dash-sessions",
"#workspace",
"#messages",
"#dock-notebook",
]) {
if ((await page.locator(selector).count()) !== 1) {
throw new Error(`missing workbench surface: ${selector}`);
}
}
// --- browser data boundary ------------------------------------------------
// The UI renders strings this process did not author: markdown from the
// model, tracebacks from the kernel, a remote host's label, and a GPU name
// that is literally `nvidia-smi` stdout. None may become executable markup.
// These samples are the ones the improvement proposal named (malicious link,
// image, attribute, remote hostname, GPU name); they run against the real
// app functions so a regression in renderMd/escaping fails CI rather than a
// browser somewhere.
const securityHeaders = response.headers();
for (const [header, expected] of [
["content-security-policy", "default-src 'self'"],
["x-content-type-options", "nosniff"],
]) {
if (!(securityHeaders[header] || "").includes(expected)) {
throw new Error(`response missing hardened header ${header}: ${expected}`);
}
}
// script-src must never carry 'unsafe-inline' (style-src legitimately does):
// that concession is what would make the whole policy decorative against the
// injection it exists to stop.
const scriptSrc = (securityHeaders["content-security-policy"] || "")
.split(";").map((s) => s.trim()).find((s) => s.startsWith("script-src")) || "";
if (scriptSrc.includes("'unsafe-inline'")) {
throw new Error("CSP script-src must not allow 'unsafe-inline'");
}
const boundary = await page.evaluate(() => {
const out = { executed: [], scriptTags: 0, imgTags: 0, missing: [], searchUrls: null };
window.__xssProbe = () => out.executed.push("fired");
const host = document.createElement("div");
host.style.display = "none";
document.body.appendChild(host);
const attacks = [
"before