From 9f3d483c60dcf0bedf46cca6a4027a51ee2acc3c Mon Sep 17 00:00:00 2001 From: pikastech Date: Mon, 20 Jul 2026 12:15:25 +0200 Subject: [PATCH] fix: require loaded L1 Web readiness evidence --- .../unidesk-webdev/references/web-probe.md | 3 + config/hwlab-node-lanes.yaml | 10 +++ ...ab-node-web-probe-native-readiness.test.ts | 12 +++ .../hwlab-node-web-probe-native-readiness.ts | 76 ++++++++++++++++++- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/.agents/skills/unidesk-webdev/references/web-probe.md b/.agents/skills/unidesk-webdev/references/web-probe.md index c5c58526..12553b3d 100644 --- a/.agents/skills/unidesk-webdev/references/web-probe.md +++ b/.agents/skills/unidesk-webdev/references/web-probe.md @@ -9,6 +9,9 @@ - 固定 HTTPS origin 来自 lane 的 `nativeDevelopment.publicExposure`,页面与判据来自 `nativeReadinessProfiles`; - 单次命令必须同时判定 DOM 挂载、基础交互、`console.error`、`pageerror`、关键失败响应和截图; - 顶层 `status=pass` 才能向用户披露入口可用,`blocked` 必须继续修复或报告 browser readiness blocker; + - Workbench profile 必须等待 session rail 结束加载;存在 session 时点击首个真实 session,并等待对话内容或明确空态可见后再截图; + - 停在“加载中”的截图只能证明 shell 挂载,不能关闭业务可用性或视觉问题; + - 已报告的视觉层级问题使用 profile 的 `geometryChecks` 输出匹配数量、实际尺寸和 YAML 预算,截图与几何判定必须同时通过; - 命令不接受 `--url` 或 `--origin`,避免绕过 owning YAML 选择其他运行面。 - L1 浏览器必须打开 owning YAML 解析出的固定 HTTPS origin;`127.0.0.1`、`localhost`、IP、port 和临时 `--url` 只能作为本机 preflight,不能替代 L1 readiness。 - Cloud Console 页面矩阵: diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 70e0e48c..e58a96d6 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -903,6 +903,16 @@ templates: - "#workspace" - .desktop-sidebar-toggle minBodyTextLength: 20 + contentReadiness: + settledSelector: "#session-tabs:not([data-loading='true'])" + itemSelector: .session-tab + selectedSelector: ".session-tab[data-active='true']" + contentSelector: "#conversation-list > :is(.message-card, .conversation-empty-hint, .timeline-row)" + timeoutMs: 30000 + geometryChecks: + - selector: ".semantic-failure-banner[data-emphasis='quiet']" + maxHeightPx: 40 + required: false interaction: selector: .desktop-sidebar-toggle observedSelector: .platform-sidebar diff --git a/scripts/src/hwlab-node-web-probe-native-readiness.test.ts b/scripts/src/hwlab-node-web-probe-native-readiness.test.ts index 2b7fd4c6..014dd33d 100644 --- a/scripts/src/hwlab-node-web-probe-native-readiness.test.ts +++ b/scripts/src/hwlab-node-web-probe-native-readiness.test.ts @@ -17,6 +17,14 @@ function fixtureProfile(): Record { viewport: { width: 1280, height: 720 }, readySelectors: ["#app", "button.toggle"], minBodyTextLength: 8, + contentReadiness: { + settledSelector: "#list:not([data-loading='true'])", + itemSelector: ".item", + selectedSelector: ".item[data-active='true']", + contentSelector: ".content", + timeoutMs: 20000, + }, + geometryChecks: [{ selector: ".quiet-status", maxHeightPx: 40, required: false }], interaction: { selector: "button.toggle", observedSelector: "aside.panel", observedAttribute: "data-collapsed" }, failedResponseStatus: 400, criticalPathPrefixes: ["/api/"], @@ -39,6 +47,8 @@ test("native readiness profile is owned by lane YAML", () => { assert.equal(profile.path, "/workbench"); assert.equal(profile.application, "workbench"); assert.deepEqual(profile.readySelectors, ["#workspace", ".desktop-sidebar-toggle"]); + assert.equal(profile.contentReadiness?.settledSelector, "#session-tabs:not([data-loading='true'])"); + assert.equal(profile.geometryChecks?.[0]?.maxHeightPx, 40); assert.equal(profile.interaction.observedSelector, ".platform-sidebar"); assert.equal(profile.interaction.observedAttribute, "data-collapsed"); assert.equal(spec.nativeDevelopment?.workbench.publicExposure.publicBaseUrl, "https://lab-dev-workbench.hwpod.com"); @@ -60,6 +70,8 @@ test("generated native readiness script is valid and generic", () => { assert.match(script, /page\.on\("pageerror"/u); assert.match(script, /page\.on\("requestfailed"/u); assert.match(script, /interactionChanged/u); + assert.match(script, /contentReadiness/u); + assert.match(script, /geometryReady/u); assert.match(script, /noDocumentOverflow/u); assert.match(script, /internalScrollPanesReady/u); assert.match(script, /jsonArtifact\("native-web-readiness\.json"/u); diff --git a/scripts/src/hwlab-node-web-probe-native-readiness.ts b/scripts/src/hwlab-node-web-probe-native-readiness.ts index 36abb16d..840096e6 100644 --- a/scripts/src/hwlab-node-web-probe-native-readiness.ts +++ b/scripts/src/hwlab-node-web-probe-native-readiness.ts @@ -9,6 +9,18 @@ export interface HwlabRuntimeWebProbeNativeReadinessProfileSpec { readonly viewport: { readonly width: number; readonly height: number }; readonly readySelectors: readonly string[]; readonly minBodyTextLength: number; + readonly contentReadiness?: { + readonly settledSelector: string; + readonly itemSelector: string; + readonly selectedSelector: string; + readonly contentSelector: string; + readonly timeoutMs: number; + }; + readonly geometryChecks?: readonly { + readonly selector: string; + readonly maxHeightPx: number; + readonly required: boolean; + }[]; readonly interaction: { readonly selector: string; readonly observedSelector: string; @@ -93,6 +105,19 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js await page.setViewportSize(config.viewport); await goto(config.path, { selectors: config.readySelectors, readinessTimeoutMs: config.navigationTimeoutMs, attempts: 2 }); + let contentReadiness = null; + if (config.contentReadiness) { + const content = config.contentReadiness; + await page.locator(content.settledSelector).waitFor({ state: "visible", timeout: content.timeoutMs }); + const item = page.locator(content.itemSelector).first(); + const itemCount = await page.locator(content.itemSelector).count(); + if (itemCount > 0) { + await item.click({ timeout: content.timeoutMs }); + await page.locator(content.selectedSelector).first().waitFor({ state: "visible", timeout: content.timeoutMs }); + await page.locator(content.contentSelector).first().waitFor({ state: "visible", timeout: content.timeoutMs }); + } + contentReadiness = { settled: true, itemCount, selected: itemCount > 0 }; + } if (config.settleMs > 0) await wait(config.settleMs); const before = await page.locator(config.interaction.observedSelector).getAttribute(config.interaction.observedAttribute); const mounted = await page.evaluate(({ selectors, minBodyTextLength, textPreviewChars, layout }) => { @@ -139,10 +164,23 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js if (config.settleMs > 0) await wait(config.settleMs); const after = await page.locator(config.interaction.observedSelector).getAttribute(config.interaction.observedAttribute); const interactionChanged = before !== after; + const geometryChecks = await page.evaluate((checks) => checks.map((check) => { + const heights = [...document.querySelectorAll(check.selector)].map((element) => element.getBoundingClientRect().height); + const maxHeight = heights.length > 0 ? Math.max(...heights) : null; + return { + selector: check.selector, + required: check.required, + matched: heights.length, + maxHeight, + maxHeightPx: check.maxHeightPx, + ok: heights.length === 0 ? !check.required : maxHeight <= check.maxHeightPx, + }; + }), config.geometryChecks || []); + const geometryReady = geometryChecks.every((check) => check.ok); const screenshotArtifact = await screenshot(config.screenshotName); const selectorsReady = mounted.selectorsVisible.every((item) => item.visible); const layoutReady = mounted.layout.noDocumentOverflow && mounted.layout.noMainContentOverflow && mounted.layout.internalScrollPanesReady; - const ok = selectorsReady && mounted.bodyTextSufficient && interactionChanged && layoutReady + const ok = selectorsReady && mounted.bodyTextSufficient && interactionChanged && layoutReady && geometryReady && consoleErrorCount === 0 && pageErrorCount === 0 && failedResponseCount === 0; const evidence = { ok, @@ -150,7 +188,9 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js profileName: config.profileName, configRef: config.configRef, mounted, + contentReadiness, interaction: { selector: config.interaction.selector, observedSelector: config.interaction.observedSelector, observedAttribute: config.interaction.observedAttribute, before, after, changed: interactionChanged }, + geometryChecks, consoleErrorCount, consoleErrors, pageErrorCount, @@ -161,7 +201,7 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js valuesRedacted: true, }; const report = await jsonArtifact("native-web-readiness.json", evidence); - recordStep("native-web-readiness", { ok, selectorsReady, bodyTextSufficient: mounted.bodyTextSufficient, interactionChanged, layoutReady, consoleErrorCount, pageErrorCount, failedResponseCount }); + recordStep("native-web-readiness", { ok, selectorsReady, bodyTextSufficient: mounted.bodyTextSufficient, contentReadiness, interactionChanged, layoutReady, geometryReady, consoleErrorCount, pageErrorCount, failedResponseCount }); return { ...evidence, report }; } `; @@ -169,7 +209,7 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebProbeNativeReadinessProfileSpec { const raw = object(value, path); - onlyKeys(raw, ["application", "path", "navigationTimeoutMs", "settleMs", "commandTimeoutSeconds", "viewport", "readySelectors", "minBodyTextLength", "interaction", "failedResponseStatus", "criticalPathPrefixes", "screenshotName", "outputLimits", "layout"], path); + onlyKeys(raw, ["application", "path", "navigationTimeoutMs", "settleMs", "commandTimeoutSeconds", "viewport", "readySelectors", "minBodyTextLength", "contentReadiness", "geometryChecks", "interaction", "failedResponseStatus", "criticalPathPrefixes", "screenshotName", "outputLimits", "layout"], path); const viewport = object(raw.viewport, `${path}.viewport`); onlyKeys(viewport, ["width", "height"], `${path}.viewport`); const interaction = object(raw.interaction, `${path}.interaction`); @@ -177,6 +217,8 @@ function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebPr const limits = object(raw.outputLimits, `${path}.outputLimits`); onlyKeys(limits, ["errors", "failedResponses", "textPreviewChars"], `${path}.outputLimits`); const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120); + const contentReadiness = raw.contentReadiness === undefined ? undefined : nativeReadinessContent(raw.contentReadiness, `${path}.contentReadiness`); + const geometryChecks = raw.geometryChecks === undefined ? undefined : nativeReadinessGeometryChecks(raw.geometryChecks, `${path}.geometryChecks`); const layout = raw.layout === undefined ? undefined : nativeReadinessLayout(raw.layout, `${path}.layout`); if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`); return { @@ -188,6 +230,8 @@ function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebPr viewport: { width: integer(viewport.width, `${path}.viewport.width`, 240, 7680), height: integer(viewport.height, `${path}.viewport.height`, 240, 4320) }, readySelectors: textArray(raw.readySelectors, `${path}.readySelectors`, 1, 20, 500), minBodyTextLength: integer(raw.minBodyTextLength, `${path}.minBodyTextLength`, 1, 100_000), + ...(contentReadiness === undefined ? {} : { contentReadiness }), + ...(geometryChecks === undefined ? {} : { geometryChecks }), interaction: { selector: boundedText(interaction.selector, `${path}.interaction.selector`, 500), observedSelector: boundedText(interaction.observedSelector, `${path}.interaction.observedSelector`, 500), @@ -201,6 +245,32 @@ function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebPr }; } +function nativeReadinessContent(value: unknown, path: string): NonNullable { + const raw = object(value, path); + onlyKeys(raw, ["settledSelector", "itemSelector", "selectedSelector", "contentSelector", "timeoutMs"], path); + return { + settledSelector: boundedText(raw.settledSelector, `${path}.settledSelector`, 500), + itemSelector: boundedText(raw.itemSelector, `${path}.itemSelector`, 500), + selectedSelector: boundedText(raw.selectedSelector, `${path}.selectedSelector`, 500), + contentSelector: boundedText(raw.contentSelector, `${path}.contentSelector`, 500), + timeoutMs: integer(raw.timeoutMs, `${path}.timeoutMs`, 1000, 120_000), + }; +} + +function nativeReadinessGeometryChecks(value: unknown, path: string): NonNullable { + if (!Array.isArray(value) || value.length < 1 || value.length > 20) throw new Error(`${path} must contain 1-20 items`); + return value.map((item, index) => { + const itemPath = `${path}[${index}]`; + const raw = object(item, itemPath); + onlyKeys(raw, ["selector", "maxHeightPx", "required"], itemPath); + return { + selector: boundedText(raw.selector, `${itemPath}.selector`, 500), + maxHeightPx: integer(raw.maxHeightPx, `${itemPath}.maxHeightPx`, 1, 4320), + required: boolean(raw.required, `${itemPath}.required`), + }; + }); +} + function nativeReadinessLayout(value: unknown, path: string): NonNullable { const raw = object(value, path); onlyKeys(raw, ["requireNoDocumentOverflow", "requireNoMainContentOverflow", "mainContentSelector", "internalScrollSelectors"], path);