fix: require loaded L1 Web readiness evidence
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

This commit is contained in:
pikastech
2026-07-20 12:15:25 +02:00
parent 11ad29d736
commit 9f3d483c60
4 changed files with 98 additions and 3 deletions
@@ -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 页面矩阵:
+10
View File
@@ -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
@@ -17,6 +17,14 @@ function fixtureProfile(): Record<string, unknown> {
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);
@@ -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<HwlabRuntimeWebProbeNativeReadinessProfileSpec["contentReadiness"]> {
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<HwlabRuntimeWebProbeNativeReadinessProfileSpec["geometryChecks"]> {
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<HwlabRuntimeWebProbeNativeReadinessProfileSpec["layout"]> {
const raw = object(value, path);
onlyKeys(raw, ["requireNoDocumentOverflow", "requireNoMainContentOverflow", "mainContentSelector", "internalScrollSelectors"], path);