import type { Page, Locator, TestInfo } from '@playwright/test' import path from 'path' import { PROJECT_LOADING_SELECTORS } from './evidence-helper.config' /** * Shared evidence helpers for generated Playwright specs (execute-flow / script-sync skills). * Copied ONCE to AK-Docs/03.Testing/05.Scripts/Shared/evidence-helper.ts — safe to re-sync from * this template on every ai-flow-kit update, because this file carries no project-specific * knowledge. Any app-specific loading/spinner convention belongs in the sibling * `evidence-helper.config.ts` instead — that file is scaffolded once and never overwritten, so * hand-added project selectors survive every future re-sync of this file. */ /** * Generic, technology-agnostic "still loading" selectors — safe to ship for every stack because * they rely on a web standard (ARIA) or conventions common enough to be near-universal, never on * one specific UI kit. This is only the fast-path layer; `waitForUiSettled()` below backs it up * with framework-agnostic animation/DOM/pixel checks so a missing selector here doesn't matter. * App-specific conventions (a UI kit's overlay class, a bespoke spinner component, ...) belong in * `evidence-helper.config.ts`, not here. */ const LOADING_SELECTORS = [ '[aria-busy="true"]', // WAI-ARIA standard busy flag — the most reliable cross-framework signal '[data-loading="true"]', '[data-testid*="loading" i]', '.spinner', '.loading', '.skeleton', '[role="progressbar"]', ...PROJECT_LOADING_SELECTORS, ] /** * Waits until any running CSS animation/transition finishes — catches spinners, skeleton * shimmer, and pulse effects regardless of framework, since virtually all of them animate via * the Web Animations API under the hood. Bounded by its own (short) timeout so a page with an * unrelated decorative infinite animation (e.g. a blinking badge) can't stall evidence capture. */ async function waitForAnimationsToFinish(page: Page, timeoutMs = 5_000): Promise { await page .waitForFunction(() => document.getAnimations().every((a) => a.playState !== 'running'), undefined, { timeout: timeoutMs, }) .catch(() => {}) } /** * Waits until the DOM stops mutating for `quietMs` straight — catches loading indicators that * get removed/replaced without an accompanying CSS animation (e.g. a "Loading..." text node * swapped for real content, rows appended after a fetch resolves). Bounded by `timeoutMs` so a * page with unrelated continuous DOM churn (polling widget, live clock) still resolves. */ async function waitForDomStable(page: Page, quietMs = 400, timeoutMs = 5_000): Promise { await page .evaluate( ({ quietMs, timeoutMs }) => new Promise((resolve) => { let quietTimer: ReturnType const hardStop = setTimeout(() => { observer.disconnect() resolve() }, timeoutMs) const settle = () => { clearTimeout(quietTimer) quietTimer = setTimeout(() => { clearTimeout(hardStop) observer.disconnect() resolve() }, quietMs) } const observer = new MutationObserver(settle) observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }) settle() }), { quietMs, timeoutMs }, ) .catch(() => {}) } /** * Last-resort, truly stack-agnostic layer: waits until consecutive viewport screenshots come * back pixel-identical. Catches whatever the selector/animation/DOM-mutation checks above miss — * canvas/WebGL loaders, animated GIFs/APNGs, sprite-sheet spinners driven by `background-position` * instead of a CSS animation, or a plain server-rendered page (Java/JSP, PHP, classic ASP.NET, ...) * whose "loading" is just a slow synchronous render with no DOM/ARIA hook to watch at all. No app * knowledge required — it only looks at rendered pixels, so it behaves identically whether the * page is React, Vue, Angular, or server-rendered Java. Cheap viewport-only screenshots (not * `fullPage`) keep the polling loop fast. */ async function waitForVisuallyStable( page: Page, { intervalMs = 200, stableFrames = 2, timeoutMs = 6_000, }: { intervalMs?: number; stableFrames?: number; timeoutMs?: number } = {}, ): Promise { const deadline = Date.now() + timeoutMs // Typed off `page.screenshot()`'s own return type instead of the global `Buffer` type, so this // file compiles without requiring the consuming project to have `@types/node` installed. let previous: Awaited> | null = null let consecutiveMatches = 0 while (Date.now() < deadline) { const current = await page.screenshot({ type: 'png' }).catch(() => null) if (current && previous && current.equals(previous)) { consecutiveMatches++ if (consecutiveMatches >= stableFrames) return } else { consecutiveMatches = 0 } previous = current await page.waitForTimeout(intervalMs) } } export interface WaitForUiSettledOptions { /** * Turn on the pixel-diff stability pass (layer 5) — OFF by default. It is the most expensive * layer (polls extra screenshots, up to several seconds per call) and layers 1-4 already * resolve the vast majority of pages, so leaving it on unconditionally silently inflates every * `captureStepEvidence()` call — a test with many steps in a loop (e.g. asserting N sortable * columns) can then exceed Playwright's default 30s test timeout with no error in app logic at * all. Turn it on per-call for a screen known to use a loader none of layers 1-4 catch (canvas/ * WebGL spinner, animated GIF/APNG, `background-position` sprite animation, or a plain * server-rendered page with no DOM/ARIA hook) — and raise that spec's Playwright `timeout` to * cover the extra cost. */ enableVisualStability?: boolean } /** * Waits until the page is visually stable before a screenshot is taken, layered from cheapest/ * most specific to most expensive/most general: * * 1. Network idle + no known loading indicator visible (ARIA + common conventions, selector fast-path) * 2. No CSS animation/transition still running (Web Animations API — framework-agnostic) * 3. DOM stopped mutating for a short quiet period (MutationObserver — framework-agnostic) * 4. Web fonts finished loading (prevents blurry / tofu-box Kanji/Katakana text) * 5. **Opt-in** (`enableVisualStability: true`) — consecutive rendered frames pixel-identical, * catching anything layers 1-4 miss on any tech stack; off by default, see * `WaitForUiSettledOptions.enableVisualStability`. * * Layers 2-5 need no knowledge of the app's loading-spinner class name, unlike layer 1 alone — * this is what makes the helper work across React, Vue, Angular, or plain server-rendered * Java/PHP pages without maintaining a per-framework selector list. */ export async function waitForUiSettled( page: Page, timeoutMs = 15_000, opts: WaitForUiSettledOptions = {}, ): Promise { await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}) await page.waitForFunction( (selectors) => selectors.every((sel) => { const el = document.querySelector(sel) if (!el) return true const style = window.getComputedStyle(el) return style.display === 'none' || style.visibility === 'hidden' }), LOADING_SELECTORS, { timeout: timeoutMs }, ).catch(() => {}) await waitForAnimationsToFinish(page) await waitForDomStable(page) await page.evaluate(() => (document as any).fonts?.ready).catch(() => {}) if (opts.enableVisualStability) { await waitForVisuallyStable(page) } } /** Lowercases, strips diacritics, and hyphenates — shared by folder and screenshot-file naming. */ function slugify(text: string): string { return text .toLowerCase() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') } /** * Derives the canonical `{TC_ID}-{kebab-scenario}` evidence folder name straight from the test's * own title, instead of trusting Playwright's auto-generated per-test `testInfo.outputDir`. * That built-in naming sanitizes/truncates/hashes the full describe+test title for collision * avoidance — undocumented, Playwright-version-dependent, and easily tipped into a different * (hashed, less readable) shape by heavy non-ASCII content (Vietnamese/Japanese titles) or by a * describe-block name that overlaps the spec file's own path segments. Two runs of the exact same * suite have been observed producing different folder names for the same TC for this reason, * which breaks anything that links to a `{TC_ID}-{kebab-scenario}/` path (result.md, bug reports, * RETEST evidence lookup). Relies on the mandated `test('{TC_ID} - {Test Case Name}', ...)` * naming convention (see script-sync SKILL.md) — splits on the first ' - ' rather than guessing. */ function tcFolderName(testInfo: TestInfo): string { const separatorIndex = testInfo.title.indexOf(' - ') if (separatorIndex === -1) return slugify(testInfo.title) const tcId = testInfo.title.slice(0, separatorIndex) const scenario = testInfo.title.slice(separatorIndex + 3) return `${tcId}-${slugify(scenario)}` } /** * Resolves the evidence directory for the currently running test, under the run's configured * root (`EVIDENCE_DIR`, exposed as `testInfo.project.outputDir` — NOT `testInfo.outputDir`, which * is Playwright's own per-test hashed directory; see `tcFolderName` for why that's avoided here). */ function tcEvidenceDir(testInfo: TestInfo): string { return path.join(testInfo.project.outputDir, tcFolderName(testInfo)) } /** Draws a 3px solid red outline around `target` without shifting layout (outline, not border). */ export async function highlightElement(target: Locator): Promise { await target.evaluate((el: HTMLElement) => { el.setAttribute('data-evidence-highlight', '1') el.style.outline = '3px solid #ff0000' el.style.outlineOffset = '2px' }) } /** Removes the highlight applied by `highlightElement` so it doesn't leak into the next step's screenshot. */ export async function removeHighlight(target: Locator): Promise { await target .evaluate((el: HTMLElement) => { el.style.outline = '' el.style.outlineOffset = '' el.removeAttribute('data-evidence-highlight') }) .catch(() => {}) } export interface CaptureStepOptions { /** Element to draw a red rectangle around before the screenshot (the item/content the tester must confirm). */ highlightSelector?: Locator /** Element to scroll into view before the screenshot (for content inside a scrollable modal/table). */ scrollSelector?: Locator /** Screenshot the full page instead of the current viewport. Default: true. */ fullPage?: boolean /** Opt into the pixel-diff stability pass — see `WaitForUiSettledOptions.enableVisualStability`. */ enableVisualStability?: boolean } /** * Captures one evidence screenshot for one TC Step, following the naming convention * `step-NN-{desc}.png` inside the run's evidence dir (Playwright's configured outputDir, * i.e. `EVIDENCE_DIR` — see playwright.config.ts template). * * Call this once per TC Step that changes UI state or navigates to a new screen/modal — * not just once at the end of the test. */ export async function captureStepEvidence( page: Page, testInfo: TestInfo, stepIndex: number, stepDesc: string, opts: CaptureStepOptions = {}, ): Promise { await waitForUiSettled(page, undefined, { enableVisualStability: opts.enableVisualStability }) if (opts.scrollSelector) { await opts.scrollSelector.scrollIntoViewIfNeeded() await waitForUiSettled(page, undefined, { enableVisualStability: opts.enableVisualStability }) } if (opts.highlightSelector) { await highlightElement(opts.highlightSelector) } const fileName = `step-${String(stepIndex).padStart(2, '0')}-${slugify(stepDesc)}.png` await page.screenshot({ path: path.join(tcEvidenceDir(testInfo), fileName), fullPage: opts.fullPage ?? true, animations: 'disabled', scale: 'css', }) if (opts.highlightSelector) { await removeHighlight(opts.highlightSelector) } } /** * Convenience wrapper for steps that click a trigger to open a popup/modal, then capture * the resulting state. Ensures the click happens BEFORE the screenshot — a common source * of "modal never shown in evidence" bugs. */ export async function openAndCapture( page: Page, testInfo: TestInfo, trigger: Locator, resultLocator: Locator, stepIndex: number, stepDesc: string, opts: CaptureStepOptions = {}, ): Promise { await highlightElement(trigger) await page.screenshot({ path: path.join(tcEvidenceDir(testInfo), `step-${String(stepIndex).padStart(2, '0')}-before-click.png`), fullPage: opts.fullPage ?? true, animations: 'disabled', }) await removeHighlight(trigger) await trigger.click() await resultLocator.waitFor({ state: 'visible' }) await captureStepEvidence(page, testInfo, stepIndex, stepDesc, { ...opts, highlightSelector: opts.highlightSelector ?? resultLocator, }) }