Merge pull request #309 from pikasTech/fix/layout-smoke-dev-gate-273

test: gate Cloud Workbench layout smoke
This commit is contained in:
Lyon
2026-05-23 17:20:49 +08:00
committed by GitHub
9 changed files with 636 additions and 33 deletions
+48 -1
View File
@@ -45,6 +45,53 @@ export function layoutSmokeExitCode(report) {
return report.status === "pass" || report.status === "usage" ? 0 : report.status === "skip" ? 2 : 2;
}
export function compactLayoutSmokeCliOutput(report) {
if (report.status === "usage") return report;
return {
status: report.status,
task: report.task,
issue: report.issue,
mode: report.mode,
sourceMode: report.sourceMode,
url: report.url,
generatedAt: report.generatedAt,
evidenceLevel: report.evidenceLevel,
devLive: report.devLive,
summary: report.summary,
failures: (report.failures ?? []).map(compactLayoutFailure),
blockers: (report.blockers ?? []).map(compactLayoutFailure),
skipped: (report.skipped ?? []).map((item) => ({
checkId: item.checkId,
failureType: item.failureType ?? "skip",
summary: item.summary
})),
artifacts: {
reportPath: report.artifacts?.reportPath ?? null,
screenshotDir: report.artifacts?.screenshotDir ?? null,
screenshotCount: Array.isArray(report.artifacts?.screenshots) ? report.artifacts.screenshots.length : 0
},
validationCommands: report.validationCommands,
safety: {
layoutOnly: report.safety?.layoutOnly === true,
codeAgentPostSent: report.safety?.codeAgentPostSent === true,
hardwareWriteApis: report.safety?.hardwareWriteApis === true,
hitTestMethod: report.safety?.hitTestMethod ?? null,
statement: report.safety?.statement ?? null
}
};
}
function compactLayoutFailure(failure) {
return {
checkId: failure.checkId ?? failure.scope ?? null,
viewport: failure.viewport ?? null,
selector: failure.selector ?? null,
failureType: failure.failureType ?? "blocked",
summary: failure.summary ?? null,
artifact: failure.artifact ?? null
};
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
const args = parseLayoutSmokeArgs(process.argv.slice(2));
@@ -66,7 +113,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`${JSON.stringify(compactLayoutSmokeCliOutput(report), null, 2)}\n`);
process.exitCode = layoutSmokeExitCode(report);
} catch (error) {
const failure = {
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -20,6 +21,7 @@ import {
smokeCliExitCode
} from "./dev-cloud-workbench-smoke.mjs";
import {
compactLayoutSmokeCliOutput,
parseLayoutSmokeArgs
} from "./dev-cloud-workbench-layout-smoke.mjs";
@@ -40,6 +42,9 @@ const expectedRuntimeIdentity = Object.freeze({
imageTag: "7de6edd",
image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:7de6edd"
});
const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8"));
const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.mjs", import.meta.url), "utf8");
test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => {
const defaultArgs = parseSmokeArgs([]);
@@ -480,8 +485,22 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
}
assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2));
assert.equal(report.issue, "pikasTech/HWLAB#273");
assert.equal(report.taskId, "dev-cloud-workbench-layout");
assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout");
assert.equal(report.sourceMode, "source-static");
assert.equal(report.evidenceLevel, "SOURCE");
assert.equal(report.devLive, false);
assert.equal(report.devPreconditions.status, "not_applicable");
assert.equal(report.localSmoke.commands.includes("npm run web:check"), true);
for (const command of [
"node --check scripts/dev-cloud-workbench-layout-smoke.mjs",
"npm run web:layout",
"npm run web:layout:build",
"npm run web:layout:live"
]) {
assert.equal(report.validationCommands.includes(command), true, `missing ${command}`);
}
for (const id of [
"layout-desktop-expanded",
"layout-desktop-collapsed",
@@ -504,7 +523,11 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
);
assert.equal(Array.isArray(report.failures), true);
assert.equal(Array.isArray(report.skipped), true);
assert.equal(report.artifacts.reportPath, null);
assert.match(report.reportLifecycle.summary, /cannot be used as DEV-LIVE evidence/u);
assert.match(report.devPreconditions.summary, /not required for SOURCE\/static gates/u);
assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true);
assert.match(report.safety.statement, /does not send Code Agent chat, call M3 IO/u);
assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip");
@@ -528,7 +551,42 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async
assert.equal(mobileCollapsed.wiring.noHorizontalScroll, true);
const desktopExpanded = report.checks.find((check) => check.id === "layout-desktop-expanded")?.observations;
assert.equal(Object.hasOwn(desktopExpanded.boxes.shell, "text"), false);
assert.equal(Object.hasOwn(desktopExpanded.semanticOverlapChecks[0].boxes["#m3-control-form"], "text"), false);
assert.equal(desktopExpanded.failures.length, 0);
assert.equal(desktopExpanded.semanticOverlapChecks.every((check) => !check.overlaps), true);
assert.equal(desktopExpanded.overflowChecks.every((check) => check.ok), true);
const compact = compactLayoutSmokeCliOutput(report);
assert.equal(compact.status, "pass");
assert.equal(compact.artifacts.screenshotCount, 21);
assert.equal(compact.failures.length, 0);
assert.equal(compact.blockers.length, 0);
assert.equal(JSON.stringify(compact).includes('"checks"'), false);
assert.equal(JSON.stringify(compact).includes('"boxes"'), false);
assert.equal(JSON.stringify(compact).includes('"text"'), false);
});
test("repo-owned web checks expose source build and DEV live layout smoke gates", () => {
assert.equal(rootPackage.scripts["web:check"], "node web/hwlab-cloud-web/scripts/check.mjs");
assert.equal(
rootPackage.scripts["web:layout"],
"node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json"
);
assert.equal(
rootPackage.scripts["web:layout:build"],
"node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json"
);
assert.equal(
rootPackage.scripts["web:layout:live"],
"node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json"
);
assert.match(rootPackage.scripts.check, /node --check scripts\/dev-cloud-workbench-layout-smoke\.mjs/u);
assert.match(rootPackage.scripts.check, /node web\/hwlab-cloud-web\/scripts\/check\.mjs/u);
assert.equal(cloudWebPackage.scripts.layout.includes("--static"), true);
assert.equal(cloudWebPackage.scripts["layout:build"].includes("--build"), true);
assert.equal(cloudWebPackage.scripts["layout:live"].includes("--live --url http://74.48.78.17:16666/"), true);
assert.match(cloudWebCheckSource, /runDevCloudWorkbenchLayoutSmoke/u);
assert.match(cloudWebCheckSource, /tmp\/dev-cloud-workbench-layout-web-check\.json/u);
assert.match(cloudWebCheckSource, /compactLayoutSmokeFailures/u);
});
+181 -22
View File
@@ -843,19 +843,38 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
repoRoot,
args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp())
);
const layoutViewports = workbenchLayoutViewports();
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`;
return {
return sanitizeLayoutReport({
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: reportModeId("layout"),
reportVersion: "v1",
status: "skip",
issue: "pikasTech/HWLAB#273",
taskId: "dev-cloud-workbench-layout",
commitId: observeSourceIdentity().reportCommitId,
acceptanceLevel: "dev_cloud_workbench_layout",
devOnly: true,
prodDisabled: true,
reportLifecycle: layoutReportLifecycle(useLiveUrl, "blocked"),
task: "DC-DCSN-P0-2026-003",
mode: "layout-browser",
sourceMode: layoutSourceMode,
url: useLiveUrl ? args.url : null,
generatedAt: new Date().toISOString(),
sourceContract: layoutSourceContract(),
validationCommands: layoutValidationCommands(),
localSmoke: layoutLocalSmoke("skip"),
dryRun: layoutDryRun(),
devPreconditions: layoutDevPreconditions(useLiveUrl, "blocked"),
evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE",
devLive: false,
summary,
viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })),
checks: [
{
id: "layout-browser-dependency",
@@ -881,8 +900,9 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
artifacts: {
screenshotDir: artifactRoot,
reportPath: args.reportPath ?? null
}
};
},
safety: layoutSafety()
});
}
if (!useLiveUrl && args.build === true) {
@@ -902,11 +922,6 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
let browser;
try {
browser = await chromium.launch({ headless: true });
const layoutViewports = [
{ id: "desktop", width: 1366, height: 768, isMobile: false },
{ id: "narrow-desktop", width: 1024, height: 768, isMobile: false },
{ id: "mobile", width: 390, height: 844, isMobile: true }
];
const viewportResults = {};
for (const viewport of layoutViewports) {
viewportResults[viewport.id] = await inspectWorkbenchLayoutViewport(browser, url, viewport, { artifactRoot });
@@ -1037,13 +1052,28 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
summary: check.summary,
observations: check.observations
}));
return {
return sanitizeLayoutReport({
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: reportModeId("layout"),
reportVersion: "v1",
status: blockers.length === 0 ? "pass" : "blocked",
issue: "pikasTech/HWLAB#273",
taskId: "dev-cloud-workbench-layout",
commitId: observeSourceIdentity().reportCommitId,
acceptanceLevel: "dev_cloud_workbench_layout",
devOnly: true,
prodDisabled: true,
reportLifecycle: layoutReportLifecycle(useLiveUrl, blockers.length === 0 ? "pass" : "blocked"),
task: "DC-DCSN-P0-2026-003",
mode: "layout-browser",
sourceMode: layoutSourceMode,
url,
generatedAt: new Date().toISOString(),
sourceContract: layoutSourceContract(),
validationCommands: layoutValidationCommands(),
localSmoke: layoutLocalSmoke(blockers.length === 0 ? "pass" : "blocked"),
dryRun: layoutDryRun(),
devPreconditions: layoutDevPreconditions(useLiveUrl, blockers.length === 0 ? "pass" : "blocked"),
evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE",
devLive: false,
summary: useLiveUrl
@@ -1063,27 +1093,35 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
]),
reportPath: args.reportPath ?? null
},
safety: {
...staticSafety(),
sourceIsDevLive: false,
layoutOnly: true,
codeAgentPostSent: false,
hardwareWriteApis: false,
hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks",
statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance."
}
};
safety: layoutSafety()
});
} catch (error) {
return {
return sanitizeLayoutReport({
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: reportModeId("layout"),
reportVersion: "v1",
status: "blocked",
issue: "pikasTech/HWLAB#273",
taskId: "dev-cloud-workbench-layout",
commitId: observeSourceIdentity().reportCommitId,
acceptanceLevel: "dev_cloud_workbench_layout",
devOnly: true,
prodDisabled: true,
reportLifecycle: layoutReportLifecycle(useLiveUrl, "blocked"),
task: "DC-DCSN-P0-2026-003",
mode: "layout-browser",
sourceMode: layoutSourceMode,
url,
generatedAt: new Date().toISOString(),
sourceContract: layoutSourceContract(),
validationCommands: layoutValidationCommands(),
localSmoke: layoutLocalSmoke("blocked"),
dryRun: layoutDryRun(),
devPreconditions: layoutDevPreconditions(useLiveUrl, "blocked"),
evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE",
devLive: false,
summary: `Workbench layout smoke failed: ${error.message}`,
viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })),
checks: [],
blockers: [
{
@@ -1109,14 +1147,56 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) {
screenshots: [],
reportPath: args.reportPath ?? null
},
safety: staticSafety()
};
safety: layoutSafety()
});
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
}
function workbenchLayoutViewports() {
return [
{ id: "desktop", width: 1366, height: 768, isMobile: false },
{ id: "narrow-desktop", width: 1024, height: 768, isMobile: false },
{ id: "mobile", width: 390, height: 844, isMobile: true }
];
}
function layoutSafety() {
return {
...staticSafety(),
sourceIsDevLive: false,
layoutOnly: true,
codeAgentPostSent: false,
hardwareWriteApis: false,
hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks",
statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance; it must not claim M3 DEV-LIVE hardware-loop acceptance."
};
}
function sanitizeLayoutReport(report) {
return stripLayoutObservationNoise(report);
}
function stripLayoutObservationNoise(value) {
if (Array.isArray(value)) return value.map(stripLayoutObservationNoise);
if (!value || typeof value !== "object") return value;
const cleaned = {};
const looksLikeElementBox =
Number.isFinite(value.left) &&
Number.isFinite(value.top) &&
Number.isFinite(value.right) &&
Number.isFinite(value.bottom) &&
Number.isFinite(value.width) &&
Number.isFinite(value.height);
for (const [key, item] of Object.entries(value)) {
if (looksLikeElementBox && key === "text") continue;
cleaned[key] = stripLayoutObservationNoise(item);
}
return cleaned;
}
function addDomOnlyCodeAgentCheck(checks) {
checks.push({
id: "live-code-agent-browser-journey",
@@ -1152,6 +1232,85 @@ function addDomOnlyCodeAgentCheck(checks) {
});
}
function layoutReportLifecycle(useLiveUrl, status) {
return {
version: "v1",
state: "active",
activeEndpoint: runtime.endpoints.api,
activeBrowserEndpoint: runtime.endpoints.frontend,
deprecatedEndpoint: null,
summary: useLiveUrl
? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not M3 hardware-loop acceptance.`
: `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.`
};
}
function layoutSourceContract() {
return {
status: "pass",
documents: [
"docs/cloud-web-workbench.md",
"docs/reference/cloud-workbench.md",
"docs/reference/code-agent-chat-readiness.md"
],
summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance."
};
}
function layoutValidationCommands() {
return [
"node --check scripts/dev-cloud-workbench-layout-smoke.mjs",
"node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs",
"npm run web:check",
"npm run web:layout",
"npm run web:layout:build",
"npm run web:layout:live"
];
}
function layoutLocalSmoke(status) {
return {
status,
commands: [
"npm run web:check",
"npm run web:layout",
"npm run web:layout:build"
],
evidence: [
"web:check runs the SOURCE/static layout smoke as a repo-owned frontend gate.",
"web:layout runs the source/static Playwright geometry, hit-target, overflow, and outer-scroll checks without public DEV dependency.",
"web:layout:build rebuilds local dist and checks the built Cloud Web assets."
],
summary: "SOURCE/static and local-build layout checks classify UI overlap, covered hit targets, overflow, and outer-scroll regressions as layout blockers."
};
}
function layoutDryRun() {
return {
status: "not_applicable",
commands: ["npm run web:layout"],
evidence: ["Layout smoke is browser evidence, not a dry-run substitute."],
summary: "No dry-run mode is accepted for layout/clickability evidence."
};
}
function layoutDevPreconditions(useLiveUrl, status) {
return {
status: useLiveUrl ? status : "not_applicable",
requirements: [
"SOURCE/static PR checks must pass without public DEV dependency.",
"local-build mode must rebuild and inspect Cloud Web dist before publish/apply.",
"DEV live mode must target http://74.48.78.17:16666/ after a controlled DEV deployment or confirmed live revision.",
"The report must include viewport, selector, failureType, and artifact/report paths for failures.",
"Layout pass is not M3 DEV-LIVE trusted closure and does not replace #227 DO/DI functional evidence."
],
commands: ["npm run web:layout:live"],
summary: useLiveUrl
? "DEV live layout smoke was executed as UI layout/clickability evidence only."
: "DEV live layout smoke is a post-deploy check and is intentionally not required for SOURCE/static gates."
};
}
function baseReport({
mode,
status,
+240 -2
View File
@@ -31,7 +31,8 @@ const issueFamily = Object.freeze({
DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40",
DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41",
DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58",
DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7"
DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7",
DEV_CLOUD_WORKBENCH_LAYOUT: "pikasTech/HWLAB#273"
});
const allowedIssues = new Set([
contractIssue,
@@ -138,6 +139,14 @@ const requiredDevCloudWorkbenchLiveValidationCommands = [
"node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json",
"node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json"
];
const requiredDevCloudWorkbenchLayoutValidationCommands = [
"node --check scripts/dev-cloud-workbench-layout-smoke.mjs",
"node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs",
"npm run web:check",
"npm run web:layout",
"npm run web:layout:build",
"npm run web:layout:live"
];
const requiredDevCloudWorkbenchDocs = [
"docs/cloud-web-workbench.md",
"docs/reference/cloud-workbench.md",
@@ -276,7 +285,7 @@ const reportFamilyTemplates = new Map([
]
]);
const statusValues = new Set(["pass", "blocked", "degraded", "not_run", "not_applicable", "not_sent", "failed"]);
const statusValues = new Set(["pass", "blocked", "degraded", "skip", "not_run", "not_applicable", "not_sent", "failed"]);
const blockerTypes = new Set([
"contract_blocker",
"environment_blocker",
@@ -527,6 +536,13 @@ async function validateReport(relativePath) {
await validateDevCloudWorkbenchLiveReport(report, label, raw);
return;
}
if (
report.issue === issueFamily.DEV_CLOUD_WORKBENCH_LAYOUT ||
report.taskId === "dev-cloud-workbench-layout"
) {
await validateDevCloudWorkbenchLayoutReport(report, label);
return;
}
const template = reportFamilyTemplates.get(report.taskId);
assert.ok(
@@ -1926,6 +1942,228 @@ function assertCodeAgentRetainedApiFields(value, label) {
}
}
async function validateDevCloudWorkbenchLayoutReport(report, label) {
for (const field of [
"$schema",
"$id",
"reportVersion",
"issue",
"taskId",
"commitId",
"acceptanceLevel",
"devOnly",
"prodDisabled",
"status",
"mode",
"sourceMode",
"url",
"evidenceLevel",
"devLive",
"sourceContract",
"validationCommands",
"localSmoke",
"dryRun",
"devPreconditions",
"checks",
"blockers",
"artifacts",
"safety"
]) {
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
}
assertString(report.$schema, `${label}.$schema`);
assertString(report.$id, `${label}.$id`);
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
assert.equal(report.issue, issueFamily.DEV_CLOUD_WORKBENCH_LAYOUT, `${label}.issue`);
assert.equal(report.taskId, "dev-cloud-workbench-layout", `${label}.taskId`);
assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`);
assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout", `${label}.acceptanceLevel`);
assert.equal(report.devOnly, true, `${label}.devOnly`);
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
assertStatus(report.status, `${label}.status`);
assert.ok(["pass", "blocked", "skip"].includes(report.status), `${label}.status`);
assert.equal(report.mode, "layout-browser", `${label}.mode`);
assert.ok(["source-static", "local-build", "dev-live"].includes(report.sourceMode), `${label}.sourceMode`);
assert.equal(report.devLive, false, `${label}.devLive`);
assert.equal(
report.evidenceLevel,
report.sourceMode === "dev-live" ? report.status === "pass" ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE",
`${label}.evidenceLevel`
);
if (report.sourceMode === "dev-live") {
assert.equal(report.url, "http://74.48.78.17:16666/", `${label}.url`);
}
assertObject(report.sourceContract, `${label}.sourceContract`);
assertStatus(report.sourceContract.status, `${label}.sourceContract.status`);
await assertDocumentSet(
report.sourceContract.documents,
`${label}.sourceContract.documents`,
requiredDevCloudWorkbenchDocs
);
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
assertStringArray(report.validationCommands, `${label}.validationCommands`, {
minLength: requiredDevCloudWorkbenchLayoutValidationCommands.length
});
assertUnique(report.validationCommands, `${label}.validationCommands`);
for (const requiredCommand of requiredDevCloudWorkbenchLayoutValidationCommands) {
assert.ok(
report.validationCommands.includes(requiredCommand),
`${label}.validationCommands missing ${requiredCommand}`
);
}
for (const section of ["localSmoke", "dryRun", "devPreconditions"]) {
assertObject(report[section], `${label}.${section}`);
assertStatus(report[section].status, `${label}.${section}.status`);
assertStringArray(report[section].commands, `${label}.${section}.commands`, { minLength: 1 });
assertStringArray(report[section].evidence ?? [], `${label}.${section}.evidence`, {
minLength: section === "devPreconditions" ? 0 : 1
});
assertString(report[section].summary, `${label}.${section}.summary`);
}
assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, {
minLength: 4
});
assertArray(report.viewports, `${label}.viewports`);
assert.deepEqual(
report.viewports.map((viewport) => `${viewport.width}x${viewport.height}`),
["1366x768", "1024x768", "390x844"],
`${label}.viewports`
);
for (const [index, viewport] of report.viewports.entries()) {
assertObject(viewport, `${label}.viewports[${index}]`);
assertString(viewport.id, `${label}.viewports[${index}].id`);
assert.equal(Number.isInteger(viewport.width), true, `${label}.viewports[${index}].width`);
assert.equal(Number.isInteger(viewport.height), true, `${label}.viewports[${index}].height`);
}
assertArray(report.checks, `${label}.checks`);
if (report.status === "skip") {
assert.ok(report.checks.length >= 1, `${label}.checks must explain the skipped layout dependency`);
} else {
assert.ok(report.checks.length >= 11, `${label}.checks must cover desktop, mobile, and gate layout checks`);
}
assertUnique(report.checks.map((check) => check.id), `${label}.checks ids`);
const checks = new Map(report.checks.map((check, index) => {
const checkLabel = `${label}.checks[${index}]`;
assertObject(check, checkLabel);
for (const field of ["id", "status", "summary"]) {
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
}
assertString(check.id, `${checkLabel}.id`);
assertStatus(check.status, `${checkLabel}.status`);
assertString(check.summary, `${checkLabel}.summary`);
if (Object.hasOwn(check, "viewport")) {
assertViewportObject(check.viewport, `${checkLabel}.viewport`);
}
if (Object.hasOwn(check, "observations")) {
assertObject(check.observations, `${checkLabel}.observations`);
}
return [check.id, check];
}));
if (report.status !== "skip") {
for (const requiredCheck of [
"layout-desktop-expanded",
"layout-desktop-collapsed",
"layout-desktop-restored",
"layout-narrow-desktop-expanded",
"layout-narrow-desktop-collapsed",
"layout-narrow-desktop-restored",
"layout-mobile-collapsed",
"layout-mobile-drawer",
"layout-gate-desktop",
"layout-gate-narrow-desktop",
"layout-gate-mobile"
]) {
assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`);
}
}
assertBlockers(report.blockers, `${label}.blockers`);
assertArray(report.failures ?? [], `${label}.failures`);
for (const [index, failure] of (report.failures ?? []).entries()) {
assertLayoutFailure(failure, `${label}.failures[${index}]`);
}
if (report.status === "pass") {
assert.equal(report.blockers.length, 0, `${label}.blockers`);
assert.equal((report.failures ?? []).length, 0, `${label}.failures`);
} else {
assert.ok(report.blockers.length > 0 || (report.failures ?? []).length > 0, `${label} blocked/skip status must carry blockers or failures`);
}
if (Object.hasOwn(report, "skipped")) {
assertArray(report.skipped, `${label}.skipped`);
for (const [index, skipped] of report.skipped.entries()) {
assertObject(skipped, `${label}.skipped[${index}]`);
assertString(skipped.checkId, `${label}.skipped[${index}].checkId`);
assert.equal(skipped.failureType, "skip", `${label}.skipped[${index}].failureType`);
assertString(skipped.summary, `${label}.skipped[${index}].summary`);
}
}
assertObject(report.artifacts, `${label}.artifacts`);
assertString(report.artifacts.screenshotDir, `${label}.artifacts.screenshotDir`);
if (Object.hasOwn(report.artifacts, "reportPath") && report.artifacts.reportPath !== null) {
assertString(report.artifacts.reportPath, `${label}.artifacts.reportPath`);
}
assertArray(report.artifacts.screenshots ?? [], `${label}.artifacts.screenshots`);
for (const [index, screenshot] of (report.artifacts.screenshots ?? []).entries()) {
assertObject(screenshot, `${label}.artifacts.screenshots[${index}]`);
assertString(screenshot.selector, `${label}.artifacts.screenshots[${index}].selector`);
assertViewportObject(screenshot.viewport, `${label}.artifacts.screenshots[${index}].viewport`);
assertString(screenshot.path, `${label}.artifacts.screenshots[${index}].path`);
}
assertObject(report.safety, `${label}.safety`);
assert.equal(report.safety.layoutOnly, true, `${label}.safety.layoutOnly`);
assert.equal(report.safety.codeAgentPostSent, false, `${label}.safety.codeAgentPostSent`);
assert.equal(report.safety.hardwareWriteApis, false, `${label}.safety.hardwareWriteApis`);
assert.equal(report.safety.sourceIsDevLive, false, `${label}.safety.sourceIsDevLive`);
assert.match(report.safety.hitTestMethod, /elementsFromPoint/u, `${label}.safety.hitTestMethod`);
assert.match(report.safety.statement, /not claim M3 DEV-LIVE hardware-loop acceptance/u, `${label}.safety.statement`);
}
function assertViewportObject(value, label) {
assertObject(value, label);
assert.equal(Number.isInteger(value.width), true, `${label}.width`);
assert.equal(Number.isInteger(value.height), true, `${label}.height`);
}
function assertLayoutFailure(value, label) {
assertObject(value, label);
if (Object.hasOwn(value, "checkId")) {
assertString(value.checkId, `${label}.checkId`);
}
assert.ok(Object.hasOwn(value, "viewport"), `${label} missing viewport`);
if (value.viewport !== null) {
assertViewportObject(value.viewport, `${label}.viewport`);
}
assert.ok(Object.hasOwn(value, "selector"), `${label} missing selector`);
if (value.selector !== null) {
assertString(value.selector, `${label}.selector`);
}
assertString(value.failureType, `${label}.failureType`);
assert.ok(
[
"overlap",
"covered-hit-target",
"overflow",
"outer-scroll-regression",
"screenshot-diff",
"blocked",
"blocked/skip",
"skip"
].includes(value.failureType),
`${label}.failureType`
);
assertString(value.summary, `${label}.summary`);
}
function assertDevCloudWorkbenchDeploymentPreflight(report, label, checks) {
for (const requiredCheck of [
"live-runtime-current-main",