Files
pikasTech-unidesk/scripts/src/hwlab-node-web-probe-console-profile.ts
T
2026-07-18 13:56:20 +02:00

420 lines
21 KiB
TypeScript

// Responsibility: Typed YAML profile parsing for repo-owned Cloud Console browser closeout.
export interface HwlabRuntimeWebProbeConsoleViewportSpec {
readonly id: string;
readonly width: number;
readonly height: number;
}
export interface HwlabRuntimeWebProbeConsoleRouteSpec {
readonly id: string;
readonly path: string;
readonly readySelector: string;
readonly screenshot: boolean;
}
export interface HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec {
readonly routeId: string;
readonly listButtonName: string;
readonly viewQueryKey: string;
}
export interface HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec {
readonly devicesRouteId: string;
readonly nodesRouteId: string;
readonly onboardingRouteId: string;
readonly listButtonName: string;
readonly cardButtonName: string;
readonly devicesListSelector: string;
readonly devicesCardSelector: string;
readonly nodesListSelector: string;
readonly pcbTraceSelector: string;
readonly detailButtonName: string;
readonly detailPathPrefix: string;
readonly onboardingCommandSelector: string;
readonly requiredCommandPrefixes: readonly string[];
readonly downloadSelector: string;
readonly forbiddenDownloadSubstrings: readonly string[];
readonly forbiddenInternalSelector: string;
}
export interface HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec {
readonly routeId: string;
readonly taskSelector: string;
readonly taskDeepLinkSegment: string;
readonly taskDetailSelector: string;
readonly reportLinkSelector: string;
readonly reportPreviewSelector: string;
readonly fullscreenButtonSelector: string;
readonly fullscreenDialogSelector: string;
}
export interface HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec {
readonly routeId: string;
readonly taskSelector: string;
readonly barSelector: string;
readonly milestoneSelector: string;
readonly ganttSelector: string;
readonly scrollSelector: string;
readonly labelPaneSelector: string;
readonly scaleSelector: string;
readonly tickSelector: string;
readonly zoomInButtonName: string;
readonly secondsScaleLabel: string;
readonly zoomScreenshotName: string;
readonly detailSelector: string;
readonly dialogSelector: string;
readonly dockedSelector: string;
readonly reportSelector: string;
readonly detailScreenshotName: string;
}
export interface HwlabRuntimeWebProbeConsoleAgentViewSpec {
readonly buttonName: string;
readonly queryValue: string;
readonly screenshotName: string;
}
export interface HwlabRuntimeWebProbeConsoleAgentSelectionSpec {
readonly selector: string;
readonly inspectorSelector: string;
readonly screenshotName: string;
}
export interface HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec {
readonly routeId: string;
readonly eventSourcePath: string;
readonly phaseSelector: string;
readonly blockedPhases: readonly string[];
readonly streamSettleMs: number;
readonly views: readonly HwlabRuntimeWebProbeConsoleAgentViewSpec[];
readonly selection?: HwlabRuntimeWebProbeConsoleAgentSelectionSpec;
}
export interface HwlabRuntimeWebProbeConsoleVerificationProfileSpec {
readonly navigationTimeoutMs: number;
readonly settleMs: number;
readonly commandTimeoutSeconds: number;
readonly minTextLength: number;
readonly maxHorizontalOverflowPx: number;
readonly outputLimits: {
readonly failures: number;
readonly pageErrors: number;
readonly consoleErrors: number;
readonly requestFailures: number;
readonly responseErrors: number;
readonly screenshots: number;
readonly textPreviewChars: number;
};
readonly viewports: readonly HwlabRuntimeWebProbeConsoleViewportSpec[];
readonly routes: readonly HwlabRuntimeWebProbeConsoleRouteSpec[];
readonly workflows: {
readonly projects?: HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec;
readonly hwpod?: HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec;
readonly mdtodo?: HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec;
readonly tasktree?: HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec;
readonly agentObserver?: HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec;
};
}
export function parseWebProbeConsoleVerificationProfiles(
value: unknown,
path: string,
): Readonly<Record<string, HwlabRuntimeWebProbeConsoleVerificationProfileSpec>> {
const raw = record(value, path);
const entries = Object.entries(raw);
if (entries.length < 1 || entries.length > 20) throw new Error(`${path} must contain 1-20 profiles`);
return Object.fromEntries(entries.map(([name, profile]) => {
simpleId(name, `${path}.${name}`);
return [name, consoleProfile(profile, `${path}.${name}`)];
}));
}
function consoleProfile(value: unknown, path: string): HwlabRuntimeWebProbeConsoleVerificationProfileSpec {
const raw = record(value, path);
onlyKeys(raw, [
"navigationTimeoutMs",
"settleMs",
"commandTimeoutSeconds",
"minTextLength",
"maxHorizontalOverflowPx",
"outputLimits",
"viewports",
"routes",
"workflows",
], path);
const viewports = array(raw.viewports, `${path}.viewports`, 1, 8).map((item, index) => viewport(item, `${path}.viewports[${index}]`));
const routes = array(raw.routes, `${path}.routes`, 1, 100).map((item, index) => route(item, `${path}.routes[${index}]`));
unique(viewports.map((item) => item.id), `${path}.viewports[].id`);
unique(routes.map((item) => item.id), `${path}.routes[].id`);
unique(routes.map((item) => item.path), `${path}.routes[].path`);
const routeIds = new Set(routes.map((item) => item.id));
const workflows = workflowConfig(raw.workflows, `${path}.workflows`, routeIds);
const output = record(raw.outputLimits, `${path}.outputLimits`);
onlyKeys(output, [
"failures", "pageErrors", "consoleErrors", "requestFailures", "responseErrors", "screenshots", "textPreviewChars",
], `${path}.outputLimits`);
const screenshotBudget = integer(output.screenshots, `${path}.outputLimits.screenshots`, 1, 1000);
const matrixScreenshots = routes.filter((item) => item.screenshot).length * viewports.length;
const workflowScreenshots = (workflows.agentObserver?.views.length ?? 0)
+ (workflows.agentObserver?.selection === undefined ? 0 : 1)
+ (workflows.tasktree === undefined ? 0 : 1);
if (matrixScreenshots + workflowScreenshots > screenshotBudget) {
throw new Error(`${path}.outputLimits.screenshots=${screenshotBudget} is below the declared matrix/workflow screenshot count ${matrixScreenshots + workflowScreenshots}`);
}
return {
navigationTimeoutMs: integer(raw.navigationTimeoutMs, `${path}.navigationTimeoutMs`, 1000, 120_000),
settleMs: integer(raw.settleMs, `${path}.settleMs`, 0, 30_000),
commandTimeoutSeconds: integer(raw.commandTimeoutSeconds, `${path}.commandTimeoutSeconds`, 60, 3600),
minTextLength: integer(raw.minTextLength, `${path}.minTextLength`, 1, 100_000),
maxHorizontalOverflowPx: integer(raw.maxHorizontalOverflowPx, `${path}.maxHorizontalOverflowPx`, 0, 1000),
outputLimits: {
failures: integer(output.failures, `${path}.outputLimits.failures`, 1, 1000),
pageErrors: integer(output.pageErrors, `${path}.outputLimits.pageErrors`, 1, 1000),
consoleErrors: integer(output.consoleErrors, `${path}.outputLimits.consoleErrors`, 1, 1000),
requestFailures: integer(output.requestFailures, `${path}.outputLimits.requestFailures`, 1, 1000),
responseErrors: integer(output.responseErrors, `${path}.outputLimits.responseErrors`, 1, 1000),
screenshots: screenshotBudget,
textPreviewChars: integer(output.textPreviewChars, `${path}.outputLimits.textPreviewChars`, 20, 2000),
},
viewports,
routes,
workflows,
};
}
function viewport(value: unknown, path: string): HwlabRuntimeWebProbeConsoleViewportSpec {
const raw = record(value, path);
onlyKeys(raw, ["id", "width", "height"], path);
return {
id: simpleId(text(raw.id, `${path}.id`), `${path}.id`),
width: integer(raw.width, `${path}.width`, 240, 7680),
height: integer(raw.height, `${path}.height`, 240, 4320),
};
}
function route(value: unknown, path: string): HwlabRuntimeWebProbeConsoleRouteSpec {
const raw = record(value, path);
onlyKeys(raw, ["id", "path", "readySelector", "screenshot"], path);
return {
id: simpleId(text(raw.id, `${path}.id`), `${path}.id`),
path: absolutePath(text(raw.path, `${path}.path`), `${path}.path`),
readySelector: boundedText(raw.readySelector, `${path}.readySelector`, 500),
screenshot: boolean(raw.screenshot, `${path}.screenshot`),
};
}
function workflowConfig(
value: unknown,
path: string,
routeIds: ReadonlySet<string>,
): HwlabRuntimeWebProbeConsoleVerificationProfileSpec["workflows"] {
const raw = record(value, path);
onlyKeys(raw, ["projects", "hwpod", "mdtodo", "tasktree", "agentObserver"], path);
return {
...(raw.projects === undefined ? {} : { projects: projectsWorkflow(raw.projects, `${path}.projects`, routeIds) }),
...(raw.hwpod === undefined ? {} : { hwpod: hwpodWorkflow(raw.hwpod, `${path}.hwpod`, routeIds) }),
...(raw.mdtodo === undefined ? {} : { mdtodo: mdtodoWorkflow(raw.mdtodo, `${path}.mdtodo`, routeIds) }),
...(raw.tasktree === undefined ? {} : { tasktree: tasktreeWorkflow(raw.tasktree, `${path}.tasktree`, routeIds) }),
...(raw.agentObserver === undefined ? {} : { agentObserver: agentObserverWorkflow(raw.agentObserver, `${path}.agentObserver`, routeIds) }),
};
}
function tasktreeWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, [
"routeId", "taskSelector", "barSelector", "milestoneSelector", "ganttSelector",
"scrollSelector", "labelPaneSelector",
"scaleSelector", "tickSelector", "zoomInButtonName", "secondsScaleLabel", "zoomScreenshotName",
"detailSelector", "dialogSelector", "dockedSelector", "reportSelector", "detailScreenshotName",
], path);
const detailScreenshotName = boundedText(raw.detailScreenshotName, `${path}.detailScreenshotName`, 120);
if (!/^[A-Za-z0-9._-]+\.png$/u.test(detailScreenshotName)) throw new Error(`${path}.detailScreenshotName must be a safe PNG filename`);
const zoomScreenshotName = boundedText(raw.zoomScreenshotName, `${path}.zoomScreenshotName`, 120);
if (!/^[A-Za-z0-9._-]+\.png$/u.test(zoomScreenshotName)) throw new Error(`${path}.zoomScreenshotName must be a safe PNG filename`);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
taskSelector: boundedText(raw.taskSelector, `${path}.taskSelector`, 500),
barSelector: boundedText(raw.barSelector, `${path}.barSelector`, 500),
milestoneSelector: boundedText(raw.milestoneSelector, `${path}.milestoneSelector`, 500),
ganttSelector: boundedText(raw.ganttSelector, `${path}.ganttSelector`, 500),
scrollSelector: boundedText(raw.scrollSelector, `${path}.scrollSelector`, 500),
labelPaneSelector: boundedText(raw.labelPaneSelector, `${path}.labelPaneSelector`, 500),
scaleSelector: boundedText(raw.scaleSelector, `${path}.scaleSelector`, 500),
tickSelector: boundedText(raw.tickSelector, `${path}.tickSelector`, 500),
zoomInButtonName: boundedText(raw.zoomInButtonName, `${path}.zoomInButtonName`, 120),
secondsScaleLabel: boundedText(raw.secondsScaleLabel, `${path}.secondsScaleLabel`, 40),
zoomScreenshotName,
detailSelector: boundedText(raw.detailSelector, `${path}.detailSelector`, 500),
dialogSelector: boundedText(raw.dialogSelector, `${path}.dialogSelector`, 500),
dockedSelector: boundedText(raw.dockedSelector, `${path}.dockedSelector`, 500),
reportSelector: boundedText(raw.reportSelector, `${path}.reportSelector`, 500),
detailScreenshotName,
};
}
function projectsWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleProjectsWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, ["routeId", "listButtonName", "viewQueryKey"], path);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
listButtonName: boundedText(raw.listButtonName, `${path}.listButtonName`, 100),
viewQueryKey: simpleId(text(raw.viewQueryKey, `${path}.viewQueryKey`), `${path}.viewQueryKey`),
};
}
function hwpodWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleHwpodWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, [
"devicesRouteId", "nodesRouteId", "onboardingRouteId", "listButtonName", "cardButtonName",
"devicesListSelector", "devicesCardSelector", "nodesListSelector", "pcbTraceSelector", "detailButtonName",
"detailPathPrefix", "onboardingCommandSelector", "requiredCommandPrefixes", "downloadSelector",
"forbiddenDownloadSubstrings", "forbiddenInternalSelector",
], path);
return {
devicesRouteId: routeReference(raw.devicesRouteId, `${path}.devicesRouteId`, routeIds),
nodesRouteId: routeReference(raw.nodesRouteId, `${path}.nodesRouteId`, routeIds),
onboardingRouteId: routeReference(raw.onboardingRouteId, `${path}.onboardingRouteId`, routeIds),
listButtonName: boundedText(raw.listButtonName, `${path}.listButtonName`, 100),
cardButtonName: boundedText(raw.cardButtonName, `${path}.cardButtonName`, 100),
devicesListSelector: boundedText(raw.devicesListSelector, `${path}.devicesListSelector`, 500),
devicesCardSelector: boundedText(raw.devicesCardSelector, `${path}.devicesCardSelector`, 500),
nodesListSelector: boundedText(raw.nodesListSelector, `${path}.nodesListSelector`, 500),
pcbTraceSelector: boundedText(raw.pcbTraceSelector, `${path}.pcbTraceSelector`, 500),
detailButtonName: boundedText(raw.detailButtonName, `${path}.detailButtonName`, 100),
detailPathPrefix: absolutePath(text(raw.detailPathPrefix, `${path}.detailPathPrefix`), `${path}.detailPathPrefix`),
onboardingCommandSelector: boundedText(raw.onboardingCommandSelector, `${path}.onboardingCommandSelector`, 500),
requiredCommandPrefixes: textArray(raw.requiredCommandPrefixes, `${path}.requiredCommandPrefixes`, 1, 20, 300),
downloadSelector: boundedText(raw.downloadSelector, `${path}.downloadSelector`, 500),
forbiddenDownloadSubstrings: textArray(raw.forbiddenDownloadSubstrings, `${path}.forbiddenDownloadSubstrings`, 1, 20, 300),
forbiddenInternalSelector: boundedText(raw.forbiddenInternalSelector, `${path}.forbiddenInternalSelector`, 500),
};
}
function mdtodoWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleMdtodoWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, [
"routeId", "taskSelector", "taskDeepLinkSegment", "taskDetailSelector", "reportLinkSelector",
"reportPreviewSelector", "fullscreenButtonSelector", "fullscreenDialogSelector",
], path);
const segment = boundedText(raw.taskDeepLinkSegment, `${path}.taskDeepLinkSegment`, 100);
if (!segment.startsWith("/")) throw new Error(`${path}.taskDeepLinkSegment must start with /`);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
taskSelector: boundedText(raw.taskSelector, `${path}.taskSelector`, 500),
taskDeepLinkSegment: segment,
taskDetailSelector: boundedText(raw.taskDetailSelector, `${path}.taskDetailSelector`, 500),
reportLinkSelector: boundedText(raw.reportLinkSelector, `${path}.reportLinkSelector`, 500),
reportPreviewSelector: boundedText(raw.reportPreviewSelector, `${path}.reportPreviewSelector`, 500),
fullscreenButtonSelector: boundedText(raw.fullscreenButtonSelector, `${path}.fullscreenButtonSelector`, 500),
fullscreenDialogSelector: boundedText(raw.fullscreenDialogSelector, `${path}.fullscreenDialogSelector`, 500),
};
}
function agentObserverWorkflow(value: unknown, path: string, routeIds: ReadonlySet<string>): HwlabRuntimeWebProbeConsoleAgentObserverWorkflowSpec {
const raw = record(value, path);
onlyKeys(raw, ["routeId", "eventSourcePath", "phaseSelector", "blockedPhases", "streamSettleMs", "views", "selection"], path);
const views = array(raw.views, `${path}.views`, 1, 10).map((item, index) => agentView(item, `${path}.views[${index}]`));
const selection = raw.selection === undefined ? undefined : agentSelection(raw.selection, `${path}.selection`);
unique(views.map((item) => item.queryValue), `${path}.views[].queryValue`);
unique([...views.map((item) => item.screenshotName), ...(selection === undefined ? [] : [selection.screenshotName])], `${path}.screenshots`);
return {
routeId: routeReference(raw.routeId, `${path}.routeId`, routeIds),
eventSourcePath: absolutePath(text(raw.eventSourcePath, `${path}.eventSourcePath`), `${path}.eventSourcePath`),
phaseSelector: boundedText(raw.phaseSelector, `${path}.phaseSelector`, 500),
blockedPhases: textArray(raw.blockedPhases, `${path}.blockedPhases`, 1, 20, 100),
streamSettleMs: integer(raw.streamSettleMs, `${path}.streamSettleMs`, 0, 120_000),
views,
...(selection === undefined ? {} : { selection }),
};
}
function agentSelection(value: unknown, path: string): HwlabRuntimeWebProbeConsoleAgentSelectionSpec {
const raw = record(value, path);
onlyKeys(raw, ["selector", "inspectorSelector", "screenshotName"], path);
const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120);
if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`);
return {
selector: boundedText(raw.selector, `${path}.selector`, 500),
inspectorSelector: boundedText(raw.inspectorSelector, `${path}.inspectorSelector`, 500),
screenshotName,
};
}
function agentView(value: unknown, path: string): HwlabRuntimeWebProbeConsoleAgentViewSpec {
const raw = record(value, path);
onlyKeys(raw, ["buttonName", "queryValue", "screenshotName"], path);
const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120);
if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`);
return {
buttonName: boundedText(raw.buttonName, `${path}.buttonName`, 100),
queryValue: simpleId(text(raw.queryValue, `${path}.queryValue`), `${path}.queryValue`),
screenshotName,
};
}
function routeReference(value: unknown, path: string, routeIds: ReadonlySet<string>): string {
const id = simpleId(text(value, path), path);
if (!routeIds.has(id)) throw new Error(`${path} references unknown route ${id}`);
return id;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function array(value: unknown, path: string, min: number, max: number): unknown[] {
if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error(`${path} must contain ${min}-${max} items`);
return value;
}
function text(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) throw new Error(`${path} must be a non-empty string`);
return value;
}
function boundedText(value: unknown, path: string, max: number): string {
const result = text(value, path);
if (result.length > max) throw new Error(`${path} must be at most ${max} chars`);
return result;
}
function textArray(value: unknown, path: string, min: number, max: number, maxChars: number): readonly string[] {
return array(value, path, min, max).map((item, index) => boundedText(item, `${path}[${index}]`, maxChars));
}
function simpleId(value: string, path: string): string {
if (!/^[A-Za-z0-9._-]+$/u.test(value) || value.length > 100) throw new Error(`${path} must be a simple id up to 100 chars`);
return value;
}
function absolutePath(value: string, path: string): string {
if (!value.startsWith("/") || value.length > 300) throw new Error(`${path} must be an absolute path up to 300 chars`);
return value;
}
function integer(value: unknown, path: string, min: number, max: number): number {
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) throw new Error(`${path} must be an integer ${min}-${max}`);
return value as number;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} must be boolean`);
return value;
}
function onlyKeys(raw: Record<string, unknown>, allowed: readonly string[], path: string): void {
const allow = new Set(allowed);
const unknown = Object.keys(raw).filter((key) => !allow.has(key));
if (unknown.length > 0) throw new Error(`${path} has unsupported fields: ${unknown.join(", ")}`);
}
function unique(values: readonly string[], path: string): void {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) throw new Error(`${path} contains duplicate ${value}`);
seen.add(value);
}
}