refactor(canvas): 预演台 - 重构工作台与场景交互链路

This commit is contained in:
ddcat666
2026-10-06 09:06:43 +08:00
parent 1dfc8ce7ac
commit 112916587c
128 changed files with 42372 additions and 5011 deletions

View File

@@ -2,7 +2,7 @@
* Issue #305 P0 — 真实 Chrome E2E。
*
* 无新增依赖:只用 Bun/Node 内置 spawn / fetch / WebSocket / fs。
* 自己启动 Vite DEV 与 headless Chrome,通过 CDP 驱动 /dev/director-repro。
* 自己启动 Vite DEV 与 headless Chrome,通过 CDP 驱动 /dev/previs-repro。
* 所有等待都有硬超时;任何断言失败 → exit 1。
* finally 只终止本脚本记录的 PID,只删除本脚本 mkdtemp 创建的 profile。
*/
@@ -74,7 +74,13 @@ 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"], {
// package.json 仍可由 Bun 直接执行;没有 Bun 时用 Node 调本地 Vite 入口。
// 不能把 `vite` 同时当作 bunx 子命令和 Vite 根目录传入,否则会启动到
// <web>/vite,SPA 根路径与 /dev/previs-repro 都会返回 404。
const viteEntry = join(process.cwd(), "node_modules", "vite", "bin", "vite.js");
const command = process.versions.bun ? "bunx" : process.execPath;
const args = process.versions.bun ? ["vite"] : [viteEntry];
const child = spawn(command, [...args, "--host", "127.0.0.1", "--port", String(port), "--strictPort"], {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
});
@@ -90,7 +96,7 @@ async function launchVite(port) {
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`);
const res = await fetch(`http://127.0.0.1:${port}/dev/previs-repro`);
if (res.ok) return child;
} catch {
// not up yet
@@ -280,7 +286,7 @@ async function connectCdp(cdpPort) {
})()`);
// 目标必须"站稳"再点:AntD 弹窗/抽屉有约 200ms 入场动画,只要求相邻两次读数相同
// 会在动画刚开始、坐标还没动的那一拍就下判定,真正派发鼠标事件时控件已经移位 ——
// 点击落到遮罩上(mask.closable=false)就完全无效("留在导演台" 曾这样偶发失败)。
// 点击落到遮罩上(mask.closable=false)就完全无效("留在预演台" 曾这样偶发失败)。
const deadline = Date.now() + 8000;
let previous = null;
let stableSince = 0;
@@ -364,19 +370,17 @@ async function stopExact(child, name) {
}
/**
* P0 场景需要覆盖完整对象 / 动画 / 保存路径;导演台默认是快速镜头模式,
* 进入工作台后显式切换到高级工作台,避免测试把默认工作流误当成高级工作台。
* The single previs workbench is ready as soon as its layout context is visible.
* There is no second page or mode-specific workbench to enter.
*/
async function openAdvancedWorkbench(cdp, label) {
const advanced = await cdp.click('button[title="打开完整的摆场、姿态、动画和摄影机工具"]');
if (!advanced) throw new Error(`${label}: 高级工作台 button not clickable`);
const ready = await cdp.poll(`document.querySelector('button[data-mode="layout"]')?.getAttribute('aria-pressed') === 'true'`, `${label} advanced workbench`, 20000);
if (!ready) throw new Error(`${label}: 高级工作台未进入摆场模式`);
async function openUnifiedWorkbench(cdp, label) {
const ready = await cdp.poll(`!!document.querySelector('[data-canvas-previs-workbench]') && !!document.querySelector('.pv-layout') && !!document.querySelector('.previs-viewport-shell')`, `${label} unified previs workbench`, 20000);
if (!ready) throw new Error(`${label}: unified previs workbench did not enter layout context`);
}
async function smokeWorkbench(cdp, baseUrl) {
console.log("\n=== A. workbench smoke ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
await cdp.navigateFresh(`${baseUrl}/dev/previs-repro`);
const initial = await cdp.evaluate(`(() => {
const headings = [...document.querySelectorAll('h2')].map((h) => h.textContent || "");
@@ -385,7 +389,7 @@ async function smokeWorkbench(cdp, baseUrl) {
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,
shellCount: document.querySelectorAll('.previs-viewport-shell').length,
};
})()`);
assert(initial.snapshot, "A1 环境快照 rendered");
@@ -395,11 +399,11 @@ async function smokeWorkbench(cdp, baseUrl) {
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);
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.previs-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
assert(hasCanvas, "A5 real canvas present in viewport shell");
const defaultQuickMode = await cdp.evaluate(`document.querySelector('button[title="用镜头优先的最短路径完成摆位、姿态和预演"]')?.getAttribute('aria-pressed')`);
assert(defaultQuickMode === "true", "A5a 默认进入快速镜头模式", `got ${JSON.stringify(defaultQuickMode)}`);
await openAdvancedWorkbench(cdp, "A");
const defaultLayoutMode = await cdp.evaluate(`document.querySelector('button[data-mode="layout"]')?.getAttribute('aria-pressed')`);
assert(defaultLayoutMode === "true", "A5a 统一预演台默认进入布景上下文", `got ${JSON.stringify(defaultLayoutMode)}`);
await openUnifiedWorkbench(cdp, "A");
// 使用真实产品 dock 验证 Tooltip 的 hover 与键盘触发,不能只验证包装 span 存在。
const tooltipButton = 'button[aria-label="移动对象"]';
@@ -421,38 +425,34 @@ async function smokeWorkbench(cdp, baseUrl) {
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
await cdp.evaluate("document.activeElement?.blur()");
// P1-A 起 AutoKey/时间轴归属动画模式:默认摆场模式下它们必须不存在。
// 统一工作台默认同时提供镜头设计与可收起的动画时间轴;自动关键帧初始关闭。
const layoutGating = await cdp.evaluate(`(() => ({
mode: document.querySelector('button[data-mode="layout"]')?.getAttribute('aria-pressed') ?? null,
sequencer: document.querySelectorAll('.director-sequencer').length,
autoKey: document.querySelectorAll('button[title="自动关键帧"]').length,
workbench: !!document.querySelector('[data-canvas-previs-workbench]'),
layout: !!document.querySelector('.pv-layout'),
sequencer: document.querySelectorAll('.previs-sequencer').length,
autoKey: document.querySelector('button[title="自动关键帧"]')?.getAttribute('aria-pressed') ?? null,
}))()`);
assert(layoutGating.mode === "true", "A6 默认进入摆场模式", `got ${JSON.stringify(layoutGating.mode)}`);
assert(layoutGating.sequencer === 0 && layoutGating.autoKey === 0, "A7 摆场模式不显示时间轴与 AutoKey", JSON.stringify(layoutGating));
assert(layoutGating.workbench && layoutGating.layout, "A6 统一预演台布局上下文已就绪", JSON.stringify(layoutGating));
assert(layoutGating.sequencer === 1 && layoutGating.autoKey === "false", "A7 时间轴可见且 AutoKey 默认关闭", JSON.stringify(layoutGating));
// 原 A6 的断言意图(AutoKey 默认不开启)在它真正存在的模式里继续守住。
const switched = await cdp.click('button[data-mode="animate"]');
if (!switched) throw new Error("A: 动画模式按钮 not clickable");
const sequencerShown = await cdp.poll(`document.querySelectorAll('.director-sequencer').length === 1`, "sequencer in animate mode", 20000);
assert(sequencerShown, "A8 动画模式显示时间轴");
const autoKey = await cdp.evaluate(`document.querySelector('button[title="自动关键帧"]')?.getAttribute('aria-pressed') ?? null`);
assert(autoKey === "false", "A9 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);
const propMenuOpened = await cdp.click('[aria-label="添加道具"]');
if (!propMenuOpened) throw new Error("A: 添加道具 button not clickable");
const cubeMenuReady = await cdp.poll(`!![...document.querySelectorAll('[role="menuitem"]')].find((item) => (item.textContent || '').trim() === '立方体' && item.getClientRects().length > 0)`, "cube menu item", 10000);
if (!cubeMenuReady) throw new Error("A: 立方体 menu item not visible");
const addedCube = await cdp.clickText("立方体", '[role="menuitem"]');
if (!addedCube) throw new Error("A: 立方体 menu item not clickable");
const cubeAppeared = await cdp.poll(`!!document.querySelector('[aria-label="删除 立方体"]')`, "cube row", 20000);
assert(cubeAppeared, "A10 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);
const cubeGone = await cdp.poll(`!document.querySelector('[aria-label="删除 立方体"]')`, "cube removed by undo", 20000);
assert(cubeGone, "A11 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);
const closed = await cdp.click('[aria-label="关闭预演台"]');
if (!closed) throw new Error("A: 关闭预演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.previs-viewport-shell').length === 0`, "workbench closed", 30000);
assert(shellGone, "A12 workbench closed cleanly before leaving scenario A");
assert(cdp.problems.length === 0, "A13 no browser problems in scenario A", JSON.stringify(cdp.problems));
@@ -465,7 +465,7 @@ async function smokeWorkbench(cdp, baseUrl) {
*/
async function localModel(cdp, baseUrl) {
console.log("\n=== B. local triangle model ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
await cdp.navigateFresh(`${baseUrl}/dev/previs-repro`);
const injected = await cdp.click('[data-testid="inject-local-model"]');
if (!injected) throw new Error("B: inject-local-model not clickable");
@@ -474,21 +474,21 @@ async function localModel(cdp, baseUrl) {
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("B: toggle-workbench not clickable");
await openAdvancedWorkbench(cdp, "B");
await openUnifiedWorkbench(cdp, "B");
const rowReady = await cdp.poll(`!!document.querySelector('[aria-label="删除本地模型 repro triangle"]')`, "model row", 30000);
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);
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.previs-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');
const c = document.querySelector('.previs-viewport-shell canvas');
return {
modelFailed: t.includes('个 3D 模型加载失败'),
retryLoad: [...document.querySelectorAll('.director-viewport-notice button')].some((b) => (b.textContent || "").includes('重试加载') && b.getClientRects().length > 0),
retryLoad: [...document.querySelectorAll('.previs-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,
@@ -509,7 +509,7 @@ async function localModel(cdp, baseUrl) {
*/
async function missingRetry(cdp, baseUrl) {
console.log("\n=== C. missing model failure and retry ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
await cdp.navigateFresh(`${baseUrl}/dev/previs-repro`);
const injected = await cdp.click('[data-testid="inject-missing-model"]');
if (!injected) throw new Error("C: inject-missing-model not clickable");
@@ -518,7 +518,7 @@ async function missingRetry(cdp, baseUrl) {
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("C: toggle-workbench not clickable");
await openAdvancedWorkbench(cdp, "C");
await openUnifiedWorkbench(cdp, "C");
const failed = await cdp.poll(`/个 3D 模型加载失败/.test(document.body.innerText || "")`, "load-failed notice", 40000);
assert(failed, "C2 model-load-failed notice appears");
@@ -533,19 +533,19 @@ async function missingRetry(cdp, baseUrl) {
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);
const closed = await cdp.click('[aria-label="关闭预演台"]');
if (!closed) throw new Error("C: 关闭预演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.previs-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}`);
const hasRetryCode = await cdp.poll(`(document.body.innerText || "").includes('PREVIS_MODEL_LOAD_RETRY')`, "retry diagnostic", 20000);
assert(hasRetryCode, "C7 PREVIS_MODEL_LOAD_RETRY recorded");
const twoFailures = await cdp.poll(`((document.body.innerText || "").match(/PREVIS_MODEL_LOAD_FAILED/g) || []).length >= 2`, "two load failures", 20000);
const failCount = await cdp.evaluate(`((document.body.innerText || "").match(/PREVIS_MODEL_LOAD_FAILED/g) || []).length`);
assert(twoFailures, "C8 PREVIS_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));
}
@@ -557,7 +557,7 @@ async function missingRetry(cdp, baseUrl) {
*/
async function deleteWhileLoading(cdp, baseUrl) {
console.log("\n=== D. delete while loading (throttled) ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
await cdp.navigateFresh(`${baseUrl}/dev/previs-repro`);
try {
await cdp.send("Network.emulateNetworkConditions", {
@@ -572,12 +572,12 @@ async function deleteWhileLoading(cdp, baseUrl) {
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");
await openAdvancedWorkbench(cdp, "D");
await openUnifiedWorkbench(cdp, "D");
const rowReady = await cdp.poll(`!!document.querySelector('[aria-label="删除本地模型 repro triangle"]')`, "model row", 30000);
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"]');
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");
@@ -594,11 +594,11 @@ async function deleteWhileLoading(cdp, baseUrl) {
const settled = await cdp.evaluate(`(() => {
const t = document.body.innerText || "";
const c = document.querySelector('.director-viewport-shell canvas');
const c = document.querySelector('.previs-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),
retryLoad: [...document.querySelectorAll('.previs-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,
@@ -609,9 +609,9 @@ async function deleteWhileLoading(cdp, baseUrl) {
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);
const closed = await cdp.click('[aria-label="关闭预演台"]');
if (!closed) throw new Error("D: 关闭预演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.previs-viewport-shell').length === 0`, "workbench closed", 20000);
assert(shellGone, "D7 workbench closed cleanly");
const refreshed = await cdp.click('[data-testid="refresh-events"]');
@@ -620,7 +620,7 @@ async function deleteWhileLoading(cdp, baseUrl) {
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'))))()`);
.filter((row) => row.includes('repro-model-local') && (row.includes('PREVIS_MODEL_LOAD_FAILED') || row.includes('PREVIS_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));
@@ -632,63 +632,63 @@ async function deleteWhileLoading(cdp, baseUrl) {
*/
async function webglLossRestore(cdp, baseUrl) {
console.log("\n=== E. WebGL context lost and restored ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
await cdp.navigateFresh(`${baseUrl}/dev/previs-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);
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.previs-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);
const rendererReady = await cdp.poll(`document.querySelector('.previs-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');
const canvas = document.querySelector('.previs-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;
window.__previsE2ELoseContext = 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; })()`);
await cdp.evaluate(`(() => { window.__previsE2ELoseContext.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; })()`);
await cdp.evaluate(`(() => { window.__previsE2ELoseContext.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);
const canvasUsable = await cdp.poll(`(() => { const c = document.querySelector('.previs-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);
const closed = await cdp.click('[aria-label="关闭预演台"]');
if (!closed) throw new Error("E: 关闭预演台 button not clickable");
const shellGone = await cdp.poll(`document.querySelectorAll('.previs-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");
const hasLost = await cdp.poll(`(document.body.innerText || "").includes('PREVIS_VIEWPORT_CONTEXT_LOST')`, "lost diagnostic", 20000);
assert(hasLost, "E8 PREVIS_VIEWPORT_CONTEXT_LOST recorded");
const hasRestored = await cdp.poll(`(document.body.innerText || "").includes('PREVIS_VIEWPORT_CONTEXT_RESTORED')`, "restored diagnostic", 20000);
assert(hasRestored, "E9 PREVIS_VIEWPORT_CONTEXT_RESTORED recorded");
assert(cdp.problems.length === 0, "E10 no browser problems in scenario E", JSON.stringify(cdp.problems));
}
/**
* 场景 F:强制保存失败 → 头部错误态与重试入口 → 关闭走确认保护。
* 选择「留在导演台」后 workbench 必须仍然存在(onClose 不得被调用)。
* 选择「留在预演台」后 workbench 必须仍然存在(onClose 不得被调用)。
*/
async function saveFailureCloseGuard(cdp, baseUrl) {
console.log("\n=== F. save failure and close guard ===");
await cdp.navigateFresh(`${baseUrl}/dev/director-repro`);
await cdp.navigateFresh(`${baseUrl}/dev/previs-repro`);
const toggledFailure = await cdp.click('[data-testid="force-save-failure"]');
if (!toggledFailure) throw new Error("F: force-save-failure switch not clickable");
@@ -697,27 +697,31 @@ async function saveFailureCloseGuard(cdp, baseUrl) {
const opened = await cdp.click('[data-testid="toggle-workbench"]');
if (!opened) throw new Error("F: toggle-workbench not clickable");
await openAdvancedWorkbench(cdp, "F");
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
await openUnifiedWorkbench(cdp, "F");
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.previs-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 propMenuOpened = await cdp.click('[aria-label="添加道具"]');
if (!propMenuOpened) throw new Error("F: 添加道具 button not clickable");
const cubeMenuReady = await cdp.poll(`!![...document.querySelectorAll('[role="menuitem"]')].find((item) => (item.textContent || '').trim() === '立方体' && item.getClientRects().length > 0)`, "cube menu item", 10000);
if (!cubeMenuReady) throw new Error("F: 立方体 menu item not visible");
const addedCube = await cdp.clickText("立方体", '[role="menuitem"]');
if (!addedCube) throw new Error("F: 立方体 menu item 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 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);
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 stayClicked = await cdp.clickText("留在预演台");
if (!stayClicked) throw new Error("F: 留在预演台 button not clickable");
const modalGone = await cdp.poll(
`![...document.querySelectorAll('.ant-modal-confirm')].some((modal) => {
const rect = modal.getBoundingClientRect();
@@ -727,11 +731,11 @@ async function saveFailureCloseGuard(cdp, baseUrl) {
"modal dismissed",
20000,
);
assert(modalGone, "F6 confirm dialog dismissed after choosing 留在导演台");
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}`);
const stillOpen = await cdp.evaluate(`document.querySelectorAll('.previs-viewport-shell').length`);
assert(stillOpen === 1, "F7 unified workbench remains open after choosing 留在预演台", `shellCount=${stillOpen}`);
assert(cdp.problems.length === 0, "F8 no browser problems in scenario F", JSON.stringify(cdp.problems));
}
@@ -743,7 +747,7 @@ async function main() {
const vitePort = await freePort();
const cdpPort = await freePort();
const baseUrl = `http://127.0.0.1:${vitePort}`;
const profileDir = mkdtempSync(join(tmpdir(), "director-p0-e2e-"));
const profileDir = mkdtempSync(join(tmpdir(), "previs-p0-e2e-"));
let vite = null;
let chrome = null;
@@ -800,13 +804,13 @@ async function main() {
console.log("=========================================");
if (failures > 0) {
console.error(`\nDirector P0 Chrome E2E FAILED (${failures} assertion(s)).`);
console.error(`\nPrevis P0 Chrome E2E FAILED (${failures} assertion(s)).`);
process.exit(1);
}
console.log("\nDirector P0 Chrome E2E PASSED.");
console.log("\nPrevis P0 Chrome E2E PASSED.");
}
main().catch((error) => {
console.error("\nDirector P0 Chrome E2E crashed:", error?.stack || error);
console.error("\nPrevis P0 Chrome E2E crashed:", error?.stack || error);
process.exit(1);
});