Files
pikasTech-unidesk/scripts/src/hwlab-node-web-probe-console-verify.ts
T

348 lines
16 KiB
TypeScript

// Responsibility: Generate the repo-owned Cloud Console route-matrix browser command.
import type { HwlabRuntimeWebProbeConsoleVerificationProfileSpec } from "./hwlab-node-web-probe-console-profile";
export function nodeWebProbeConsoleVerifyScript(
profileName: string,
profile: HwlabRuntimeWebProbeConsoleVerificationProfileSpec,
configRef: string,
): string {
const config = { profileName, configRef, ...profile };
return String.raw`const config = ${JSON.stringify(config)};
export default async function consoleVerify({ page, context, goto, reloadStable, wait, screenshot, jsonArtifact, recordStep }) {
const failures = [];
const pages = [];
const screenshots = [];
const consoleErrors = [];
const responseErrors = [];
let totalFailureCount = 0;
let totalConsoleErrorCount = 0;
let totalResponseErrorCount = 0;
const addFailure = (message) => {
totalFailureCount += 1;
if (failures.length < config.outputLimits.failures) failures.push(String(message));
};
const addConsoleError = (message) => {
totalConsoleErrorCount += 1;
if (consoleErrors.length < config.outputLimits.consoleErrors) consoleErrors.push(String(message).slice(0, 300));
};
const addResponseError = (response) => {
totalResponseErrorCount += 1;
if (responseErrors.length < config.outputLimits.responseErrors) {
responseErrors.push({ status: response.status(), url: response.url().replace(/[?#].*$/u, "") });
}
};
await context.addInitScript(() => {
const OriginalEventSource = window.EventSource;
window.__unideskConsoleEventSources = [];
if (!OriginalEventSource || window.__unideskConsoleEventSourceWrapped === true) return;
window.__unideskConsoleEventSourceWrapped = true;
function TrackedEventSource(url, options) {
const instance = new OriginalEventSource(url, options);
window.__unideskConsoleEventSources.push(String(url));
return instance;
}
TrackedEventSource.prototype = OriginalEventSource.prototype;
TrackedEventSource.CONNECTING = OriginalEventSource.CONNECTING;
TrackedEventSource.OPEN = OriginalEventSource.OPEN;
TrackedEventSource.CLOSED = OriginalEventSource.CLOSED;
window.EventSource = TrackedEventSource;
});
page.on("console", (message) => {
if (message.type() === "error") addConsoleError(message.text());
});
page.on("response", (response) => {
if (response.status() >= 500) addResponseError(response);
});
const routeById = new Map(config.routes.map((route) => [route.id, route]));
const requiredRoute = (id) => {
const route = routeById.get(id);
if (!route) throw new Error("console verification profile references missing route " + id);
return route;
};
const gotoRoute = async (id) => {
const route = requiredRoute(id);
await goto(route.path, { selectors: [route.readySelector], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 });
if (config.settleMs > 0) await wait(config.settleMs);
return route;
};
const captureScreenshot = async (name) => {
if (screenshots.length >= config.outputLimits.screenshots) {
addFailure("screenshot budget exceeded before " + name);
return null;
}
try {
const artifact = await screenshot(name);
screenshots.push(artifact);
return artifact;
} catch (error) {
addFailure("screenshot " + name + " failed: " + errorMessage(error));
return null;
}
};
for (const viewport of config.viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const beforeCount = pages.length;
for (const route of config.routes) {
await gotoRoute(route.id);
const metrics = await page.evaluate(({ requestedRoute, viewportId, readySelector, textPreviewChars }) => {
const html = document.documentElement;
const body = document.body;
const app = document.querySelector("#app");
const root = document.querySelector(readySelector);
const text = (app?.textContent || "").replace(/\s+/gu, " ").trim();
const rootRect = root?.getBoundingClientRect();
return {
requestedRoute,
viewportId,
finalPath: location.pathname + location.search,
title: document.title,
textLength: text.length,
textPreview: text.slice(0, textPreviewChars),
appVisible: Boolean(app && app.getBoundingClientRect().width > 0 && app.getBoundingClientRect().height > 0),
rootVisible: Boolean(rootRect && rootRect.width > 0 && rootRect.height > 0),
horizontalOverflowPx: Math.max(0, html.scrollWidth - innerWidth, body.scrollWidth - innerWidth),
documentHeight: Math.max(html.scrollHeight, body.scrollHeight),
};
}, {
requestedRoute: route.path,
viewportId: viewport.id,
readySelector: route.readySelector,
textPreviewChars: config.outputLimits.textPreviewChars,
});
pages.push(metrics);
if (!metrics.appVisible || !metrics.rootVisible || metrics.textLength < config.minTextLength) {
addFailure(viewport.id + " " + route.path + ": app/root blank");
}
if (metrics.finalPath.startsWith("/login")) addFailure(viewport.id + " " + route.path + ": redirected to login");
if (metrics.horizontalOverflowPx > config.maxHorizontalOverflowPx) {
addFailure(viewport.id + " " + route.path + ": horizontal overflow " + metrics.horizontalOverflowPx + "px");
}
if (route.screenshot) await captureScreenshot(viewport.id + "-" + route.id + ".png");
}
recordStep("console-matrix-" + viewport.id, {
ok: totalFailureCount === 0,
viewport,
routeSamples: pages.length - beforeCount,
totalFailureCount,
});
}
const workflowEvidence = {};
if (config.workflows.projects) workflowEvidence.projects = await verifyProjects(config.workflows.projects);
if (config.workflows.hwpod) workflowEvidence.hwpod = await verifyHwpod(config.workflows.hwpod);
if (config.workflows.mdtodo) workflowEvidence.mdtodo = await verifyMdtodo(config.workflows.mdtodo);
if (config.workflows.agentObserver) workflowEvidence.agentObserver = await verifyAgentObserver(config.workflows.agentObserver);
if (totalResponseErrorCount > 0) addFailure("HTTP 5xx responses: " + totalResponseErrorCount);
const evidence = {
profileName: config.profileName,
configRef: config.configRef,
pages,
failures,
failureCount: totalFailureCount,
consoleErrors,
consoleErrorCount: totalConsoleErrorCount,
responseErrors,
responseErrorCount: totalResponseErrorCount,
screenshots,
workflowEvidence,
valuesRedacted: true,
};
const report = await jsonArtifact("cloud-console-verification.json", evidence);
const ok = totalFailureCount === 0;
recordStep("console-verification-final", {
ok,
profileName: config.profileName,
routeSamples: pages.length,
failureCount: totalFailureCount,
screenshotCount: screenshots.length,
});
return {
ok,
status: ok ? "pass" : "blocked",
profileName: config.profileName,
configRef: config.configRef,
routeSamples: pages.length,
viewportCount: config.viewports.length,
failureCount: totalFailureCount,
failures,
consoleErrorCount: totalConsoleErrorCount,
responseErrorCount: totalResponseErrorCount,
screenshotCount: screenshots.length,
report,
workflowEvidence,
issueEvidence: {
profileName: config.profileName,
configRef: config.configRef,
reportSha256: typeof report?.sha256 === "string" ? report.sha256 : null,
screenshotSha256: screenshots.map((item) => item?.sha256).filter((item) => typeof item === "string"),
failureCount: totalFailureCount,
failures,
valuesRedacted: true,
},
valuesRedacted: true,
};
async function verifyProjects(workflow) {
await gotoRoute(workflow.routeId);
const button = page.getByRole("button", { name: workflow.listButtonName, exact: true });
if (await button.count() < 1) {
addFailure("Projects: list view control missing");
return { listView: false };
}
await button.first().click();
await wait(config.settleMs);
const queryPresent = new URL(page.url()).searchParams.has(workflow.viewQueryKey);
if (!queryPresent) addFailure("Projects: list view did not enter URL state");
return { listView: queryPresent, finalPath: new URL(page.url()).pathname + new URL(page.url()).search };
}
async function verifyHwpod(workflow) {
await gotoRoute(workflow.devicesRouteId);
const listButton = page.getByRole("button", { name: workflow.listButtonName, exact: true });
if (await listButton.count() < 1) {
addFailure("HWPOD devices: list view control missing");
} else {
await listButton.first().click();
await wait(config.settleMs);
if (await page.locator(workflow.devicesListSelector).count() < 1) addFailure("HWPOD devices: list projection missing");
const cardButton = page.getByRole("button", { name: workflow.cardButtonName, exact: true });
if (await cardButton.count() < 1) addFailure("HWPOD devices: card view control missing");
else {
await cardButton.first().click();
await wait(config.settleMs);
if (await page.locator(workflow.devicesCardSelector).count() < 1) addFailure("HWPOD devices: card projection missing");
}
}
await page.emulateMedia({ reducedMotion: "reduce" });
const reducedMotion = await page.evaluate((selector) => ({
matches: matchMedia("(prefers-reduced-motion: reduce)").matches,
animations: [...document.querySelectorAll(selector)].map((element) => getComputedStyle(element).animationName),
}), workflow.pcbTraceSelector);
if (!reducedMotion.matches || reducedMotion.animations.some((name) => name !== "none")) {
addFailure("HWPOD devices: reduced-motion does not stop PCB animation");
}
await page.emulateMedia({ reducedMotion: "no-preference" });
let deviceDeepLink = null;
const detail = page.getByRole("button", { name: workflow.detailButtonName, exact: true }).first();
if (await detail.count() > 0) {
await detail.click();
await wait(config.settleMs);
deviceDeepLink = new URL(page.url()).pathname;
const basePath = requiredRoute(workflow.devicesRouteId).path.split("?")[0];
if (!deviceDeepLink.startsWith(workflow.detailPathPrefix) || deviceDeepLink === basePath) {
addFailure("HWPOD devices: invalid detail deep link " + deviceDeepLink);
}
await reloadStable({ selectors: [requiredRoute(workflow.devicesRouteId).readySelector], readinessTimeoutMs: config.navigationTimeoutMs });
if (new URL(page.url()).pathname !== deviceDeepLink) addFailure("HWPOD devices: deep link did not survive refresh");
}
await gotoRoute(workflow.nodesRouteId);
const nodeListButton = page.getByRole("button", { name: workflow.listButtonName, exact: true });
if (await nodeListButton.count() < 1) addFailure("HWPOD nodes: list view control missing");
else {
await nodeListButton.first().click();
await wait(config.settleMs);
if (await page.locator(workflow.nodesListSelector).count() < 1) addFailure("HWPOD nodes: list projection missing");
}
await gotoRoute(workflow.onboardingRouteId);
const onboarding = await page.evaluate((workflowConfig) => ({
commands: [...document.querySelectorAll(workflowConfig.onboardingCommandSelector)].map((element) => element.textContent?.trim() || ""),
downloadHref: document.querySelector(workflowConfig.downloadSelector)?.getAttribute("href") || null,
internalVisibleCount: document.querySelectorAll(workflowConfig.forbiddenInternalSelector).length,
}), workflow);
for (const prefix of workflow.requiredCommandPrefixes) {
if (!onboarding.commands.some((command) => command.startsWith(prefix))) addFailure("HWPOD onboarding: required command prefix missing: " + prefix);
}
for (const forbidden of workflow.forbiddenDownloadSubstrings) {
if (onboarding.downloadHref?.includes(forbidden)) addFailure("HWPOD onboarding: forbidden download endpoint visible: " + forbidden);
}
if (onboarding.internalVisibleCount > 0) addFailure("HWPOD onboarding: internal entrypoint token visible");
return { deviceDeepLink, reducedMotion, onboarding };
}
async function verifyMdtodo(workflow) {
await gotoRoute(workflow.routeId);
const firstTask = page.locator(workflow.taskSelector).first();
if (await firstTask.count() < 1) {
addFailure("MDTODO: projected task tree is empty");
return { taskDeepLink: null, reportPreviewVisible: false, reportFullscreenVisible: false };
}
await firstTask.click();
await wait(config.settleMs);
const taskDeepLink = new URL(page.url()).pathname;
if (!taskDeepLink.includes(workflow.taskDeepLinkSegment)) addFailure("MDTODO: task deep link missing " + taskDeepLink);
await reloadStable({ selectors: [workflow.taskDetailSelector], readinessTimeoutMs: config.navigationTimeoutMs });
if (new URL(page.url()).pathname !== taskDeepLink) addFailure("MDTODO: task deep link did not survive refresh");
let reportPreviewVisible = false;
let reportFullscreenVisible = false;
const reportLink = page.locator(workflow.reportLinkSelector).first();
if (await reportLink.count() > 0) {
await reportLink.click();
await wait(config.settleMs);
reportPreviewVisible = await page.locator(workflow.reportPreviewSelector).isVisible().catch(() => false);
if (!reportPreviewVisible) addFailure("MDTODO: report preview did not become visible");
const fullscreen = page.locator(workflow.fullscreenButtonSelector).first();
if (await fullscreen.count() > 0) {
await fullscreen.click();
await wait(config.settleMs);
reportFullscreenVisible = await page.locator(workflow.fullscreenDialogSelector).isVisible().catch(() => false);
if (!reportFullscreenVisible) addFailure("MDTODO: report fullscreen did not become visible");
await page.keyboard.press("Escape");
}
}
return { taskDeepLink, reportPreviewVisible, reportFullscreenVisible };
}
async function verifyAgentObserver(workflow) {
await gotoRoute(workflow.routeId);
if (workflow.streamSettleMs > 0) await wait(workflow.streamSettleMs);
const before = await agentObserverState(workflow);
if (before.sourceCount !== 1) addFailure("AgentRun: expected one product EventSource, observed " + before.sourceCount);
const viewResults = [];
for (const view of workflow.views) {
const button = page.getByRole("button", { name: view.buttonName, exact: true });
if (await button.count() < 1) {
addFailure("AgentRun: missing view button " + view.buttonName);
continue;
}
await button.first().click();
await wait(config.settleMs);
const queryValue = new URL(page.url()).searchParams.get("view");
if (queryValue !== view.queryValue) addFailure("AgentRun: view " + view.buttonName + " URL state expected " + view.queryValue + ", got " + queryValue);
const artifact = await captureScreenshot(view.screenshotName);
viewResults.push({ buttonName: view.buttonName, queryValue, screenshotSha256: artifact?.sha256 || null });
}
const after = await agentObserverState(workflow);
if (after.sourceCount !== before.sourceCount) {
addFailure("AgentRun: view switch changed EventSource count " + before.sourceCount + " -> " + after.sourceCount);
}
if (workflow.blockedPhases.includes(after.phase)) addFailure("AgentRun: transport phase " + after.phase);
return { before, after, views: viewResults };
}
async function agentObserverState(workflow) {
return await page.evaluate(({ eventSourcePath, phaseSelector }) => {
const sources = Array.isArray(window.__unideskConsoleEventSources) ? window.__unideskConsoleEventSources : [];
return {
sources,
sourceCount: sources.filter((url) => String(url).includes(eventSourcePath)).length,
phase: document.querySelector(phaseSelector)?.textContent?.trim() || null,
};
}, workflow);
}
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
`;
}