|
|
|
@@ -9,6 +9,18 @@ export interface HwlabRuntimeWebProbeNativeReadinessProfileSpec {
|
|
|
|
|
readonly viewport: { readonly width: number; readonly height: number };
|
|
|
|
|
readonly readySelectors: readonly string[];
|
|
|
|
|
readonly minBodyTextLength: number;
|
|
|
|
|
readonly contentReadiness?: {
|
|
|
|
|
readonly settledSelector: string;
|
|
|
|
|
readonly itemSelector: string;
|
|
|
|
|
readonly selectedSelector: string;
|
|
|
|
|
readonly contentSelector: string;
|
|
|
|
|
readonly timeoutMs: number;
|
|
|
|
|
};
|
|
|
|
|
readonly geometryChecks?: readonly {
|
|
|
|
|
readonly selector: string;
|
|
|
|
|
readonly maxHeightPx: number;
|
|
|
|
|
readonly required: boolean;
|
|
|
|
|
}[];
|
|
|
|
|
readonly interaction: {
|
|
|
|
|
readonly selector: string;
|
|
|
|
|
readonly observedSelector: string;
|
|
|
|
@@ -93,6 +105,19 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js
|
|
|
|
|
|
|
|
|
|
await page.setViewportSize(config.viewport);
|
|
|
|
|
await goto(config.path, { selectors: config.readySelectors, readinessTimeoutMs: config.navigationTimeoutMs, attempts: 2 });
|
|
|
|
|
let contentReadiness = null;
|
|
|
|
|
if (config.contentReadiness) {
|
|
|
|
|
const content = config.contentReadiness;
|
|
|
|
|
await page.locator(content.settledSelector).waitFor({ state: "visible", timeout: content.timeoutMs });
|
|
|
|
|
const item = page.locator(content.itemSelector).first();
|
|
|
|
|
const itemCount = await page.locator(content.itemSelector).count();
|
|
|
|
|
if (itemCount > 0) {
|
|
|
|
|
await item.click({ timeout: content.timeoutMs });
|
|
|
|
|
await page.locator(content.selectedSelector).first().waitFor({ state: "visible", timeout: content.timeoutMs });
|
|
|
|
|
await page.locator(content.contentSelector).first().waitFor({ state: "visible", timeout: content.timeoutMs });
|
|
|
|
|
}
|
|
|
|
|
contentReadiness = { settled: true, itemCount, selected: itemCount > 0 };
|
|
|
|
|
}
|
|
|
|
|
if (config.settleMs > 0) await wait(config.settleMs);
|
|
|
|
|
const before = await page.locator(config.interaction.observedSelector).getAttribute(config.interaction.observedAttribute);
|
|
|
|
|
const mounted = await page.evaluate(({ selectors, minBodyTextLength, textPreviewChars, layout }) => {
|
|
|
|
@@ -139,10 +164,23 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js
|
|
|
|
|
if (config.settleMs > 0) await wait(config.settleMs);
|
|
|
|
|
const after = await page.locator(config.interaction.observedSelector).getAttribute(config.interaction.observedAttribute);
|
|
|
|
|
const interactionChanged = before !== after;
|
|
|
|
|
const geometryChecks = await page.evaluate((checks) => checks.map((check) => {
|
|
|
|
|
const heights = [...document.querySelectorAll(check.selector)].map((element) => element.getBoundingClientRect().height);
|
|
|
|
|
const maxHeight = heights.length > 0 ? Math.max(...heights) : null;
|
|
|
|
|
return {
|
|
|
|
|
selector: check.selector,
|
|
|
|
|
required: check.required,
|
|
|
|
|
matched: heights.length,
|
|
|
|
|
maxHeight,
|
|
|
|
|
maxHeightPx: check.maxHeightPx,
|
|
|
|
|
ok: heights.length === 0 ? !check.required : maxHeight <= check.maxHeightPx,
|
|
|
|
|
};
|
|
|
|
|
}), config.geometryChecks || []);
|
|
|
|
|
const geometryReady = geometryChecks.every((check) => check.ok);
|
|
|
|
|
const screenshotArtifact = await screenshot(config.screenshotName);
|
|
|
|
|
const selectorsReady = mounted.selectorsVisible.every((item) => item.visible);
|
|
|
|
|
const layoutReady = mounted.layout.noDocumentOverflow && mounted.layout.noMainContentOverflow && mounted.layout.internalScrollPanesReady;
|
|
|
|
|
const ok = selectorsReady && mounted.bodyTextSufficient && interactionChanged && layoutReady
|
|
|
|
|
const ok = selectorsReady && mounted.bodyTextSufficient && interactionChanged && layoutReady && geometryReady
|
|
|
|
|
&& consoleErrorCount === 0 && pageErrorCount === 0 && failedResponseCount === 0;
|
|
|
|
|
const evidence = {
|
|
|
|
|
ok,
|
|
|
|
@@ -150,7 +188,9 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js
|
|
|
|
|
profileName: config.profileName,
|
|
|
|
|
configRef: config.configRef,
|
|
|
|
|
mounted,
|
|
|
|
|
contentReadiness,
|
|
|
|
|
interaction: { selector: config.interaction.selector, observedSelector: config.interaction.observedSelector, observedAttribute: config.interaction.observedAttribute, before, after, changed: interactionChanged },
|
|
|
|
|
geometryChecks,
|
|
|
|
|
consoleErrorCount,
|
|
|
|
|
consoleErrors,
|
|
|
|
|
pageErrorCount,
|
|
|
|
@@ -161,7 +201,7 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
const report = await jsonArtifact("native-web-readiness.json", evidence);
|
|
|
|
|
recordStep("native-web-readiness", { ok, selectorsReady, bodyTextSufficient: mounted.bodyTextSufficient, interactionChanged, layoutReady, consoleErrorCount, pageErrorCount, failedResponseCount });
|
|
|
|
|
recordStep("native-web-readiness", { ok, selectorsReady, bodyTextSufficient: mounted.bodyTextSufficient, contentReadiness, interactionChanged, layoutReady, geometryReady, consoleErrorCount, pageErrorCount, failedResponseCount });
|
|
|
|
|
return { ...evidence, report };
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
@@ -169,7 +209,7 @@ export default async function nativeReadiness({ page, goto, wait, screenshot, js
|
|
|
|
|
|
|
|
|
|
function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebProbeNativeReadinessProfileSpec {
|
|
|
|
|
const raw = object(value, path);
|
|
|
|
|
onlyKeys(raw, ["application", "path", "navigationTimeoutMs", "settleMs", "commandTimeoutSeconds", "viewport", "readySelectors", "minBodyTextLength", "interaction", "failedResponseStatus", "criticalPathPrefixes", "screenshotName", "outputLimits", "layout"], path);
|
|
|
|
|
onlyKeys(raw, ["application", "path", "navigationTimeoutMs", "settleMs", "commandTimeoutSeconds", "viewport", "readySelectors", "minBodyTextLength", "contentReadiness", "geometryChecks", "interaction", "failedResponseStatus", "criticalPathPrefixes", "screenshotName", "outputLimits", "layout"], path);
|
|
|
|
|
const viewport = object(raw.viewport, `${path}.viewport`);
|
|
|
|
|
onlyKeys(viewport, ["width", "height"], `${path}.viewport`);
|
|
|
|
|
const interaction = object(raw.interaction, `${path}.interaction`);
|
|
|
|
@@ -177,6 +217,8 @@ function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebPr
|
|
|
|
|
const limits = object(raw.outputLimits, `${path}.outputLimits`);
|
|
|
|
|
onlyKeys(limits, ["errors", "failedResponses", "textPreviewChars"], `${path}.outputLimits`);
|
|
|
|
|
const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120);
|
|
|
|
|
const contentReadiness = raw.contentReadiness === undefined ? undefined : nativeReadinessContent(raw.contentReadiness, `${path}.contentReadiness`);
|
|
|
|
|
const geometryChecks = raw.geometryChecks === undefined ? undefined : nativeReadinessGeometryChecks(raw.geometryChecks, `${path}.geometryChecks`);
|
|
|
|
|
const layout = raw.layout === undefined ? undefined : nativeReadinessLayout(raw.layout, `${path}.layout`);
|
|
|
|
|
if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`);
|
|
|
|
|
return {
|
|
|
|
@@ -188,6 +230,8 @@ function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebPr
|
|
|
|
|
viewport: { width: integer(viewport.width, `${path}.viewport.width`, 240, 7680), height: integer(viewport.height, `${path}.viewport.height`, 240, 4320) },
|
|
|
|
|
readySelectors: textArray(raw.readySelectors, `${path}.readySelectors`, 1, 20, 500),
|
|
|
|
|
minBodyTextLength: integer(raw.minBodyTextLength, `${path}.minBodyTextLength`, 1, 100_000),
|
|
|
|
|
...(contentReadiness === undefined ? {} : { contentReadiness }),
|
|
|
|
|
...(geometryChecks === undefined ? {} : { geometryChecks }),
|
|
|
|
|
interaction: {
|
|
|
|
|
selector: boundedText(interaction.selector, `${path}.interaction.selector`, 500),
|
|
|
|
|
observedSelector: boundedText(interaction.observedSelector, `${path}.interaction.observedSelector`, 500),
|
|
|
|
@@ -201,6 +245,32 @@ function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebPr
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nativeReadinessContent(value: unknown, path: string): NonNullable<HwlabRuntimeWebProbeNativeReadinessProfileSpec["contentReadiness"]> {
|
|
|
|
|
const raw = object(value, path);
|
|
|
|
|
onlyKeys(raw, ["settledSelector", "itemSelector", "selectedSelector", "contentSelector", "timeoutMs"], path);
|
|
|
|
|
return {
|
|
|
|
|
settledSelector: boundedText(raw.settledSelector, `${path}.settledSelector`, 500),
|
|
|
|
|
itemSelector: boundedText(raw.itemSelector, `${path}.itemSelector`, 500),
|
|
|
|
|
selectedSelector: boundedText(raw.selectedSelector, `${path}.selectedSelector`, 500),
|
|
|
|
|
contentSelector: boundedText(raw.contentSelector, `${path}.contentSelector`, 500),
|
|
|
|
|
timeoutMs: integer(raw.timeoutMs, `${path}.timeoutMs`, 1000, 120_000),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nativeReadinessGeometryChecks(value: unknown, path: string): NonNullable<HwlabRuntimeWebProbeNativeReadinessProfileSpec["geometryChecks"]> {
|
|
|
|
|
if (!Array.isArray(value) || value.length < 1 || value.length > 20) throw new Error(`${path} must contain 1-20 items`);
|
|
|
|
|
return value.map((item, index) => {
|
|
|
|
|
const itemPath = `${path}[${index}]`;
|
|
|
|
|
const raw = object(item, itemPath);
|
|
|
|
|
onlyKeys(raw, ["selector", "maxHeightPx", "required"], itemPath);
|
|
|
|
|
return {
|
|
|
|
|
selector: boundedText(raw.selector, `${itemPath}.selector`, 500),
|
|
|
|
|
maxHeightPx: integer(raw.maxHeightPx, `${itemPath}.maxHeightPx`, 1, 4320),
|
|
|
|
|
required: boolean(raw.required, `${itemPath}.required`),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nativeReadinessLayout(value: unknown, path: string): NonNullable<HwlabRuntimeWebProbeNativeReadinessProfileSpec["layout"]> {
|
|
|
|
|
const raw = object(value, path);
|
|
|
|
|
onlyKeys(raw, ["requireNoDocumentOverflow", "requireNoMainContentOverflow", "mainContentSelector", "internalScrollSelectors"], path);
|
|
|
|
|