feat(cutover): add live fullscreen monitoring wall

This commit is contained in:
oliver 2026-10-11 09:08:03 +08:00
parent b9d5e27cc4
commit 4d60601050
8 changed files with 307 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View file

@ -696,6 +696,16 @@ const en = {
colChange: "Changes",
},
bizMigration: {
fullscreen: "Wall mode",
exitFullscreen: "Exit wall mode",
fullscreenHint: "Display cutover status fullscreen. Press Esc to return; use a full-window layout when fullscreen is unavailable.",
wallTitle: "Cutover monitoring · Service migration",
wallProgress: "Wave migration progress · Current snapshot",
wallFilters: "Show filters and evaluation controls",
wallState_waiting: "Awaiting evaluation",
wallState_unknown: "Data needs confirmation",
wallState_anomaly: "Service anomalies detected",
wallState_normal: "No service anomalies in current samples",
pairCollectionHint: "Each metric has a paired collection round. Saving settings updates task bindings. Route tables are manual-only for final checks; use a separate business comparison for millions of routes. Actual cadence depends on command duration and device availability.",
pairedSampling: "Paired sampling · Timeliness",
manualRoute: "Manual final check",

View file

@ -695,6 +695,16 @@ const zh = {
colChange: "变更",
},
bizMigration: {
fullscreen: "大屏模式",
exitFullscreen: "退出大屏",
fullscreenHint: "全屏展示割接状态,按 Esc 返回看板;浏览器不支持全屏时使用窗口大屏。",
wallTitle: "割接监控 · 业务迁移",
wallProgress: "本批迁移进度 · 当前快照",
wallFilters: "显示筛选与判定操作",
wallState_waiting: "等待判定",
wallState_unknown: "数据待确认",
wallState_anomaly: "存在业务异常",
wallState_normal: "当前样本无业务异常",
pairCollectionHint: "每个指标两侧同轮关联采集。保存配置会同步增删采集任务;路由项仅手动采集,收尾时再选择,百万路由建议使用独立业务对比任务。频率是目标间隔,实际周期受命令耗时与设备占用影响。",
pairedSampling: "双端关联采集 · 及时性",
manualRoute: "手动收尾",

View file

@ -11213,6 +11213,9 @@ html.login-page--paused .login-page__flare {
.bm-sampling { border: 1px solid rgba(148,163,184,.25); border-radius: 4px; min-width: 0; }
.bm-sampling > summary { padding: 8px 12px; cursor: pointer; font-size: 12px; }
.app-heroui-modal .bm-sampling .pt-list-table-wrap { max-width: 100%; min-height: 0; }
.bm-live-header__actions { display: flex; gap: 8px; align-items: center; }
.bm-board-controls > summary { display: none; }
.bm-board-controls > .bm-live-filters { margin-top: 8px; }
@media (max-width: 720px) {
.bm-live-sources { grid-template-columns: 1fr; }
.bm-live-kpis { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; }
@ -15787,3 +15790,106 @@ option {
.pager__jump-input {
width: 4.25rem;
}
/* Cutover wall: keep one board/poller alive in native or window fullscreen. */
.app-heroui-modal.bm-wall-modal {
width: 100vw !important;
max-width: 100vw !important;
height: 100dvh;
max-height: 100dvh;
padding: 0;
margin: 0;
transform: none;
box-sizing: border-box;
border: 0;
}
.app-heroui-modal.bm-wall-modal .app-heroui-modal__dialog {
width: 100% !important;
max-width: 100% !important;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
transform: none;
overflow: hidden;
box-sizing: border-box;
}
.bm-wall-modal .modal__header,
.bm-wall-modal .modal__footer,
.bm-wall-modal .bm-detail__chrome,
.bm-wall-modal .nm-config-modal__tabs { display: none; }
.app-heroui-modal.bm-wall-modal .bm-detail {
width: 100%;
min-width: 0;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
gap: 0;
overflow: hidden;
}
.bm-board.is-wall,
.bm-board:fullscreen {
width: 100%;
min-width: 0;
height: 100dvh;
min-height: 0;
max-height: 100dvh;
margin: 0;
padding: clamp(12px, 1.25vw, 28px);
gap: 12px;
border-radius: 0;
background: #0b1220;
color: #e2e8f0;
box-sizing: border-box;
overflow: auto;
}
.bm-board.is-wall > * { flex-shrink: 0; }
.bm-wall-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-width: 0; }
.bm-wall-heading > div { min-width: 0; }
.bm-wall-heading__label { color: #94a3b8; font-size: 13px; letter-spacing: .08em; }
.bm-wall-heading h2 { margin: 4px 0 0; font-size: clamp(22px, 2vw, 36px); line-height: 1.2; overflow-wrap: anywhere; }
.bm-wall-heading__status { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
.bm-wall-heading time { color: #94a3b8; font: 14px ui-monospace, monospace; white-space: nowrap; }
.bm-wall-state { padding: 6px 12px; font-size: 15px; border: 1px solid currentColor; border-radius: 3px; }
.bm-wall-state.is-normal { color: #34d399; }
.bm-wall-state.is-anomaly { color: #f87171; }
.bm-wall-state.is-waiting, .bm-wall-state.is-unknown { color: #fbbf24; }
.bm-board.is-wall .bm-live-header { position: sticky; top: 0; z-index: 5; background: #0b1220; padding: 8px 0; }
.bm-board.is-wall .bm-live-header strong { font-size: 17px; }
.bm-board.is-wall .bm-live-sources strong { font-size: 19px; }
.bm-board.is-wall .bm-live-kpis > div { padding: 16px 20px; }
.bm-board.is-wall .bm-live-kpis span { font-size: 16px; }
.bm-board.is-wall .bm-live-kpis strong { font-size: clamp(40px, 3.5vw, 68px); }
.bm-board.is-wall .bm-live-kpis strong small { font-size: 20px; }
.bm-board.is-wall .bm-live-warning { font-size: 14px; }
.bm-wall-progress { display: flex; align-items: center; gap: 20px; padding: 4px 0; color: #34d399; }
.bm-wall-progress > div:first-child { display: flex; align-items: baseline; gap: 16px; }
.bm-wall-progress span { font-size: 13px; }
.bm-wall-progress strong { font: 600 24px ui-monospace, monospace; }
.bm-wall-progress.is-unknown { color: #fbbf24; }
.bm-wall-progress__track { flex: 1; height: 8px; min-width: 60px; background: #243044; border-radius: 2px; overflow: hidden; }
.bm-wall-progress__track > span { display: block; height: 100%; background: currentColor; }
.bm-board.is-wall .bm-board-controls > summary { display: list-item; cursor: pointer; color: #94a3b8; font-size: 13px; padding: 6px 0; }
.bm-board.is-wall .bm-sampling { max-height: 240px; overflow: auto; }
.bm-board.is-wall .bm-sampling td, .bm-board.is-wall .bm-sampling th { font-size: 14px; }
.bm-board.is-wall .bs-cmp-board__body { flex: 1 0 260px; min-height: 260px; }
.bm-board.is-wall .bs-cmp-main { display: flex; min-height: 0; }
.bm-board.is-wall .bm-board__table { flex: 1; width: 100%; min-height: 0; max-height: none; }
.bm-board.is-wall .bm-board__table td { font-size: 15px; }
.bm-board.is-wall .bs-cmp-nav__list { max-height: none; }
@media (max-width: 720px) {
.bm-wall-heading { align-items: flex-start; gap: 12px; }
.bm-wall-heading__status { max-width: 48%; }
.bm-wall-heading time { font-size: 10px; white-space: normal; }
.bm-wall-state { font-size: 12px; padding: 4px 6px; }
.bm-board.is-wall .bm-live-kpis > div { padding: 12px; }
.bm-board.is-wall .bm-live-kpis strong { font-size: 36px; }
.bm-board.is-wall .bm-live-kpis span { font-size: 13px; }
.bm-wall-progress { flex-wrap: wrap; gap: 8px; }
.bm-wall-progress__track { flex-basis: 100%; }
.bm-board.is-wall .bs-cmp-board__body { flex-basis: auto; }
.bm-board.is-wall .bm-board__table { max-height: 50dvh; }
}

View file

@ -489,6 +489,9 @@ export function BizMigrationPage() {
const [boardLoading, setBoardLoading] = useState(false);
const [boardError, setBoardError] = useState("");
const [boardClock, setBoardClock] = useState(Date.now());
const [boardFullscreen, setBoardFullscreen] = useState(false);
const boardRef = useRef<HTMLDivElement>(null);
const fullscreenPending = useRef(false);
const boardRequest = useRef(0);
const runsRequest = useRef(0);
const activeBatchRef = useRef(batchId);
@ -513,6 +516,66 @@ export function BizMigrationPage() {
const pairedPending = followLatest && board?.sampling?.some((pair) =>
[pair.old_collect_status, pair.new_collect_status].some((s) => !["success", "partial"].includes(s || "idle")));
const coverageUnknown = Boolean(board?.run && (liveSummary?.coverage_complete !== true || samplesStale || pairedPending));
const progressPercent = liveProgress?.total
? Math.max(0, Math.min(100, Math.round(liveProgress.ok / liveProgress.total * 100))) : 0;
const wallState = boardError ? "unknown" : !board?.run ? "waiting" : coverageUnknown ? "unknown"
: (liveSummary?.anomaly || 0) > 0 ? "anomaly" : "normal";
const exitBoardFullscreen = useCallback(async () => {
if (document.fullscreenElement === boardRef.current) {
try { await document.exitFullscreen(); } catch { return; }
}
setBoardFullscreen(false);
}, []);
async function toggleBoardFullscreen() {
if (fullscreenPending.current) return;
const element = boardRef.current;
if (!element) return;
fullscreenPending.current = true;
try {
if (boardFullscreen) {
await exitBoardFullscreen();
return;
}
try { await element.requestFullscreen(); } catch { /* Use the full-window layout below. */ }
if (boardRef.current === element) setBoardFullscreen(true);
} finally {
fullscreenPending.current = false;
}
}
useEffect(() => {
const sync = () => setBoardFullscreen(Boolean(boardRef.current && document.fullscreenElement === boardRef.current));
document.addEventListener("fullscreenchange", sync);
return () => document.removeEventListener("fullscreenchange", sync);
}, []);
useEffect(() => {
const element = boardRef.current;
return () => {
if (element && document.fullscreenElement === element) void document.exitFullscreen().catch(() => {});
};
}, [projectId, detailTab]);
useEffect(() => {
if (!boardFullscreen) return;
const onEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
void exitBoardFullscreen();
};
window.addEventListener("keydown", onEscape, true);
return () => window.removeEventListener("keydown", onEscape, true);
}, [boardFullscreen, exitBoardFullscreen]);
useEffect(() => {
if (!projectId || detailTab !== "board") return;
// Freshness must keep advancing even when API refreshes fail.
const timer = setInterval(() => setBoardClock(Date.now()), 1000);
return () => clearInterval(timer);
}, [projectId, detailTab]);
const filteredProjects = useMemo(() => {
const kw = debouncedListKw.trim().toLowerCase();
@ -1185,6 +1248,7 @@ export function BizMigrationPage() {
}
const openProject = (id: string, tab: DetailTab = "setup") => {
setBoardFullscreen(false);
setProjectId(id);
setDetailTab(tab);
setSelectedExpectKeys(new Set());
@ -1196,6 +1260,7 @@ export function BizMigrationPage() {
};
const closeProject = () => {
setBoardFullscreen(false);
setProjectId("");
setDetailTab("setup");
};
@ -2185,9 +2250,10 @@ export function BizMigrationPage() {
{/* Project detail */}
<AppModalShell
open={Boolean(projectId && project)}
onClose={closeProject}
size="cover"
className={detailTab === "board" ? "bs-cmp-board-modal" : undefined}
onClose={() => { if (boardFullscreen) void exitBoardFullscreen(); else closeProject(); }}
dismissible={!boardFullscreen}
size={boardFullscreen ? "full" : "cover"}
className={detailTab === "board" ? `bs-cmp-board-modal${boardFullscreen ? " bm-wall-modal" : ""}` : undefined}
>
<Modal.Header>
<Modal.Heading>
@ -2707,7 +2773,15 @@ export function BizMigrationPage() {
) : null}
{detailTab === "board" ? (
<div className="bs-cmp-board bm-board">
<div ref={boardRef} className={`bs-cmp-board bm-board${boardFullscreen ? " is-wall" : ""}`}>
{boardFullscreen ? <div className="bm-wall-heading">
<div><span className="bm-wall-heading__label">{t("bizMigration.wallTitle")}</span>
<h2>{project.name}</h2></div>
<div className="bm-wall-heading__status">
<span className={`bm-wall-state is-${wallState}`} role="status">{t(`bizMigration.wallState_${wallState}`)}</span>
<time dateTime={new Date(boardClock).toISOString()}>{fmtTime(new Date(boardClock).toISOString())}</time>
</div>
</div> : null}
<div className="bm-live-header">
<div>
<span className={`bm-live-state${monitorIsLive ? " is-live" : ""}`}>
@ -2716,8 +2790,16 @@ export function BizMigrationPage() {
<strong>{batch?.batch_label || "—"}</strong>
</div>
<span className="muted">{t("bizMigration.evaluatedAt")} {fmtTime(board?.run?.created_at)}</span>
<Button size="sm" variant="secondary" isDisabled={boardLoading || !batchId}
onPress={() => void loadBoard(batchId, historyRunId)}>{t("bizMigration.refreshBoard")}</Button>
<div className="bm-live-header__actions">
<Button size="sm" variant="secondary" isDisabled={boardLoading || !batchId}
onPress={() => void loadBoard(batchId, historyRunId)}>{t("bizMigration.refreshBoard")}</Button>
<Button size="sm" variant="secondary" onPress={() => void toggleBoardFullscreen()}
aria-label={t(boardFullscreen ? "bizMigration.exitFullscreen" : "bizMigration.fullscreen")}
aria-describedby="bm-wall-hint">
{t(boardFullscreen ? "bizMigration.exitFullscreen" : "bizMigration.fullscreen")}
</Button>
<span id="bm-wall-hint" className="visually-hidden">{t("bizMigration.fullscreenHint")}</span>
</div>
</div>
<div className="bm-live-sources">
{(["old", "new"] as const).map((side) => {
@ -2737,6 +2819,13 @@ export function BizMigrationPage() {
<div className="is-bad"><span>{t("bizMigration.anomalyInBatch")}</span><strong>{Math.max(0, (liveSummary?.anomaly ?? 0) - (liveSummary?.anomaly_outside_expect ?? 0))}</strong></div>
<div className="is-bad"><span>{t("bizMigration.anomalyOutsideBatch")}</span><strong>{liveSummary?.anomaly_outside_expect ?? 0}</strong></div>
</div>
{boardFullscreen ? <div className={`bm-wall-progress${wallState === "unknown" || wallState === "waiting" ? " is-unknown" : ""}`}>
<div><span>{t("bizMigration.wallProgress")}</span><strong>{progressPercent}%</strong></div>
<div className="bm-wall-progress__track" role="progressbar" aria-label={t("bizMigration.wallProgress")}
aria-valuemin={0} aria-valuemax={100} aria-valuenow={progressPercent}>
<span style={{ width: `${progressPercent}%` }} />
</div>
</div> : null}
{followLatest && board?.sampling?.length ? <details className="bm-sampling" open>
<summary>{t("bizMigration.pairedSampling")}</summary>
<div className="pt-list-table-wrap"><table className="data-table pt-list-table">
@ -2784,6 +2873,8 @@ export function BizMigrationPage() {
</span>
</div>
) : null}
<details className="bm-board-controls" open={!boardFullscreen}>
<summary>{t("bizMigration.wallFilters")}</summary>
<div className="bs-cmp-board__toolbar filter-inline">
<FieldSelect
value={batchId}
@ -2879,6 +2970,7 @@ export function BizMigrationPage() {
{t("bizMigration.showOutsideAnomalies")}
</Button> : null}
</div>
</details>
{sheetCards.some((c) => c.new_baseline_missing) ? (
<div className="form-error bm-hint">
{t("bizMigration.newBaselineMissingBanner", {

View file

@ -12,7 +12,7 @@ const browser = await chromium.launch({ headless: true, channel: process.env.NET
const page = await browser.newPage({ viewport: { width: 1440, height: 1080 } });
const errors = [], passed = [], requests = [], writes = [];
page.on("pageerror", (e) => errors.push(e.message));
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "";
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "", sampleAgeSec = 0;
const now = new Date().toISOString();
const project = { id: "p", name: "核心 PE 替换", old_task_id: "op", new_task_id: "np", old_hf_task_id: "oh", new_hf_task_id: "nh", old_baseline_batch_id: "ob", new_baseline_batch_id: "nb", monitor_template_id: "mt", hf_interval_sec: 60, status: "active", created_at: now,
collect_metric_ids_effective: ["arp"], old_task: { id: "op", ne_name: "PE-OLD", ne_ip: "10.0.0.1" }, new_task: { id: "np", ne_name: "PE-NEW", ne_ip: "10.0.0.2" } };
@ -22,9 +22,10 @@ const sheets = ["s1", "s2"].map((sid, i) => ({ sheet_id: sid, metric_id: "arp",
const monitor = { id: "mt", name: "业务连续性", compare_template_id: "ct", compare_template_name: "业务模板", collect_metric_ids: ["arp"], collect_metric_ids_effective: ["arp"], available_metric_ids: ["arp", "ip_route"], defaults: { custom_setting: false, threshold: 7, out_of_expect: "ignore" }, sheet_overrides: [{ metric_id: "arp", success: [{ old_groups: [["removed"]], new_groups: [["added"]], name: "客户迁移规则" }], anomaly: [] }] };
const dataset = Array.from({ length: 601 }, (_, i) => ({ id: `row-${i}`, metric_id: "arp", sheet_id: i % 2 ? "s2" : "s1", verdict: i === 600 ? "anomaly" : "migrated", color: i === 600 ? "red" : "green", in_expect: i !== 600, old_key: `10.1.${Math.floor(i / 255)}.${i % 255}`, new_key: `10.1.${Math.floor(i / 255)}.${i % 255}`, old_status: "gone", new_status: "up", rule_hit: i === 600 ? "service_content_changed" : "success#1", integrity_changes: i === 600 ? [{ field: "mac", before: "aa:bb", after: "cc:dd" }] : [] }));
function run(rid, pid = "p") {
const sampleTime = sampleAgeSec ? new Date(Date.now() - sampleAgeSec * 1000).toISOString() : now;
return { id: rid, purpose: rid === "hist" ? "manual" : "auto", created_at: now,
summary: { progress: { ok: pid === "p2" ? 3 : 99, total: pid === "p2" ? 3 : 101 }, anomaly: 1, anomaly_outside_expect: 1, coverage_complete: !uncertain, duplicate_keys: uncertain ? 2 : 0,
sheet_cards: sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: now, new_collected_at: now, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })) } };
sheet_cards: sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: sampleTime, new_collected_at: sampleTime, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })) } };
}
await page.route("**/v1/**", async (route) => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
@ -76,6 +77,66 @@ try {
await dialog().getByText("双端关联采集 · 及时性", { exact: true }).waitFor();
assert.equal(await dialog().locator('.bm-sampling tbody td:last-child').textContent(), '0.4 s');
passed.push("measured paired start skew", "paired round and duration visible", "live continuity KPIs", "outside anomaly visible before acceptance", "100 row page bound");
await page.setViewportSize({ width: 1920, height: 1080 });
await dialog().getByRole("button", { name: "大屏模式", exact: true }).click();
await page.locator('.bm-board.is-wall').waitFor();
assert.ok(await page.evaluate(() => document.fullscreenElement?.classList.contains('bm-board')));
assert.deepEqual(await page.locator('.bm-board').evaluate((el) => [Math.round(el.getBoundingClientRect().width), Math.round(el.getBoundingClientRect().height)]), [1920, 1080]);
await page.locator('.bm-wall-heading').getByRole('heading', { name: project.name, exact: true }).waitFor();
assert.equal(await page.getByRole('progressbar').getAttribute('aria-valuenow'), '98');
await page.locator('.bm-wall-state').getByText('存在业务异常', { exact: true }).waitFor();
assert.equal(await table().locator('tbody tr').count(), 100);
assert.ok(await page.locator('.bm-board').evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
await page.screenshot({ path: path.join(output, 'cutover-monitor-wall.png') });
await page.setViewportSize({ width: 3840, height: 2160 });
assert.deepEqual(await page.locator('.bm-board').evaluate((el) => [Math.round(el.getBoundingClientRect().width), Math.round(el.getBoundingClientRect().height)]), [3840, 2160]);
assert.ok(await page.locator('.bm-board').evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
await page.setViewportSize({ width: 1920, height: 1080 });
const wallRequests = requests.filter((r) => r.p.endsWith('/board')).length;
await page.waitForTimeout(10500);
assert.ok(requests.filter((r) => r.p.endsWith('/board')).length > wallRequests);
await page.getByRole('button', { name: '退出大屏', exact: true }).click();
await page.waitForFunction(() => !document.querySelector('.bm-board.is-wall') && !document.fullscreenElement);
assert.ok(await dialog().isVisible());
assert.ok(await dialog().getByRole('checkbox', { name: '仅预期', exact: true }).isChecked());
passed.push('native wall fills 1920x1080', '4K wall fits without horizontal overflow', 'project identity and progress visible on wall', 'outside alarms stay visible on wall', 'wall keeps one bounded data page', 'live wall keeps refreshing', 'wall exit preserves project and filters');
// Block native fullscreen to verify the window layout and Escape handling.
await page.evaluate(() => {
window.__originalFullscreen = Element.prototype.requestFullscreen;
Element.prototype.requestFullscreen = async () => { throw new Error('fixture_fullscreen_blocked'); };
});
await dialog().getByRole('button', { name: '大屏模式', exact: true }).click();
await page.locator('.bm-board.is-wall').waitFor();
assert.equal(await page.evaluate(() => document.fullscreenElement), null);
assert.deepEqual(await page.locator('.bm-board').evaluate((el) => [Math.round(el.getBoundingClientRect().width), Math.round(el.getBoundingClientRect().height)]), [1920, 1080]);
uncertain = true;
await page.getByRole('button', { name: '刷新看板', exact: true }).click();
await page.locator('.bm-wall-state').getByText('数据待确认', { exact: true }).waitFor();
uncertain = false; sampleAgeSec = 200;
await page.getByRole('button', { name: '刷新看板', exact: true }).click();
await page.getByText('采集已过期', { exact: true }).waitFor();
assert.equal(await page.locator('.bm-wall-state').textContent(), '数据待确认');
sampleAgeSec = 0;
failBoard = true;
await page.getByRole('button', { name: '刷新看板', exact: true }).click();
await dialog().getByRole('alert').filter({ hasText: '获取失败' }).waitFor();
assert.equal(await page.locator('.bm-wall-state').textContent(), '数据待确认');
const clockText = await page.locator('.bm-wall-heading time').textContent();
await page.waitForFunction((previous) => document.querySelector('.bm-wall-heading time')?.textContent !== previous,
clockText, { timeout: 5000 });
assert.notEqual(await page.locator('.bm-wall-heading time').textContent(), clockText);
failBoard = false;
await page.setViewportSize({ width: 390, height: 1000 });
assert.ok(await page.locator('.bm-board').evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.bm-board.is-wall'));
assert.ok(await dialog().isVisible());
assert.ok(await dialog().getByRole('checkbox', { name: '仅预期', exact: true }).isChecked());
await page.evaluate(() => { Element.prototype.requestFullscreen = window.__originalFullscreen; });
await page.setViewportSize({ width: 1440, height: 1080 });
await dialog().getByRole('button', { name: '刷新看板', exact: true }).click();
await page.waitForFunction(() => !document.querySelector('.bm-live-warning[role="alert"]'));
passed.push('blocked fullscreen uses full-window wall', 'unfinished pair cannot look healthy on wall', 'stale samples remain uncertain on wall', 'refresh failure retains explicit uncertainty', 'wall clock continues when refresh fails', '390px wall stays within viewport', 'Escape exits wall without closing project');
await dialog().getByRole("button", { name: "下一页", exact: true }).click();
await page.waitForFunction(() => document.querySelector('.bm-board__table tbody tr code')?.textContent === '10.1.0.100');
passed.push("server pagination beyond first page");
@ -110,15 +171,24 @@ try {
uncertain = false;
await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption("hist");
await dialog().getByText("历史快照", { exact: true }).waitFor();
await dialog().getByRole('button', { name: '大屏模式', exact: true }).click();
await page.locator('.bm-board.is-wall').waitFor();
await page.getByText('历史快照', { exact: true }).waitFor();
const historyBoardRequests = requests.filter((r) => r.p.endsWith("/board")).length;
await page.waitForTimeout(10500);
assert.equal(requests.filter((r) => r.p.endsWith("/board")).length, historyBoardRequests);
passed.push("historical view remains frozen");
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.bm-board.is-wall') && !document.fullscreenElement);
assert.ok(await dialog().isVisible());
assert.equal(await dialog().getByRole('combobox', { name: '判定历史', exact: true }).inputValue(), 'hist');
passed.push("historical wall remains frozen", "native Escape preserves project and historical selection");
liveRunId = "live-2";
const liveResponse = page.waitForResponse((r) => r.url().includes('/runs/live-2/diffs'));
await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption("");
await liveResponse;
await waitRows(100);
const liveCount = requests.filter((r) => r.p.endsWith("/board")).length;
await page.waitForTimeout(10500);
await page.waitForResponse((r) => new URL(r.url()).pathname.endsWith('/board'), { timeout: 15000 });
assert.ok(requests.filter((r) => r.p.endsWith("/board")).length > liveCount);
passed.push("active live board automatically refreshes", "refresh cycles do not overlap");
failBoard = true;
@ -174,6 +244,15 @@ try {
console.log(JSON.stringify({ passed, errors }, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(output, "cutover-monitor-failure.png"), fullPage: true });
console.log(JSON.stringify(await page.locator('.bm-board').evaluate((el) => {
const layout = [];
for (let node = el; node; node = node.parentElement) {
const rect = node.getBoundingClientRect(), style = getComputedStyle(node);
layout.push({ class: node.className, width: rect.width, height: rect.height, x: rect.x, y: rect.y,
minWidth: style.minWidth, maxWidth: style.maxWidth, padding: style.padding, margin: style.margin, transform: style.transform });
}
return layout;
}), null, 2));
console.log(JSON.stringify({ passed, errors, recentRequests: requests.slice(-12) }, null, 2));
throw error;
} finally {