550 lines
28 KiB
TypeScript
550 lines
28 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 pageErrors = [];
|
|
const consoleErrors = [];
|
|
const requestFailures = [];
|
|
const responseErrors = [];
|
|
let totalFailureCount = 0;
|
|
let totalPageErrorCount = 0;
|
|
let totalConsoleErrorCount = 0;
|
|
let totalRequestFailureCount = 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 addPageError = (error) => {
|
|
totalPageErrorCount += 1;
|
|
if (pageErrors.length < config.outputLimits.pageErrors) {
|
|
pageErrors.push(String(error?.stack || errorMessage(error)).slice(0, 600));
|
|
}
|
|
};
|
|
const addRequestFailure = (request) => {
|
|
totalRequestFailureCount += 1;
|
|
if (requestFailures.length < config.outputLimits.requestFailures) {
|
|
requestFailures.push({
|
|
method: request.method(),
|
|
url: request.url().replace(/[?#].*$/u, ""),
|
|
errorText: String(request.failure()?.errorText || "request failed").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("pageerror", addPageError);
|
|
page.on("requestfailed", addRequestFailure);
|
|
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.tasktree) workflowEvidence.tasktree = await verifyTasktree(config.workflows.tasktree);
|
|
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,
|
|
pageErrors,
|
|
pageErrorCount: totalPageErrorCount,
|
|
requestFailures,
|
|
requestFailureCount: totalRequestFailureCount,
|
|
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",
|
|
pageErrors,
|
|
consoleErrors,
|
|
requestFailures,
|
|
responseErrors,
|
|
failureCount: totalFailureCount,
|
|
failures,
|
|
pageErrorCount: totalPageErrorCount,
|
|
consoleErrorCount: totalConsoleErrorCount,
|
|
requestFailureCount: totalRequestFailureCount,
|
|
responseErrorCount: totalResponseErrorCount,
|
|
profileName: config.profileName,
|
|
configRef: config.configRef,
|
|
routeSamples: pages.length,
|
|
viewportCount: config.viewports.length,
|
|
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,
|
|
pageErrors,
|
|
consoleErrors,
|
|
requestFailures,
|
|
responseErrors,
|
|
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();
|
|
await firstTask.waitFor({ state: "visible", timeout: config.navigationTimeoutMs }).catch(() => undefined);
|
|
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 verifyTasktree(workflow) {
|
|
await gotoRoute(workflow.routeId);
|
|
const firstTask = page.locator(workflow.taskSelector).first();
|
|
await firstTask.waitFor({ state: "visible", timeout: config.navigationTimeoutMs }).catch(() => undefined);
|
|
if (await firstTask.count() < 1) {
|
|
addFailure("TaskTree: timeline is empty");
|
|
return { taskDeepLink: null, taskCount: 0, visibleBarCount: 0, reportCount: 0, dialogVisible: false };
|
|
}
|
|
const timeline = await page.evaluate((workflowConfig) => {
|
|
const visible = (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
};
|
|
const gantt = document.querySelector(workflowConfig.ganttSelector);
|
|
const scroll = document.querySelector(workflowConfig.scrollSelector);
|
|
const labels = [...document.querySelectorAll(workflowConfig.taskSelector)];
|
|
const bars = [...document.querySelectorAll(workflowConfig.barSelector)];
|
|
return {
|
|
taskCount: labels.length,
|
|
datedTaskCount: labels.filter((element) => element.getAttribute("data-dated") === "true").length,
|
|
visibleLabelCount: labels.filter(visible).length,
|
|
barCount: bars.length,
|
|
visibleBarCount: bars.filter(visible).length,
|
|
milestoneCount: document.querySelectorAll(workflowConfig.milestoneSelector).length,
|
|
ganttInternalOverflowPx: scroll ? Math.max(0, scroll.scrollWidth - scroll.clientWidth) : null,
|
|
splitPane: Boolean(gantt && scroll && !scroll.contains(labels[0])),
|
|
labelsWithVisibleText: labels.filter((element) => (element.textContent || "").trim().length > 0).length,
|
|
};
|
|
}, workflow);
|
|
if (timeline.taskCount < 1 || timeline.visibleLabelCount !== timeline.taskCount) addFailure("TaskTree: task labels are not visible");
|
|
if (timeline.visibleBarCount !== timeline.datedTaskCount || timeline.labelsWithVisibleText !== timeline.taskCount) addFailure("TaskTree: dated Gantt bars or labels are inconsistent");
|
|
const zoomIn = page.getByRole("button", { name: workflow.zoomInButtonName, exact: true });
|
|
if (await zoomIn.count() < 1) {
|
|
addFailure("TaskTree: timeline zoom control missing");
|
|
} else {
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
const scale = (await page.locator(workflow.scaleSelector).textContent().catch(() => ""))?.trim();
|
|
if (scale === workflow.secondsScaleLabel) break;
|
|
await zoomIn.click();
|
|
await wait(config.settleMs);
|
|
}
|
|
const zoomedTimeline = await page.evaluate((workflowConfig) => {
|
|
const scroll = document.querySelector(workflowConfig.scrollSelector);
|
|
if (!(scroll instanceof HTMLElement)) return null;
|
|
scroll.scrollLeft = Math.max(0, (scroll.scrollWidth - scroll.clientWidth) * 0.4);
|
|
scroll.dispatchEvent(new Event("scroll"));
|
|
return { clientWidth: scroll.clientWidth, scrollWidth: scroll.scrollWidth };
|
|
}, workflow);
|
|
await wait(config.settleMs);
|
|
const pinnedColumn = await page.evaluate((workflowConfig) => {
|
|
const scroll = document.querySelector(workflowConfig.scrollSelector);
|
|
const labelPane = document.querySelector(workflowConfig.labelPaneSelector);
|
|
const label = document.querySelector(workflowConfig.taskSelector);
|
|
const scale = document.querySelector(workflowConfig.scaleSelector)?.textContent?.trim() || "";
|
|
if (!(scroll instanceof HTMLElement) || !(labelPane instanceof HTMLElement) || !(label instanceof HTMLElement)) return null;
|
|
const labelPaneRect = labelPane.getBoundingClientRect();
|
|
const labelRect = label.getBoundingClientRect();
|
|
const sampleX = Math.min(labelRect.right - 4, labelRect.left + Math.max(12, labelRect.width / 2));
|
|
const sampleY = labelRect.top + labelRect.height / 2;
|
|
const topElement = document.elementFromPoint(sampleX, sampleY);
|
|
const tickLabels = [...document.querySelectorAll(workflowConfig.tickSelector)];
|
|
return {
|
|
scale,
|
|
labelLeftDelta: Math.round(labelRect.left - labelPaneRect.left),
|
|
labelOwnsHitPoint: Boolean(topElement && (topElement === label || label.contains(topElement))),
|
|
labelInsideScrollPane: scroll.contains(label),
|
|
labelTransform: getComputedStyle(label).transform,
|
|
tickLabelsFit: tickLabels.length > 0 && tickLabels.every((tick) => tick.scrollWidth <= tick.clientWidth + 1 && tick.scrollHeight <= tick.clientHeight + 1),
|
|
scrollLeft: Math.round(scroll.scrollLeft),
|
|
};
|
|
}, workflow);
|
|
if (!zoomedTimeline || zoomedTimeline.scrollWidth <= zoomedTimeline.clientWidth) addFailure("TaskTree: zoomed timeline is not horizontally scrollable");
|
|
if (!pinnedColumn || pinnedColumn.scale !== workflow.secondsScaleLabel) addFailure("TaskTree: timeline did not reach seconds scale");
|
|
if (!timeline.splitPane || !pinnedColumn || Math.abs(pinnedColumn.labelLeftDelta) > 2 || !pinnedColumn.labelOwnsHitPoint || pinnedColumn.labelInsideScrollPane || pinnedColumn.labelTransform !== "none") {
|
|
addFailure("TaskTree: task label is obscured after horizontal scrolling");
|
|
}
|
|
if (!pinnedColumn?.tickLabelsFit) addFailure("TaskTree: seconds-scale tick labels are clipped");
|
|
await captureScreenshot(workflow.zoomScreenshotName);
|
|
timeline.zoomed = { ...zoomedTimeline, ...pinnedColumn };
|
|
}
|
|
await firstTask.click();
|
|
await wait(config.settleMs);
|
|
const taskDeepLink = new URL(page.url()).pathname;
|
|
if (!taskDeepLink.startsWith(requiredRoute(workflow.routeId).path + "/")) addFailure("TaskTree: group deep link missing " + taskDeepLink);
|
|
const groupTask = page.locator(workflow.taskSelector).first();
|
|
await groupTask.waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
|
await groupTask.click();
|
|
await page.locator(workflow.detailSelector).waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
|
await wait(config.settleMs);
|
|
const detail = await page.evaluate((workflowConfig) => {
|
|
const detailPanel = document.querySelector(workflowConfig.detailSelector);
|
|
const dialog = document.querySelector(workflowConfig.dialogSelector);
|
|
const docked = document.querySelector(workflowConfig.dockedSelector);
|
|
const rect = detailPanel?.getBoundingClientRect();
|
|
return {
|
|
detailVisible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
|
detailInsideViewport: Boolean(rect && rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1),
|
|
detailMode: dialog ? "dialog" : docked ? "docked" : "unknown",
|
|
reportCount: detailPanel?.querySelectorAll(workflowConfig.reportSelector).length || 0,
|
|
};
|
|
}, workflow);
|
|
if (!detail.detailVisible || !detail.detailInsideViewport) addFailure("TaskTree: detail panel is not bounded by the viewport");
|
|
if (detail.detailMode === "unknown") addFailure("TaskTree: detail panel is neither dialog nor docked");
|
|
if (detail.reportCount < 1) addFailure("TaskTree: selected task has no execution report");
|
|
const artifact = await captureScreenshot(workflow.detailScreenshotName);
|
|
if (detail.detailMode === "dialog") await page.keyboard.press("Escape");
|
|
await reloadStable({ selectors: [requiredRoute(workflow.routeId).readySelector], readinessTimeoutMs: config.navigationTimeoutMs });
|
|
if (new URL(page.url()).pathname !== taskDeepLink) addFailure("TaskTree: group deep link did not survive refresh");
|
|
return { taskDeepLink, ...timeline, ...detail, detailScreenshotSha256: artifact?.sha256 || null };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
let selection = null;
|
|
if (workflow.selection) {
|
|
const candidate = page.locator(workflow.selection.selector).first();
|
|
if (await candidate.count() < 1) {
|
|
addFailure("AgentRun: selection target missing " + workflow.selection.selector);
|
|
} else {
|
|
await candidate.focus();
|
|
await page.keyboard.press("Enter");
|
|
await page.locator(workflow.selection.inspectorSelector).waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
|
await wait(config.settleMs);
|
|
const layout = await page.evaluate(({ inspectorSelector }) => {
|
|
const bounds = (selector) => {
|
|
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
|
return rect ? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height } : null;
|
|
};
|
|
const edges = Array.from(document.querySelectorAll(".agent-mind-map-flow .vue-flow__edge-path")).map((edge) => {
|
|
const style = getComputedStyle(edge);
|
|
const length = typeof edge.getTotalLength === "function" ? edge.getTotalLength() : 0;
|
|
const strokeWidth = Number.parseFloat(style.strokeWidth);
|
|
const opacity = Number.parseFloat(style.opacity);
|
|
const visible = length > 8
|
|
&& style.display !== "none"
|
|
&& style.visibility !== "hidden"
|
|
&& opacity > 0
|
|
&& strokeWidth >= 1
|
|
&& style.stroke !== "none"
|
|
&& style.stroke !== "rgba(0, 0, 0, 0)";
|
|
return { length, stroke: style.stroke, strokeWidth, opacity, display: style.display, visibility: style.visibility, visible };
|
|
});
|
|
const inspector = bounds(inspectorSelector);
|
|
const content = bounds(".agent-observer-content");
|
|
const header = bounds(inspectorSelector + " .operations-inspector-header");
|
|
const tabs = bounds(inspectorSelector + " .operations-inspector-tabs");
|
|
const body = bounds(inspectorSelector + " .operations-inspector-body");
|
|
return {
|
|
edgeCount: edges.length,
|
|
visibleEdgeCount: edges.filter((edge) => edge.visible).length,
|
|
edgeStyle: edges[0] || null,
|
|
inspector,
|
|
noWorkspaceOverlap: Boolean(content && inspector && content.right <= inspector.left + 1),
|
|
noInspectorChromeOverlap: Boolean(header && tabs && body && header.bottom <= tabs.top + 1 && tabs.bottom <= body.top + 1),
|
|
inspectorInsideViewport: Boolean(inspector && inspector.left >= 0 && inspector.right <= innerWidth + 1 && inspector.top >= 0 && inspector.bottom <= innerHeight + 1),
|
|
};
|
|
}, workflow.selection);
|
|
if (layout.visibleEdgeCount < 1) addFailure("AgentRun: mind map has no visible connected SVG edges");
|
|
if (!layout.noWorkspaceOverlap) addFailure("AgentRun: Inspector overlaps the workspace");
|
|
if (!layout.noInspectorChromeOverlap) addFailure("AgentRun: Inspector header, tabs and body overlap");
|
|
if (!layout.inspectorInsideViewport) addFailure("AgentRun: Inspector exceeds the viewport");
|
|
const artifact = await captureScreenshot(workflow.selection.screenshotName);
|
|
selection = { layout, 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, selection };
|
|
}
|
|
|
|
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);
|
|
}
|
|
`;
|
|
}
|