fix: enforce v02 cloud web validation

This commit is contained in:
Codex
2026-05-31 11:39:18 +08:00
parent 6314f6747c
commit d269dd52bf
8 changed files with 154 additions and 393 deletions
+1 -1
View File
@@ -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",
+7 -7
View File
@@ -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
};
}
+7
View File
@@ -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);
+85
View File
@@ -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
};
}
+42 -6
View File
@@ -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;
+1 -1
View File
@@ -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",
+11 -44
View File
@@ -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) {
@@ -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";
}