348 lines
16 KiB
TypeScript
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);
|
|
}
|
|
`;
|
|
}
|