From d269dd52bf30f45f47b29a72e9a4bd68cbeda175 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 11:39:18 +0800 Subject: [PATCH] fix: enforce v02 cloud web validation --- package.json | 2 +- scripts/artifact-publish.mjs | 14 +- scripts/g14-gitops-render.test.ts | 7 + web/hwlab-cloud-web/app-trace.test.ts | 85 +++++ web/hwlab-cloud-web/app-trace.ts | 48 ++- web/hwlab-cloud-web/package.json | 2 +- web/hwlab-cloud-web/scripts/check.ts | 55 +-- .../scripts/sidebar-resize.test.ts | 334 ------------------ 8 files changed, 154 insertions(+), 393 deletions(-) create mode 100644 web/hwlab-cloud-web/app-trace.test.ts delete mode 100644 web/hwlab-cloud-web/scripts/sidebar-resize.test.ts diff --git a/package.json b/package.json index 6b4a52c6..618c3c63 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "deploy:desired-state:plan": "node scripts/run-bun.mjs scripts/deploy-desired-state-plan.mjs --pretty", "deploy:desired-state:check": "node scripts/run-bun.mjs scripts/deploy-desired-state-plan.mjs --check", "cli:client": "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client", - "web:check": "(cd web/hwlab-cloud-web && bun run check) && bun test web/hwlab-cloud-web/message-markdown.test.ts", + "web:check": "cd web/hwlab-cloud-web && bun run check", "web:build": "(cd web/hwlab-cloud-web && bun run build)", "artifact-catalog:preview-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write", "g14:gitops:render": "node scripts/g14-gitops-render.mjs", diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 9984e007..077880d0 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -969,8 +969,8 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC } if (service.runtimeKind === "cloud-web") { - const build = await run(bunExecutable(), ["web/hwlab-cloud-web/scripts/build.ts"]); - if (build.code !== 0) { + const check = await run(bunExecutable(), ["run", "--cwd", "web/hwlab-cloud-web", "check"]); + if (check.code !== 0) { return { ...service, image: ref, @@ -982,11 +982,11 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC blocker: blocker({ type: "environment_blocker", scope: service.serviceId, - summary: "cloud web dist build failed before BuildKit image build", - next: "Run bun run --cwd web/hwlab-cloud-web build, fix the static asset build, then rerun --publish." + summary: "cloud web source check failed before BuildKit image build", + next: "Run bun run --cwd web/hwlab-cloud-web check, fix TypeScript/unit/build failures, then rerun --publish." }), - logTail: tailText(`${build.stdout}\n${build.stderr}`), - cloudWebBuildDurationMs: build.durationMs + logTail: tailText(`${check.stdout}\n${check.stderr}`), + cloudWebCheckDurationMs: check.durationMs }; } @@ -1013,7 +1013,7 @@ async function buildService({ args, repo, commitId, shortCommit, service, buildC service = { ...service, distFreshness, - cloudWebBuildDurationMs: build.durationMs + cloudWebCheckDurationMs: check.durationMs }; } diff --git a/scripts/g14-gitops-render.test.ts b/scripts/g14-gitops-render.test.ts index 7f48adbe..ff92211e 100644 --- a/scripts/g14-gitops-render.test.ts +++ b/scripts/g14-gitops-render.test.ts @@ -180,6 +180,13 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots assert.doesNotMatch(JSON.stringify(collectArtifacts), /tasks\.build-hwlab-device-pod\.results/u); const devicePodBuildScript = taskByName(pipelineJson, "build-hwlab-device-pod").taskSpec.steps.find((step) => step.name === "publish")?.script ?? ""; assert.match(devicePodBuildScript, /--report "\/workspace\/source\/service-results\/\$\(params\.service-id\)\.json"/u); + const cloudWebBuildScript = taskByName(pipelineJson, "build-hwlab-cloud-web").taskSpec.steps.find((step) => step.name === "publish")?.script ?? ""; + assert.match(cloudWebBuildScript, /node scripts\/g14-artifact-publish\.mjs --publish/u); + + const artifactPublishSource = await readFile("scripts/artifact-publish.mjs", "utf8"); + assert.match(artifactPublishSource, /\["run", "--cwd", "web\/hwlab-cloud-web", "check"\]/u); + assert.match(artifactPublishSource, /cloud web source check failed before BuildKit image build/u); + assert.doesNotMatch(artifactPublishSource, /Run bun run --cwd web\/hwlab-cloud-web build, fix the static asset build/u); const workloads = await readFile(path.join(outDir, "runtime-v02", "workloads.yaml"), "utf8"); const workloadsJson = JSON.parse(workloads); diff --git a/web/hwlab-cloud-web/app-trace.test.ts b/web/hwlab-cloud-web/app-trace.test.ts new file mode 100644 index 00000000..cc9b6398 --- /dev/null +++ b/web/hwlab-cloud-web/app-trace.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { traceDisplayRows } from "./app-trace.ts"; + +test("trace display rows render request, setup, command, assistant markdown, and completion path", () => { + const trace = { + traceId: "trc_trace_contract", + events: traceEvents(), + assistantStreams: [{ itemId: "assistant-final", text: "# 处理完成\n\n- trace markdown ok" }] + }; + + const rows = traceDisplayRows(trace, trace.events); + const text = rows.map((row) => `${row.header}\n${row.body ?? ""}`).join("\n---\n"); + + assert.match(text, /请求接受.*请检查 trace/u); + assert.match(text, /会话复用/u); + assert.match(text, /工具调用/u); + assert.match(text, /hostJobId=job-trace-1/u); + assert.match(text, /助手最后一条消息,轮次完成/u); + assert.match(text, /# 处理完成/u); + assert.equal(rows.some((row) => row.bodyFormat === "markdown"), true); +}); + +test("trace display rows render completion when no terminal assistant event exists", () => { + const events = [ + event(1, "request:accepted", { promptSummary: "只跑完成事件" }), + event(2, "turn:completed", { status: "completed" }) + ]; + + const rows = traceDisplayRows({ traceId: "trc_completion", events }, events); + assert.equal(rows.some((row) => /轮次完成/u.test(row.header)), true); +}); + +function traceEvents() { + return [ + event(1, "request:accepted", { promptSummary: "请检查 trace" }), + event(2, "session:reused"), + commandEvent(3, "item/commandExecution:started", { + itemId: "cmd-trace-1", + command: "bash -lc 'build start'" + }), + { + ...event(4, "item/commandExecution/outputDelta", { + type: "tool_call", + status: "output_chunk", + itemId: "cmd-trace-1", + outputSummary: '{"hostJobId":"job-trace-1","success":true}' + }) + }, + commandEvent(5, "item/commandExecution:completed", { + itemId: "cmd-trace-1", + command: "bash -lc 'build status'", + status: "completed", + exitCode: 0, + durationMs: 1200 + }), + event(6, "assistant:completed", { + type: "assistant_message", + terminal: true, + status: "completed", + itemId: "assistant-final" + }) + ]; +} + +function commandEvent(seq, label, fields) { + return event(seq, label, { + type: "tool_call", + toolName: "commandExecution", + ...fields + }); +} + +function event(seq, label, fields = {}) { + return { + traceId: "trc_trace_contract", + seq, + label, + type: fields.type ?? "event", + status: fields.status ?? "observed", + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + ...fields + }; +} diff --git a/web/hwlab-cloud-web/app-trace.ts b/web/hwlab-cloud-web/app-trace.ts index cabe63ce..f45d2df4 100644 --- a/web/hwlab-cloud-web/app-trace.ts +++ b/web/hwlab-cloud-web/app-trace.ts @@ -283,7 +283,7 @@ function setConversationScrollTop(top, { user = true } = {}) { return traceScrollMetrics(); } -function traceDisplayRows(trace, events) { +export function traceDisplayRows(trace, events) { const rows = []; const commandGroups = traceCommandExecutionGroups(events); const requestEvent = tracePrimaryRequestEvent(events); @@ -381,7 +381,7 @@ function traceCommandExecutionGroups(events) { } function traceCommandGroupKey(event, fallback = null) { - return nonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`; + return traceNonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`; } function traceCommandSummaryRow(trace, group) { @@ -647,7 +647,7 @@ function traceAssistantTextForEvent(trace, event, index = 0) { if (direct) return traceAssistantTextDuplicatesTerminal(trace, event, direct) ? "" : direct; const streams = traceAssistantStreams(trace); if (streams.length === 0) return ""; - const itemId = nonEmptyString(event.itemId ?? event.messageId ?? event.id); + const itemId = traceNonEmptyString(event.itemId ?? event.messageId ?? event.id); if (itemId) { const match = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId); if (match?.text) { @@ -671,7 +671,7 @@ function traceAssistantEvents(trace) { function traceAssistantTerminalText(trace, event = null) { const streams = traceAssistantStreams(trace); - const itemId = nonEmptyString(event?.itemId ?? event?.messageId ?? event?.id); + const itemId = traceNonEmptyString(event?.itemId ?? event?.messageId ?? event?.id); if (itemId) { const matched = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId); if (matched?.text) return normalizeTraceAssistantMarkdown(matched.text); @@ -712,9 +712,9 @@ function traceAssistantCumulativeStream(trace) { if (streams.length === 0) return null; const terminalIds = new Set(traceAssistantEvents(trace) .filter(isTerminalAssistantTraceEvent) - .flatMap((event) => [event.itemId, event.messageId, event.id].map(nonEmptyString).filter(Boolean))); + .flatMap((event) => [event.itemId, event.messageId, event.id].map(traceNonEmptyString).filter(Boolean))); const candidates = streams.filter((stream) => { - const streamId = nonEmptyString(stream.itemId ?? stream.messageId ?? stream.id); + const streamId = traceNonEmptyString(stream.itemId ?? stream.messageId ?? stream.id); return !streamId || !terminalIds.has(streamId); }); return [...(candidates.length > 0 ? candidates : streams)] @@ -906,6 +906,42 @@ function isCommandExecutionTraceEvent(event) { (event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed"); } +function isAssistantMessageTraceEvent(event) { + const label = String(event?.label ?? ""); + return event?.type === "assistant_message" || label.startsWith("assistant:"); +} + +function isTerminalAssistantTraceEvent(event) { + const label = String(event?.label ?? ""); + return event?.terminal === true || label === "assistant:completed"; +} + +function isRequestTraceEvent(event) { + const label = String(event?.label ?? ""); + return label === "request:accepted-short-connection" || + label === "request:accepted" || + label === "request:received"; +} + +function isSetupTraceEvent(event) { + const label = String(event?.label ?? ""); + return /^session:|^stdio:|^prompt:|^thread:|^turn:started|^turn:start|turn\/start|codex turn|tool:codex-app-server/iu.test(label); +} + +function isCompletionTraceEvent(event) { + const label = String(event?.label ?? ""); + return label.startsWith("turn:completed") || label.startsWith("result:completed"); +} + +function tracePromptText(event) { + return cleanTraceText(event?.promptSummary ?? event?.prompt ?? ""); +} + +function traceNonEmptyString(value) { + const text = String(value ?? "").trim(); + return text ? text : null; +} + function isNoisyTraceEvent(event) { const label = String(event?.label ?? ""); if (isToolOutputChunkTraceEvent(event)) return false; diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 77982a68..06011b57 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "check": "bun test auth.test.ts code-agent-facts.test.ts code-agent-status.test.ts live-status.test.ts message-markdown.test.ts scripts/dist-contract.test.ts && bun run scripts/check.ts", + "check": "bun test && bun run scripts/check.ts", "build": "bun run scripts/build.ts", "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", "layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json", diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 965bf6dc..3c8f74ac 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -7,7 +7,6 @@ import { assertCloudWebDistFresh, buildCloudWebDist, readCloudWebAppSource } fro const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = path.resolve(rootDir, "../.."); -const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun"); const requiredFiles = Object.freeze([ "index.html", @@ -41,6 +40,7 @@ await assertCloudWebDistFresh(rootDir); const html = readWeb("index.html"); const styles = readWeb("styles.css"); const app = readCloudWebAppSource(rootDir); +assertTraceHelpersBound(readWeb("app-trace.ts")); const liveStatus = readWeb("live-status.ts"); const helpMarkdown = readWeb("help.md"); const faviconSvg = readWeb("favicon.svg"); @@ -177,52 +177,19 @@ assertIncludes(artifactCatalog, "hwlab-device-pod", "artifact catalog must inclu console.log("hwlab-cloud-web check ok: Device Pod summary sidebar, modal detail, simple event stream, TS build, and executor authority contracts are present"); -function runJavaScriptSyntaxCheck() { - const files = new Set([ - ...collectJavaScriptFiles(rootDir), - path.resolve(repoRoot, "internal/cloud/server.ts"), - path.resolve(repoRoot, "internal/device-pod/fake-data.mjs"), - path.resolve(repoRoot, "cmd/hwlab-device-pod/main.ts") - ]); - - for (const filePath of [...files].sort()) { - const isTypeScript = filePath.endsWith(".ts"); - const result = isTypeScript - ? spawnSync(bunCommand, ["build", filePath, "--target", "bun", "--outfile", "/tmp/hwlab-cloud-web-check-ts.js"], { - cwd: repoRoot, - encoding: "utf8", - windowsHide: true, - timeout: 5000 - }) - : spawnSync(process.execPath, ["--check", filePath], { - cwd: repoRoot, - encoding: "utf8", - windowsHide: true - }); - assert.equal( - result.status, - 0, - [ - `JS static syntax check failed: ${path.relative(repoRoot, filePath)}`, - result.stdout.trim(), - result.stderr.trim() - ].filter(Boolean).join("\n") - ); +function assertTraceHelpersBound(source) { + const traceHelperCalls = new Set(); + for (const match of source.matchAll(/\b((?:is[A-Z][A-Za-z0-9_$]*TraceEvent)|(?:trace[A-Z][A-Za-z0-9_$]*)|(?:cleanTrace[A-Za-z0-9_$]*)|(?:compactTrace[A-Za-z0-9_$]*)|(?:rawTrace[A-Za-z0-9_$]*))\s*\(/gu)) { + traceHelperCalls.add(match[1]); } + const missing = [...traceHelperCalls] + .filter((name) => !new RegExp(`\\bfunction\\s+${escapeRegExp(name)}\\s*\\(`, "u").test(source)) + .filter((name) => !new RegExp(`\\bexport\\s+function\\s+${escapeRegExp(name)}\\s*\\(`, "u").test(source)); + assert.deepEqual(missing, [], `app-trace.ts has trace helper calls without local function definitions: ${missing.join(", ")}`); } -function collectJavaScriptFiles(dir) { - const ignoredDirs = new Set(["dist", "node_modules", ".cache", ".state"]); - const result = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.resolve(dir, entry.name); - if (entry.isDirectory()) { - if (!ignoredDirs.has(entry.name)) result.push(...collectJavaScriptFiles(fullPath)); - continue; - } - if (entry.isFile() && /\.(?:mjs|js)$/u.test(entry.name)) result.push(fullPath); - } - return result; +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } function readWeb(relativePath) { diff --git a/web/hwlab-cloud-web/scripts/sidebar-resize.test.ts b/web/hwlab-cloud-web/scripts/sidebar-resize.test.ts deleted file mode 100644 index f6e63d81..00000000 --- a/web/hwlab-cloud-web/scripts/sidebar-resize.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -import { chromium } from "playwright"; - -const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const defaultCredentials = Object.freeze({ - username: "admin", - password: "hwlab2026" -}); - -test("left activity rail resize is controlled, clamped, persisted, and disabled while collapsed", async () => { - const server = await startStaticServer(webRoot); - const browser = await chromium.launch({ headless: true }); - try { - const page = await browser.newPage({ - viewport: { width: 1366, height: 768 }, - deviceScaleFactor: 1 - }); - await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 15000 }); - await login(page); - await page.locator("#left-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); - - const before = await sidebarMetrics(page); - assert.equal(before.handle.role, "separator"); - assert.equal(before.handle.ariaLabel, "拖拽调整左侧导航宽度"); - assert.match(before.handle.title, /左右方向键/); - assert.equal(before.handle.tabIndex, 0); - assert.equal(before.handle.ariaDisabled, "false"); - assert.equal(before.handle.ariaHidden, "false"); - assert.equal(before.railWidth, 92); - assert.equal(before.handle.min, 72); - assert.equal(before.handle.max, 180); - assert.equal(before.keyTargetsReachable, true); - - await dragLeftResize(page, before.railWidth + 48); - const afterDrag = await sidebarMetrics(page); - assert.ok(afterDrag.railWidth >= before.railWidth + 40, `rail width did not grow: ${afterDrag.railWidth}`); - assert.ok(afterDrag.centerWidth <= before.centerWidth - 40, `center width did not shrink: ${afterDrag.centerWidth}`); - assert.equal(afterDrag.storage.leftSidebarWidth, afterDrag.railWidth); - assert.equal(afterDrag.keyTargetsReachable, true); - assert.equal(afterDrag.noHorizontalOverflow, true); - assert.equal(afterDrag.rootScrollLocked, true); - - await page.locator("#left-sidebar-resize").press("End"); - const afterEnd = await sidebarMetrics(page); - assert.equal(afterEnd.railWidth, afterEnd.handle.max); - assert.equal(afterEnd.keyTargetsReachable, true); - - await dragLeftResize(page, 999); - const afterOverMaxDrag = await sidebarMetrics(page); - assert.equal(afterOverMaxDrag.railWidth, afterOverMaxDrag.handle.max); - - await page.locator("#left-sidebar-resize").press("Home"); - const afterHome = await sidebarMetrics(page); - assert.equal(afterHome.railWidth, afterHome.handle.min); - assert.equal(afterHome.keyTargetsReachable, true); - - await dragLeftResize(page, 10); - const afterUnderMinDrag = await sidebarMetrics(page); - assert.equal(afterUnderMinDrag.railWidth, afterUnderMinDrag.handle.min); - - await page.locator("#left-sidebar-resize").press("ArrowRight"); - const afterArrowRight = await sidebarMetrics(page); - assert.equal(afterArrowRight.railWidth, afterUnderMinDrag.railWidth + 8); - - await page.locator("#left-sidebar-resize").press("ArrowLeft"); - const afterArrowLeft = await sidebarMetrics(page); - assert.equal(afterArrowLeft.railWidth, afterUnderMinDrag.railWidth); - - await page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }); - await page.locator("#left-sidebar-resize").waitFor({ state: "visible", timeout: 12000 }); - const restored = await sidebarMetrics(page); - assert.equal(restored.railWidth, afterArrowLeft.railWidth); - assert.equal(restored.storage.leftSidebarWidth, afterArrowLeft.railWidth); - - await page.locator("#left-sidebar-toggle").click(); - const collapsed = await sidebarMetrics(page); - assert.equal(collapsed.collapsed, true); - assert.equal(collapsed.railWidth, 44); - assert.equal(collapsed.handle.display, "none"); - assert.equal(collapsed.handle.tabIndex, -1); - assert.equal(collapsed.handle.ariaDisabled, "true"); - assert.equal(collapsed.handle.ariaHidden, "true"); - assert.equal(collapsed.keyTargetsReachable, true); - - await page.locator("#left-sidebar-toggle").click(); - const expandedAgain = await sidebarMetrics(page); - assert.equal(expandedAgain.collapsed, false); - assert.equal(expandedAgain.railWidth, afterArrowLeft.railWidth); - assert.equal(expandedAgain.handle.tabIndex, 0); - assert.equal(expandedAgain.keyTargetsReachable, true); - - await page.close(); - } finally { - await browser.close(); - await server.close(); - } -}); - -test("left resize handle is disabled on narrow and mobile workbench layouts", async () => { - const server = await startStaticServer(webRoot); - const browser = await chromium.launch({ headless: true }); - try { - for (const viewport of [ - { width: 1024, height: 768, isMobile: false }, - { width: 390, height: 844, isMobile: true } - ]) { - const page = await browser.newPage({ - viewport: { width: viewport.width, height: viewport.height }, - deviceScaleFactor: 1, - isMobile: viewport.isMobile - }); - await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 15000 }); - await login(page); - const metrics = await sidebarMetrics(page); - assert.equal(metrics.handle.display, "none", `${viewport.width}x${viewport.height} handle display`); - assert.equal(metrics.handle.tabIndex, -1, `${viewport.width}x${viewport.height} tabindex`); - assert.equal(metrics.handle.ariaDisabled, "true", `${viewport.width}x${viewport.height} aria-disabled`); - assert.equal(metrics.handle.ariaHidden, "true", `${viewport.width}x${viewport.height} aria-hidden`); - assert.equal(metrics.disabledTargetsReachable, true, `${viewport.width}x${viewport.height} workbench areas`); - assert.equal(metrics.noHorizontalOverflow, true, `${viewport.width}x${viewport.height} overflow`); - await page.close(); - } - } finally { - await browser.close(); - await server.close(); - } -}); - -async function login(page) { - await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 }); - const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false); - if (!loginVisible) { - await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); - return; - } - await page.locator("#login-username").fill(defaultCredentials.username); - await page.locator("#login-password").fill(defaultCredentials.password); - await page.locator("#login-submit").click(); - await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); -} - -async function dragLeftResize(page, targetWidth) { - const start = await page.evaluate(() => { - const rail = document.querySelector("#activity-rail"); - const handle = document.querySelector("#left-sidebar-resize"); - const railBox = rail.getBoundingClientRect(); - const handleBox = handle.getBoundingClientRect(); - return { - currentWidth: railBox.width, - x: handleBox.left + handleBox.width / 2, - y: handleBox.top + Math.min(80, handleBox.height / 2) - }; - }); - await page.mouse.move(start.x, start.y); - await page.mouse.down(); - await page.mouse.move(start.x + targetWidth - start.currentWidth, start.y, { steps: 8 }); - await page.mouse.up(); -} - -async function sidebarMetrics(page) { - return page.evaluate(() => { - const shell = document.querySelector("[data-app-shell]"); - const rail = document.querySelector("#activity-rail"); - const center = document.querySelector(".center-workspace"); - const right = document.querySelector("#device-pod-sidebar"); - const handle = document.querySelector("#left-sidebar-resize"); - const handleStyle = getComputedStyle(handle); - const handleBox = handle.getBoundingClientRect(); - const storage = JSON.parse(localStorage.getItem("hwlab.workbench.layout.v1") ?? "null"); - const targetSelectors = [ - "#command-input", - "#command-send", - "#device-pod-select", - '#device-pod-summary [data-device-detail="pod"]', - "#device-pod-interfaces .summary-tile", - "#device-event-scroll", - "#device-event-follow" - ]; - const keyTargets = targetSelectors.map((selector) => inspectTargetVisibility(selector)); - const disabledTargets = ["#command-form", "#device-pod-summary"].map((selector) => inspectTargetVisibility(selector)); - return { - collapsed: shell.classList.contains("is-left-sidebar-collapsed"), - railWidth: Math.round(rail.getBoundingClientRect().width), - centerWidth: Math.round(center.getBoundingClientRect().width), - rightWidth: Math.round(right.getBoundingClientRect().width), - storage, - handle: { - display: handleStyle.display, - width: handleBox.width, - role: handle.getAttribute("role"), - ariaLabel: handle.getAttribute("aria-label"), - title: handle.getAttribute("title"), - tabIndex: handle.tabIndex, - ariaDisabled: handle.getAttribute("aria-disabled"), - ariaHidden: handle.getAttribute("aria-hidden"), - min: Number(handle.getAttribute("aria-valuemin")), - max: Number(handle.getAttribute("aria-valuemax")), - now: Number(handle.getAttribute("aria-valuenow")), - valueText: handle.getAttribute("aria-valuetext") - }, - keyTargets, - keyTargetsReachable: keyTargets.every((target) => target.ok), - disabledTargets, - disabledTargetsReachable: disabledTargets.every((target) => target.ok), - noHorizontalOverflow: - document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 && - document.body.scrollWidth <= document.body.clientWidth + 2 && - shell.scrollWidth <= shell.clientWidth + 2 && - right.scrollWidth <= right.clientWidth + 2, - rootScrollLocked: - getComputedStyle(document.documentElement).overflow === "hidden" && - getComputedStyle(document.body).overflow === "hidden" && - document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && - document.body.scrollHeight <= document.body.clientHeight + 2 - }; - - function inspectTargetVisibility(selector) { - const element = document.querySelector(selector); - if (!element) return { selector, ok: false, missing: true }; - scrollIntoNearestContainer(element); - const box = element.getBoundingClientRect(); - const clip = clipBoxFor(element); - const visibleBox = { - left: Math.max(box.left, clip.left), - top: Math.max(box.top, clip.top), - right: Math.min(box.right, clip.right), - bottom: Math.min(box.bottom, clip.bottom) - }; - const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); - const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); - const x = Math.min(Math.max(visibleBox.left + visibleWidth / 2, 0), window.innerWidth - 1); - const y = Math.min(Math.max(visibleBox.top + Math.min(visibleHeight / 2, 32), 0), window.innerHeight - 1); - const stack = document.elementsFromPoint(x, y); - const visible = box.width > 0 && box.height > 0 && visibleWidth > 0 && visibleHeight > 0; - return { - selector, - ok: visible && stack.some((candidate) => candidate === element || element.contains(candidate)), - box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, - visibleBox, - hit: stack[0]?.id || stack[0]?.className || stack[0]?.tagName || "none" - }; - } - - function scrollIntoNearestContainer(element) { - const container = nearestScrollableAncestor(element); - if (!container) { - element.scrollIntoView({ block: "center", inline: "nearest" }); - return; - } - const elementBox = element.getBoundingClientRect(); - const containerBox = container.getBoundingClientRect(); - const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); - container.scrollTop += elementBox.top - containerBox.top - inset; - } - - function nearestScrollableAncestor(element) { - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); - if (scrollable) return current; - current = current.parentElement; - } - return null; - } - - function clipBoxFor(element) { - let visible = { - left: 0, - top: 0, - right: window.innerWidth, - bottom: window.innerHeight - }; - let current = element.parentElement; - while (current && current !== document.documentElement) { - const style = getComputedStyle(current); - if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { - const box = current.getBoundingClientRect(); - visible = { - left: Math.max(visible.left, box.left), - top: Math.max(visible.top, box.top), - right: Math.min(visible.right, box.right), - bottom: Math.min(visible.bottom, box.bottom) - }; - } - current = current.parentElement; - } - return visible; - } - }); -} - -async function startStaticServer(rootDir) { - const server = createServer(async (request, response) => { - try { - const url = new URL(request.url ?? "/", "http://127.0.0.1"); - const pathname = url.pathname === "/" ? "/index.html" : url.pathname; - const resolved = path.resolve(rootDir, `.${pathname}`); - if (!resolved.startsWith(rootDir)) { - response.writeHead(403).end("forbidden"); - return; - } - const body = await readFile(resolved); - response.writeHead(200, { "content-type": contentType(resolved) }); - response.end(body); - } catch { - response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); - response.end("not found"); - } - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - return { - url: `http://127.0.0.1:${address.port}/`, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) - }; -} - -function contentType(filePath) { - if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; - if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; - if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; - if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8"; - return "application/octet-stream"; -}