fix(canvas): 3D 导演台 - 完成 Issue #305 P0 稳定性治理 (#324)

* fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期

* style(canvas): 3D 导演台 - 对齐 CI 格式门禁
This commit is contained in:
echoD886
2026-08-27 10:32:45 +08:00
committed by GitHub
parent 2a28ababf1
commit c2766544d7
42 changed files with 8124 additions and 188 deletions

View File

@@ -0,0 +1,724 @@
/**
* Issue #305 P0 — 真实 Chrome E2E。
*
* 无新增依赖:只用 Bun/Node 内置 spawn / fetch / WebSocket / fs。
* 自己启动 Vite DEV 与 headless Chrome,通过 CDP 驱动 /dev/director-repro。
* 所有等待都有硬超时;任何断言失败 → exit 1。
* finally 只终止本脚本记录的 PID,只删除本脚本 mkdtemp 创建的 profile。
*/
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:net";
const CHROME_CANDIDATES = [
process.env.CHROME_BIN,
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/opt/google/chrome/chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
].filter(Boolean);
/** 唯一允许出现的浏览器噪声:精确匹配,其他一律判失败。 */
const ALLOWED_NOISE = ["Warning: [antd: InputNumber] `addonAfter` is deprecated. Please use `Space.Compact` instead.", "Warning: [antd: InputNumber] `addonBefore` is deprecated. Please use `Space.Compact` instead."];
/**
* 网络与资源失败绝不放行:复现台必须是真正的同源本地确定性场景。
* 出现 4xx/5xx/ERR_* 一律判失败,由根因修复,而不是扩大 allowlist。
*/
const results = [];
let failures = 0;
function pass(name, detail = "") {
results.push({ ok: true, name, detail });
console.log(`PASS ${name}${detail ? " — " + detail : ""}`);
}
function fail(name, detail = "") {
results.push({ ok: false, name, detail });
failures += 1;
console.log(`FAIL ${name}${detail ? " — " + detail : ""}`);
}
function assert(condition, name, detail = "") {
if (condition) pass(name, detail);
else fail(name, detail);
return Boolean(condition);
}
function resolveChrome() {
for (const candidate of CHROME_CANDIDATES) {
if (candidate && existsSync(candidate)) return candidate;
}
throw new Error("No Chrome binary found. Set CHROME_BIN or install google-chrome/chromium. Tried:\n " + CHROME_CANDIDATES.join("\n "));
}
function freePort() {
return new Promise((resolve, reject) => {
const server = createServer();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 启动 Vite DEV,等待 ready 行或 TCP 可连接;超时即抛。 */
async function launchVite(port) {
const child = spawn("bunx", ["vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
});
let log = "";
child.stdout.on("data", (d) => {
log += d.toString();
});
child.stderr.on("data", (d) => {
log += d.toString();
});
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Vite exited early (code ${child.exitCode}):\n${log}`);
try {
const res = await fetch(`http://127.0.0.1:${port}/dev/director-repro`);
if (res.ok) return child;
} catch {
// not up yet
}
await sleep(500);
}
throw new Error(`Vite did not become ready within 120s:\n${log}`);
}
/** 启动 headless Chrome 并等待 CDP /json/version;超时即抛。 */
async function launchChrome(chromePath, cdpPort, profileDir) {
const args = [
"--headless=new",
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${profileDir}`,
"--no-first-run",
"--no-default-browser-check",
"--window-size=1440,1000",
// 保留 WebGL:不要 --disable-gpu。SwiftShader 提供确定性软件渲染。
"--enable-unsafe-swiftshader",
"--use-angle=swiftshader",
];
if (process.platform === "linux" || process.env.CI) args.push("--no-sandbox", "--disable-dev-shm-usage");
args.push("about:blank");
const child = spawn(chromePath, args, { stdio: ["ignore", "pipe", "pipe"] });
let log = "";
child.stdout.on("data", (d) => {
log += d.toString();
});
child.stderr.on("data", (d) => {
log += d.toString();
});
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Chrome exited early (code ${child.exitCode}):\n${log}`);
try {
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
if (res.ok) return child;
} catch {
// not up yet
}
await sleep(400);
}
throw new Error(`Chrome CDP did not become ready within 60s:\n${log}`);
}
/**
* CDP 客户端:id/pending map + 事件收集。
* 只放行 ALLOWED_NOISE 精确匹配,其他异常/console error/网络失败全部记入 problems。
*/
async function connectCdp(cdpPort) {
const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
const target = list.find((t) => t.type === "page");
if (!target) throw new Error("No CDP page target found");
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP websocket open timeout")), 20000);
ws.addEventListener(
"open",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
ws.addEventListener(
"error",
(e) => {
clearTimeout(timer);
reject(new Error("CDP websocket error: " + String(e?.message || e)));
},
{ once: true },
);
});
const pending = new Map();
let nextId = 0;
const problems = [];
const record = (kind, text) => {
const clean = String(text ?? "").trim();
if (!clean) return;
if (ALLOWED_NOISE.includes(clean)) return;
problems.push({ kind, text: clean });
};
ws.addEventListener("message", (ev) => {
let msg;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(`CDP error: ${JSON.stringify(msg.error)}`));
else resolve(msg.result);
return;
}
const p = msg.params;
switch (msg.method) {
case "Runtime.exceptionThrown":
record("exception", p?.exceptionDetails?.exception?.description || p?.exceptionDetails?.text);
break;
case "Log.entryAdded":
if (p?.entry?.level === "error") record("log.error", p.entry.text);
break;
case "Runtime.consoleAPICalled":
if (p?.type === "error") record("console.error", (p.args || []).map((a) => a.value ?? a.description ?? "").join(" "));
break;
case "Network.loadingFailed":
record("network.failed", `${p?.type || "?"} ${p?.errorText || "?"}`);
break;
case "Network.responseReceived":
if (typeof p?.response?.status === "number" && p.response.status >= 400) {
record("network.status", `${p.response.status} ${p.response.url}`);
}
break;
default:
break;
}
});
const send = (method, params = {}) => {
const id = ++nextId;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}
}, 30000);
});
};
await send("Runtime.enable");
await send("Page.enable");
await send("Log.enable");
await send("Network.enable");
const evaluate = async (expression) => {
const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) {
throw new Error("evaluate threw: " + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
}
return r.result.value;
};
const poll = async (expression, label, timeout = 15000, interval = 250) => {
const deadline = Date.now() + timeout;
let last;
while (Date.now() < deadline) {
last = await evaluate(expression);
if (last) return true;
await sleep(interval);
}
console.log(` (poll timed out after ${timeout}ms: ${label}, last=${JSON.stringify(last)})`);
return false;
};
/**
* 真实鼠标点击:先量出可见目标的中心点,再派发 Input.dispatchMouseEvent。
* 不用 el.click(),因为那是 untrusted 合成事件,拿不到真实 user gesture。
*/
const clickPoint = async (locatorExpression, label) => {
const box = await evaluate(`(() => {
const el = ${locatorExpression};
if (!el) return null;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
el.scrollIntoView({ block: "center", inline: "center" });
const after = el.getBoundingClientRect();
return { x: after.left + after.width / 2, y: after.top + after.height / 2 };
})()`);
if (!box) {
console.log(` (click target not visible: ${label})`);
return false;
}
const point = { x: Math.round(box.x), y: Math.round(box.y), button: "left", buttons: 1 };
await send("Input.dispatchMouseEvent", { type: "mouseMoved", ...point });
await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point, clickCount: 1 });
await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, clickCount: 1 });
return true;
};
const click = (selector) => clickPoint(`document.querySelector(${JSON.stringify(selector)})`, selector);
const clickText = (text, tag = "button") => clickPoint(`[...document.querySelectorAll(${JSON.stringify(tag)})].find((b) => (b.textContent || "").includes(${JSON.stringify(text)}) && b.getClientRects().length > 0)`, `${tag}:contains(${text})`);
/** 每个场景都从干净页面开始:诊断缓冲区与 store 都重置。 */
const navigateFresh = async (url) => {
// 必须在导航前清空:导航后再清会吞掉 bootstrap 阶段的真实异常。
problems.length = 0;
await send("Page.navigate", { url });
const ok = await poll(`!!document.querySelector('[data-testid="inject-local-model"]')`, "lab mounted", 60000);
if (!ok) throw new Error("Repro lab did not mount within 60s");
return true;
};
return { send, evaluate, poll, click, clickText, navigateFresh, problems, close: () => ws.close() };
}
/**
* 只终止本脚本记录的子进程:先 SIGTERM,有界等待后才对同一 PID SIGKILL。
*
* 被信号杀死的 Node 子进程 exitCode 仍为 null,只有 signalCode 有值,
* 因此「已停止」必须同时看两者,否则会误判成还在运行并一路等到硬截止。
*/
async function stopExact(child, name) {
if (!child) return;
const stopped = () => child.exitCode !== null || child.signalCode !== null;
if (stopped()) return;
try {
child.kill("SIGTERM");
} catch {
/* already gone */
}
const deadline = Date.now() + 8000;
while (Date.now() < deadline && !stopped()) await sleep(200);
if (!stopped()) {
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
const hard = Date.now() + 4000;
while (Date.now() < hard && !stopped()) await sleep(200);
}
if (!stopped()) {
throw new Error(`Failed to stop ${name} (pid=${child.pid}) after SIGTERM and SIGKILL`);
}
console.log(` stopped ${name} (pid=${child.pid}, exit=${child.exitCode}, signal=${child.signalCode})`);
}
/** 场景 A:复现台 + 工作台基础集成链路(AutoKey 默认、新增、Undo)。 */
async function smokeWorkbench(cdp, baseUrl) {
console.log("\n=== A. workbench smoke ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
const initial = await cdp.evaluate(`(() => {
const headings = [...document.querySelectorAll('h2')].map((h) => h.textContent || "");
return {
snapshot: headings.some((t) => t.includes('环境快照')),
matrix15: headings.some((t) => t.includes('P0 手工复现矩阵(15)')),
injectLocal: !!document.querySelector('[data-testid="inject-local-model"]'),
injectMissing: !!document.querySelector('[data-testid="inject-missing-model"]'),
shellCount: document.querySelectorAll('.director-viewport-shell').length,
};
})()`);
assert(initial.snapshot, "A1 环境快照 rendered");
assert(initial.matrix15, "A2 P0 手工复现矩阵(15)rendered");
assert(initial.injectLocal && initial.injectMissing, "A3 both inject buttons present");
assert(initial.shellCount === 0, "A4 workbench closed initially", `shellCount=${initial.shellCount}`);
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("A: toggle-workbench not clickable");
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
assert(hasCanvas, "A5 real canvas present in viewport shell");
const autoKey = await cdp.evaluate(`document.querySelector('button[title="自动关键帧"]')?.getAttribute('aria-pressed') ?? null`);
assert(autoKey === "false", "A6 AutoKey defaults to aria-pressed=false", `got ${JSON.stringify(autoKey)}`);
const addedCube = await cdp.click('[aria-label="添加立方体"]');
if (!addedCube) throw new Error("A: 添加立方体 button not clickable");
const cubeAppeared = await cdp.poll(`!!document.querySelector('[aria-label="删除立方体"]')`, "cube row", 20000);
assert(cubeAppeared, "A7 added cube appears in object list");
const undone = await cdp.click('[aria-label="撤销"]');
if (!undone) throw new Error("A: 撤销 button not clickable");
const cubeGone = await cdp.poll(`!document.querySelector('[aria-label="删除立方体"]')`, "cube removed by undo", 20000);
assert(cubeGone, "A8 Undo removes the added cube");
// 场景结束前必须真实关闭:下一个场景要重新导航,不能靠忽略 beforeunload 绕过未保存态。
const closed = await cdp.click('[aria-label="关闭导演台"]');
if (!closed) throw new Error("A: 关闭导演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 30000);
assert(shellGone, "A9 workbench closed cleanly before leaving scenario A");
assert(cdp.problems.length === 0, "A10 no browser problems in scenario A", JSON.stringify(cdp.problems));
}
/**
* 场景 B:本地 triangle glTF。
* 判据是集成层面的稳定窗口:5s 内不出现任何失败态且 canvas 持续可用。
* 名称出现只证明对象在场景里,不等于 loader ready,因此不作为 ready 断言。
*/
async function localModel(cdp, baseUrl) {
console.log("\n=== B. local triangle model ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
const injected = await cdp.click('[data-testid="inject-local-model"]');
if (!injected) throw new Error("B: inject-local-model not clickable");
const counted = await cdp.poll(`(document.querySelector('[data-testid="object-count"]')?.textContent || "").includes('4')`, "object count 4", 15000);
assert(counted, "B1 local model injected into scene");
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("B: toggle-workbench not clickable");
const rowReady = await cdp.poll(`!!document.querySelector('[aria-label="删除本地模型 repro triangle"]')`, "model row", 30000);
assert(rowReady, "B2 local model row present in object list");
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
assert(hasCanvas, "B3 real canvas present");
await sleep(5000);
const stable = await cdp.evaluate(`(() => {
const t = document.body.innerText || "";
const c = document.querySelector('.director-viewport-shell canvas');
return {
modelFailed: t.includes('个 3D 模型加载失败'),
retryLoad: [...document.querySelectorAll('.director-viewport-notice button')].some((b) => (b.textContent || "").includes('重试加载') && b.getClientRects().length > 0),
renderFailed: t.includes('3D 视口渲染失败'),
contextLost: t.includes('3D 显示上下文已丢失'),
canvasUsable: !!c && c.clientWidth > 0 && c.clientHeight > 0,
};
})()`);
assert(!stable.modelFailed, "B4 no model-load-failed notice", JSON.stringify(stable));
assert(!stable.retryLoad, "B5 no retry-load affordance");
assert(!stable.renderFailed, "B6 no viewport render failure");
assert(!stable.contextLost, "B7 no WebGL context-lost notice");
assert(stable.canvasUsable, "B8 5s 稳定窗口内无失败且 canvas 持续可用");
assert(cdp.problems.length === 0, "B9 no browser problems in scenario B", JSON.stringify(cdp.problems));
}
/**
* 场景 C:缺失模型失败 → 用户重试 → 第二轮失败。
* 通过「提示消失再重新出现」证明重试真的重跑了加载,而不是命中陈旧提示。
*/
async function missingRetry(cdp, baseUrl) {
console.log("\n=== C. missing model failure and retry ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
const injected = await cdp.click('[data-testid="inject-missing-model"]');
if (!injected) throw new Error("C: inject-missing-model not clickable");
const counted = await cdp.poll(`(document.querySelector('[data-testid="object-count"]')?.textContent || "").includes('4')`, "object count 4", 15000);
assert(counted, "C1 missing model injected into scene");
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("C: toggle-workbench not clickable");
const failed = await cdp.poll(`/个 3D 模型加载失败/.test(document.body.innerText || "")`, "load-failed notice", 40000);
assert(failed, "C2 model-load-failed notice appears");
const retryVisible = await cdp.poll(`[...document.querySelectorAll('button')].some((b) => (b.textContent || "").includes('重试加载'))`, "retry affordance", 20000);
assert(retryVisible, "C3 actionable 重试加载 affordance present");
const clickedRetry = await cdp.clickText("重试加载");
if (!clickedRetry) throw new Error("C: 重试加载 button not clickable");
const noticeCleared = await cdp.poll(`!/个 3D 模型加载失败/.test(document.body.innerText || "")`, "notice cleared after retry", 20000);
assert(noticeCleared, "C4 notice clears when retry restarts the load");
const failedAgain = await cdp.poll(`/个 3D 模型加载失败/.test(document.body.innerText || "")`, "second-round failure", 40000);
assert(failedAgain, "C5 second-round failure after retry");
const closed = await cdp.click('[aria-label="关闭导演台"]');
if (!closed) throw new Error("C: 关闭导演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 20000);
assert(shellGone, "C6 workbench closed cleanly");
const refreshed = await cdp.click('[data-testid="refresh-events"]');
if (!refreshed) throw new Error("C: refresh-events not clickable");
const hasRetryCode = await cdp.poll(`(document.body.innerText || "").includes('DIRECTOR_MODEL_LOAD_RETRY')`, "retry diagnostic", 20000);
assert(hasRetryCode, "C7 DIRECTOR_MODEL_LOAD_RETRY recorded");
const twoFailures = await cdp.poll(`((document.body.innerText || "").match(/DIRECTOR_MODEL_LOAD_FAILED/g) || []).length >= 2`, "two load failures", 20000);
const failCount = await cdp.evaluate(`((document.body.innerText || "").match(/DIRECTOR_MODEL_LOAD_FAILED/g) || []).length`);
assert(twoFailures, "C8 DIRECTOR_MODEL_LOAD_FAILED recorded at least twice", `count=${failCount}`);
assert(cdp.problems.length === 0, "C9 no browser problems in scenario C", JSON.stringify(cdp.problems));
}
/**
* 场景 D:加载中删除。
* 用真实网络节流把 GLB 拉长到仍在飞行中,再立刻删除对象;
* 恢复网络后必须没有晚到回流,也不能为已删除对象记任何失败诊断。
*/
async function deleteWhileLoading(cdp, baseUrl) {
console.log("\n=== D. delete while loading (throttled) ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
try {
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 3000,
downloadThroughput: 20000,
uploadThroughput: 20000,
connectionType: "cellular3g",
});
const injected = await cdp.click('[data-testid="inject-local-model"]');
if (!injected) throw new Error("D: inject-local-model not clickable");
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("D: toggle-workbench not clickable");
const rowReady = await cdp.poll(`!!document.querySelector('[aria-label="删除本地模型 repro triangle"]')`, "model row", 30000);
assert(rowReady, "D1 model row present while load still in flight");
const deleted = await cdp.click('[aria-label="删除本地模型 repro triangle"]');
if (!deleted) throw new Error("D: delete button not clickable");
const gone = await cdp.poll(`!(document.body.innerText || "").includes('本地模型 repro triangle')`, "name removed", 20000);
assert(gone, "D2 object removed while its load was in flight");
} finally {
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
});
}
await sleep(5000);
const settled = await cdp.evaluate(`(() => {
const t = document.body.innerText || "";
const c = document.querySelector('.director-viewport-shell canvas');
return {
nameBack: t.includes('本地模型 repro triangle'),
modelFailed: t.includes('个 3D 模型加载失败'),
retryLoad: [...document.querySelectorAll('.director-viewport-notice button')].some((b) => (b.textContent || "").includes('重试加载') && b.getClientRects().length > 0),
renderFailed: t.includes('3D 视口渲染失败'),
contextLost: t.includes('3D 显示上下文已丢失'),
canvasUsable: !!c && c.clientWidth > 0 && c.clientHeight > 0,
};
})()`);
assert(!settled.nameBack, "D3 no late-arriving reflow of the deleted object", JSON.stringify(settled));
assert(!settled.modelFailed && !settled.retryLoad, "D4 no failure/retry surfaced for deleted object");
assert(!settled.renderFailed && !settled.contextLost, "D5 viewport stayed healthy");
assert(settled.canvasUsable, "D6 canvas still usable after in-flight delete");
const closed = await cdp.click('[aria-label="关闭导演台"]');
if (!closed) throw new Error("D: 关闭导演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 20000);
assert(shellGone, "D7 workbench closed cleanly");
const refreshed = await cdp.click('[data-testid="refresh-events"]');
if (!refreshed) throw new Error("D: refresh-events not clickable");
await sleep(600);
const localDiag = await cdp.evaluate(`(() => [...document.querySelectorAll('.ant-table-tbody tr.ant-table-row')]
.map((r) => [...r.querySelectorAll('td')].map((td) => td.innerText).join(" "))
.filter((row) => row.includes('repro-model-local') && (row.includes('DIRECTOR_MODEL_LOAD_FAILED') || row.includes('DIRECTOR_MODEL_ADOPT_FAILED'))))()`);
assert(localDiag.length === 0, "D8 no LOAD_FAILED/ADOPT_FAILED for the deleted object", JSON.stringify(localDiag));
assert(cdp.problems.length === 0, "D9 no browser problems in scenario D", JSON.stringify(cdp.problems));
}
/**
* 场景 E:真实 WebGL 上下文丢失与恢复。
* 用 WEBGL_lose_context 扩展驱动真实 GPU 事件,不伪造 DOM 状态。
*/
async function webglLossRestore(cdp, baseUrl) {
console.log("\n=== E. WebGL context lost and restored ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("E: toggle-workbench not clickable");
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
assert(hasCanvas, "E1 real canvas present before context loss");
// 真实就绪门:capture context 已登记且未 lost,说明监听器已安装。
// 早于此触发 loseContext 会让事件落在监听器安装之前。
const rendererReady = await cdp.poll(`document.querySelector('.director-viewport-shell')?.getAttribute('data-renderer-ready') === 'true'`, "renderer ready", 30000);
assert(rendererReady, "E2 capture renderer registered before context loss");
const prepared = await cdp.evaluate(`(() => {
const canvas = document.querySelector('.director-viewport-shell canvas');
if (!canvas) return false;
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) return false;
const ext = gl.getExtension('WEBGL_lose_context');
if (!ext || typeof ext.loseContext !== 'function' || typeof ext.restoreContext !== 'function') return false;
window.__directorE2ELoseContext = ext;
return true;
})()`);
if (!prepared) throw new Error("E: WEBGL_lose_context extension unavailable — cannot drive a real context loss");
assert(prepared, "E3 WEBGL_lose_context extension acquired from live context");
await cdp.evaluate(`(() => { window.__directorE2ELoseContext.loseContext(); return true; })()`);
const lostNotice = await cdp.poll(`(document.body.innerText || "").includes('3D 显示上下文已丢失')`, "context-lost notice", 30000);
assert(lostNotice, "E4 context-lost notice surfaced in DOM");
await cdp.evaluate(`(() => { window.__directorE2ELoseContext.restoreContext(); return true; })()`);
const restored = await cdp.poll(`!(document.body.innerText || "").includes('3D 显示上下文已丢失')`, "context-lost notice cleared", 30000);
assert(restored, "E5 context-lost notice cleared after restore");
const canvasUsable = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0 && c.clientHeight > 0; })()`, "canvas usable after restore", 20000);
assert(canvasUsable, "E6 canvas remains usable after restore");
const closed = await cdp.click('[aria-label="关闭导演台"]');
if (!closed) throw new Error("E: 关闭导演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 20000);
assert(shellGone, "E7 workbench closed cleanly");
const refreshed = await cdp.click('[data-testid="refresh-events"]');
if (!refreshed) throw new Error("E: refresh-events not clickable");
const hasLost = await cdp.poll(`(document.body.innerText || "").includes('DIRECTOR_VIEWPORT_CONTEXT_LOST')`, "lost diagnostic", 20000);
assert(hasLost, "E8 DIRECTOR_VIEWPORT_CONTEXT_LOST recorded");
const hasRestored = await cdp.poll(`(document.body.innerText || "").includes('DIRECTOR_VIEWPORT_CONTEXT_RESTORED')`, "restored diagnostic", 20000);
assert(hasRestored, "E9 DIRECTOR_VIEWPORT_CONTEXT_RESTORED recorded");
assert(cdp.problems.length === 0, "E10 no browser problems in scenario E", JSON.stringify(cdp.problems));
}
/**
* 场景 F:强制保存失败 → 头部错误态与重试入口 → 关闭走确认保护。
* 选择「留在导演台」后 workbench 必须仍然存在(onClose 不得被调用)。
*/
async function saveFailureCloseGuard(cdp, baseUrl) {
console.log("\n=== F. save failure and close guard ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
const toggledFailure = await cdp.click('[data-testid="force-save-failure"]');
if (!toggledFailure) throw new Error("F: force-save-failure switch not clickable");
const failureOn = await cdp.poll(`document.querySelector('[data-testid="force-save-failure"]')?.getAttribute('aria-checked') === 'true'`, "failure switch on", 15000);
assert(failureOn, "F1 forced save failure enabled");
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("F: toggle-workbench not clickable");
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
assert(hasCanvas, "F2 workbench open with real canvas");
// canonical 改动:dock 新增立方体会走 commit → coordinator.edit → flush(被强制失败)。
const addedCube = await cdp.click('[aria-label="添加立方体"]');
if (!addedCube) throw new Error("F: 添加立方体 button not clickable");
const errorState = await cdp.poll(`(document.body.innerText || "").includes('保存失败')`, "save failure header", 40000);
assert(errorState, "F3 header surfaces 保存失败 after forced flush failure");
const retryVisible = await cdp.poll(`[...document.querySelectorAll('button')].some((b) => (b.textContent || "").includes('重试保存'))`, "retry save affordance", 20000);
assert(retryVisible, "F4 actionable 重试保存 affordance present");
const closeClicked = await cdp.click('[aria-label="关闭导演台"]');
if (!closeClicked) throw new Error("F: 关闭导演台 button not clickable");
const modalShown = await cdp.poll(`!!document.querySelector('.ant-modal-confirm') && (document.body.innerText || "").includes('留在导演台')`, "close confirm modal", 40000);
assert(modalShown, "F5 close is guarded by a confirm dialog, not silent exit");
const stayClicked = await cdp.clickText("留在导演台");
if (!stayClicked) throw new Error("F: 留在导演台 button not clickable");
const modalGone = await cdp.poll(`!document.querySelector('.ant-modal-confirm')`, "modal dismissed", 20000);
assert(modalGone, "F6 confirm dialog dismissed after choosing 留在导演台");
await sleep(1000);
const stillOpen = await cdp.evaluate(`document.querySelectorAll('.director-viewport-shell').length`);
assert(stillOpen === 1, "F7 workbench remains open after choosing 留在导演台", `shellCount=${stillOpen}`);
assert(cdp.problems.length === 0, "F8 no browser problems in scenario F", JSON.stringify(cdp.problems));
}
async function main() {
const chromePath = resolveChrome();
console.log(`Chrome binary: ${chromePath}`);
const vitePort = await freePort();
const cdpPort = await freePort();
const baseUrl = `http://127.0.0.1:${vitePort}`;
const profileDir = mkdtempSync(join(tmpdir(), "director-p0-e2e-"));
let vite = null;
let chrome = null;
let cdp = null;
try {
console.log(`Starting Vite on ${baseUrl} ...`);
vite = await launchVite(vitePort);
console.log(` vite pid=${vite.pid}`);
console.log(`Starting Chrome with CDP on 127.0.0.1:${cdpPort} ...`);
chrome = await launchChrome(chromePath, cdpPort, profileDir);
console.log(` chrome pid=${chrome.pid}, profile=${profileDir}`);
cdp = await connectCdp(cdpPort);
console.log(" CDP connected (Runtime, Page, Log, Network enabled)");
for (const scenario of [smokeWorkbench, localModel, missingRetry, deleteWhileLoading, webglLossRestore, saveFailureCloseGuard]) {
try {
await scenario(cdp, baseUrl);
} catch (error) {
fail(`${scenario.name} threw`, String(error?.message || error));
}
}
} finally {
try {
cdp?.close();
} catch {
/* socket already closed */
}
// 三个清理步骤互不阻塞:任一失败都记为断言失败(最终 exit 1),但不吞掉其余清理。
try {
await stopExact(chrome, "chrome");
} catch (error) {
fail("cleanup: stop chrome", String(error?.message || error));
}
try {
await stopExact(vite, "vite");
} catch (error) {
fail("cleanup: stop vite", String(error?.message || error));
}
try {
rmSync(profileDir, { recursive: true, force: true });
console.log(` removed profile ${profileDir}`);
} catch (error) {
fail("cleanup: remove profile", `${profileDir}: ${String(error?.message || error)}`);
}
}
const passed = results.filter((r) => r.ok).length;
console.log("\n================ SUMMARY ================");
console.log(`assertions: ${passed} passed, ${failures} failed, ${results.length} total`);
for (const r of results.filter((x) => !x.ok)) console.log(` FAILED: ${r.name}${r.detail ? " — " + r.detail : ""}`);
console.log("=========================================");
if (failures > 0) {
console.error(`\nDirector P0 Chrome E2E FAILED (${failures} assertion(s)).`);
process.exit(1);
}
console.log("\nDirector P0 Chrome E2E PASSED.");
}
main().catch((error) => {
console.error("\nDirector P0 Chrome E2E crashed:", error?.stack || error);
process.exit(1);
});