fix: enforce v02 cloud web validation
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user