kt-blog-web/e2e/argon-parity/argonParityHelpers.ts

240 lines
8.0 KiB
TypeScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { expect, type Page, type TestInfo } from '@playwright/test';
import {
ARGON_PAGE_STATES,
LIVE_BLOG_ORIGIN,
LOCAL_ARTIFACT_ROOT,
type ArgonPageState,
type ArgonRouteCase,
} from './argonParityMatrix';
export interface ArgonRouteSnapshot {
bodyClass: string;
header: {
backdropFilter: string;
backgroundColor: string;
height: number;
top: number;
} | null;
htmlClass: string;
leftbar: {
text: string;
visible: boolean;
width: number;
} | null;
mainText: string;
rightbar: {
text: string;
visible: boolean;
width: number;
} | null;
rootClass: string;
routeKey: string;
scrollY: number;
state: ArgonPageState;
title: string;
}
/**
* @param routeCase Argon route case whose WordPress query route should be opened.
* @returns Absolute live WordPress URL for capture-only baseline checks.
*/
export function toLiveUrl(routeCase: ArgonRouteCase) {
return new URL(routeCase.livePath, LIVE_BLOG_ORIGIN).toString();
}
/**
* @param routeCase Argon route case whose local hash route should be opened.
* @returns Local Vue hash route URL relative to Playwright `baseURL`.
*/
export function toLocalHashUrl(routeCase: ArgonRouteCase) {
const normalizedLocalPath = routeCase.localPath.startsWith('/')
? routeCase.localPath
: `/${routeCase.localPath}`;
return `/#${normalizedLocalPath}`;
}
/**
* @param page Playwright page that will navigate to the live WordPress route.
* @param routeCase Route case selected from the captured live Argon matrix.
*/
export async function gotoLiveRoute(page: Page, routeCase: ArgonRouteCase) {
await page.goto(toLiveUrl(routeCase), { waitUntil: 'domcontentloaded' });
await waitForArgonPage(page);
}
/**
* @param page Playwright page that will navigate to the local Vue route.
* @param routeCase Route case selected from the Argon parity matrix.
*/
export async function gotoLocalRoute(page: Page, routeCase: ArgonRouteCase) {
await page.goto(toLocalHashUrl(routeCase), { waitUntil: 'domcontentloaded' });
await waitForArgonPage(page);
}
/**
* @param page Browser page whose Argon root should be visible before assertions.
*/
export async function waitForArgonPage(page: Page) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => undefined);
await expect(page.locator('.kt-blog, body.wp-theme-argon').first()).toBeVisible();
}
/**
* @param page Browser page to position at the same logical scroll state used by live capture.
* @param state Logical page state: top, middle, or bottom.
*/
export async function scrollToArgonState(page: Page, state: ArgonPageState) {
if (state === 'top') {
await page.evaluate(() => window.scrollTo(0, 0));
}
if (state === 'middle') {
await page.evaluate(() => window.scrollTo(0, Math.round(window.innerHeight * 0.65)));
}
if (state === 'bottom') {
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
}
await page.waitForTimeout(180);
}
/**
* @param page Browser page whose visual and semantic route state should be sampled.
* @param routeCase Route case being captured; stored in artifact output for traceability.
* @param state Logical scroll state being captured.
* @returns Small JSON-serializable snapshot for comparing local output with live Argon behavior.
*/
export async function captureRouteSnapshot(
page: Page,
routeCase: ArgonRouteCase,
state: ArgonPageState,
): Promise<ArgonRouteSnapshot> {
await scrollToArgonState(page, state);
return page.evaluate(
({ routeKey, stateName }) => {
const root = document.querySelector<HTMLElement>('.kt-blog');
const header = document.querySelector<HTMLElement>('.kt-blog__header-navbar, #navbar-main');
const leftbar = document.querySelector<HTMLElement>('.kt-blog__sidebar, #leftbar');
const rightbar = document.querySelector<HTMLElement>('.kt-blog__rightbar, #rightbar');
const main = document.querySelector<HTMLElement>('.kt-blog__main, main');
/**
* @param element Optional element whose geometry and text should be summarized.
* @returns A compact widget summary that is stable enough for parity diagnostics.
*/
const captureWidget = (element: HTMLElement | null) => {
if (!element) {
return null;
}
const rect = element.getBoundingClientRect();
return {
text: element.innerText.replace(/\s+/g, ' ').slice(0, 220),
visible: rect.width > 0 && rect.height > 0,
width: Math.round(rect.width),
};
};
const headerRect = header?.getBoundingClientRect();
const headerStyle = header ? getComputedStyle(header) : null;
return {
bodyClass: document.body.className,
header:
header && headerRect && headerStyle
? {
backdropFilter: headerStyle.backdropFilter,
backgroundColor: headerStyle.backgroundColor,
height: Math.round(headerRect.height),
top: Math.round(headerRect.top),
}
: null,
htmlClass: document.documentElement.className,
leftbar: captureWidget(leftbar),
mainText: (main?.innerText || document.body.innerText).replace(/\s+/g, ' ').slice(0, 500),
rightbar: captureWidget(rightbar),
rootClass: root?.className || '',
routeKey,
scrollY: Math.round(window.scrollY),
state: stateName,
title: document.title,
};
},
{
routeKey: routeCase.routeKey,
stateName: state,
},
);
}
/**
* @param page Local Blog Web page after route navigation.
* @param routeCase Expected Argon route contract for the current local route.
*/
export async function expectLocalRouteContract(page: Page, routeCase: ArgonRouteCase) {
const root = page.locator('.kt-blog');
await expect(root).toBeVisible();
await expect(root).toHaveClass(/kt-blog--wp-argon/);
await expect(root).toHaveClass(new RegExp(`kt-blog--${routeCase.kind}`));
await expect(page).toHaveTitle(routeCase.titlePattern);
for (const text of routeCase.expectedTexts) {
await expectVisibleBodyText(page, text);
}
await expect(page.locator('.kt-blog__header-navbar')).toBeVisible();
await expect(page.locator('.kt-blog__main')).toBeVisible();
}
/**
* @param page Local Blog Web page after route navigation.
* @param text Text captured from the live route contract.
*/
async function expectVisibleBodyText(page: Page, text: string) {
await expect
.poll(() => page.evaluate((expectedText) => document.body.innerText.includes(expectedText), text))
.toBe(true);
}
/**
* @param page Local or live Blog page to capture across all top/middle/bottom states.
* @param routeCase Route case being captured.
* @returns Three route snapshots keyed by Argon logical page state.
*/
export async function captureAllRouteStates(page: Page, routeCase: ArgonRouteCase) {
const snapshots: ArgonRouteSnapshot[] = [];
for (const state of ARGON_PAGE_STATES) {
snapshots.push(await captureRouteSnapshot(page, routeCase, state));
}
return snapshots;
}
/**
* @param testInfo Playwright test metadata used to place artifacts under the project output folder.
* @param name Stable artifact filename without extension.
* @param payload JSON-serializable payload to write for later screenshot/metric comparison.
*/
export async function writeArgonArtifact(testInfo: TestInfo, name: string, payload: unknown) {
const artifactDir = path.join(LOCAL_ARTIFACT_ROOT, testInfo.project.name, sanitizeArtifactName(testInfo.title));
await fs.mkdir(artifactDir, { recursive: true });
await fs.writeFile(path.join(artifactDir, `${name}.json`), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
/**
* @param rawName Raw title or route key that may contain punctuation unsafe for filenames.
* @returns Filesystem-safe artifact name segment.
*/
export function sanitizeArtifactName(rawName: string) {
return rawName.replace(/[^\p{Letter}\p{Number}._-]+/gu, '-').replace(/^-+|-+$/g, '');
}