import { expect, type ConsoleMessage, type Locator, type Page } from '@playwright/test'; const MACHINE_LINK_SELECTOR = [ 'a[href*="/machine/"]', 'a[href*="/machines/"]', 'a[href*="/viewer/"]', 'a[href*="machine="]', 'a[href*="machineId="]', ].join(', '); const IGNORED_CONSOLE_ERROR_PATTERNS = [ /ResizeObserver loop limit exceeded/i, /ResizeObserver loop completed with undelivered notifications/i, /THREE\.WebGLRenderer: Context Lost/i, /WebGL warning:/i, /GPU stall due to ReadPixels/i, /ContextResult::kTransientFailure/i, ]; export interface ConsoleCapture { readonly errors: string[]; dispose: () => void; expectNoErrors: () => Promise; } export interface FocusSnapshot { tagName: string; label: string; visible: boolean; outlineStyle: string; outlineWidth: string; } export function captureUnexpectedConsole(page: Page): ConsoleCapture { const errors: string[] = []; const onConsole = (message: ConsoleMessage) => { if (message.type() !== 'error') { return; } const text = message.text(); if (IGNORED_CONSOLE_ERROR_PATTERNS.some((pattern) => pattern.test(text))) { return; } const location = message.location(); const source = location.url ? ` (${location.url}:${location.lineNumber})` : ''; errors.push(`[console.error]${source} ${text}`); }; const onPageError = (error: Error) => { errors.push(`[pageerror] ${error.message}${error.stack ? `\n${error.stack}` : ''}`); }; page.on('console', onConsole); page.on('pageerror', onPageError); return { errors, dispose: () => { page.off('console', onConsole); page.off('pageerror', onPageError); }, expectNoErrors: async () => { await page.waitForTimeout(100); expect(errors).toEqual([]); }, }; } export async function installClipboardStub(page: Page): Promise { await page.addInitScript(() => { const target = window as Window & { __mechanicaClipboardWrites?: string[] }; target.__mechanicaClipboardWrites = []; const clipboard = { writeText: async (text: string) => { target.__mechanicaClipboardWrites?.push(String(text)); }, readText: async () => { const writes = target.__mechanicaClipboardWrites ?? []; return writes.length > 0 ? writes[writes.length - 1] : ''; }, }; try { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: clipboard, }); } catch { (navigator as unknown as { clipboard: typeof clipboard }).clipboard = clipboard; } }); } export async function waitForClipboardWrite(page: Page, timeout = 5_000): Promise { const handle = await page.waitForFunction( () => { const target = window as Window & { __mechanicaClipboardWrites?: string[] }; const writes = target.__mechanicaClipboardWrites; return writes && writes.length > 0 ? writes[writes.length - 1] : null; }, null, { timeout }, ); try { const value = await handle.jsonValue(); if (typeof value !== 'string' || value.length === 0) { throw new Error('Clipboard stub did not receive a non-empty string write.'); } return value; } finally { await handle.dispose(); } } export async function waitForAppShell(page: Page): Promise { await page.waitForLoadState('domcontentloaded'); await expect(page.locator('#root')).toBeVisible(); await page.waitForFunction( () => { const root = document.querySelector('#root'); return Boolean(root && root.children.length > 0 && root.getBoundingClientRect().width > 0); }, null, { timeout: 15_000 }, ); await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined); } export async function waitForAnyVisible( page: Page, locators: Locator[], description: string, timeout = 15_000, ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeout) { for (const locator of locators) { const count = await locator.count().catch(() => 0); const candidateCount = count > 0 ? Math.min(count, 30) : 1; for (let index = 0; index < candidateCount; index += 1) { const candidate = count > 0 ? locator.nth(index) : locator.first(); if (await candidate.isVisible().catch(() => false)) { return candidate; } } } await page.waitForTimeout(100); } throw new Error( `Timed out after ${timeout}ms waiting for ${description}. Checked ${locators.length} locator group(s).`, ); } export async function findAnyVisible( page: Page, locators: Locator[], description: string, timeout = 5_000, ): Promise { try { return await waitForAnyVisible(page, locators, description, timeout); } catch { return null; } } export function catalogueSearchLocators(page: Page): Locator[] { return [ page.getByRole('searchbox'), page.getByPlaceholder(/search/i), page.locator( 'input[type="search"], input[name*="search" i], input[aria-label*="search" i]', ), ]; } export function machineCardLinks(page: Page): Locator { return page.locator(MACHINE_LINK_SELECTOR); } export function shareButtonLocators(page: Page): Locator[] { return [ page.getByRole('button', { name: /copy\s+link|share\s+link|share|copy/i }), page.locator( 'button[aria-label*="share" i], button[title*="share" i], button[aria-label*="copy" i], button[title*="copy" i]', ), ]; } export async function findShareButton(page: Page): Promise { return waitForAnyVisible(page, shareButtonLocators(page), 'share/copy-link button', 10_000); } export function isMachineUrl(url: URL): boolean { const pathAndHash = `${url.pathname}${url.hash}`.toLowerCase(); return ( /\/(?:machine|machines|viewer)\//.test(pathAndHash) || url.searchParams.has('machine') || url.searchParams.has('machineId') || url.searchParams.has('m') || url.searchParams.has('id') ); } export async function openCatalogue(page: Page): Promise { await page.goto('/'); await waitForAppShell(page); await waitForAnyVisible( page, [ page.getByRole('main'), page.locator('main'), page.getByText(/mechanica|mechanical systems|catalogue|explorer/i), ], 'catalogue shell content', 15_000, ); } export async function openFirstMachineFromCatalogue(page: Page): Promise { await openCatalogue(page); const machineLink = await waitForAnyVisible( page, [machineCardLinks(page)], 'at least one machine catalogue card link', 15_000, ); const href = await machineLink.getAttribute('href'); if (!href) { throw new Error('First visible machine card did not expose an href.'); } await machineLink.click(); await page.waitForURL((url) => isMachineUrl(url), { timeout: 15_000 }); await waitForAppShell(page); return href; } export async function waitForViewerSurface(page: Page): Promise { return waitForAnyVisible( page, [ page.locator('canvas'), page.locator('[data-testid="webgl-fallback"]'), page.locator('[role="application"]'), page.getByText(/webgl|graphics acceleration|3d viewer|viewer unavailable/i), ], '3D viewer canvas or WebGL fallback', 20_000, ); } export async function tryNudgeShareableState(page: Page): Promise { const control = await findAnyVisible( page, [ page.getByRole('slider', { name: /explode|separation|opacity|rpm|time/i }), page.getByLabel(/explode|separation|cross.?section|wireframe|annotation|label/i), page.getByRole('switch', { name: /wireframe|label|annotation|cross.?section/i }), page.getByRole('button', { name: /explode|wireframe|label|annotation|front|back|left|right|top|isometric/i, }), ], 'optional shareable viewer-state control', 2_000, ); if (!control) { return; } await control.scrollIntoViewIfNeeded().catch(() => undefined); const role = await control.getAttribute('role').catch(() => null); const type = await control.getAttribute('type').catch(() => null); const tagName = await control .evaluate((element) => element.tagName.toLowerCase()) .catch(() => ''); try { if (role === 'slider' || type === 'range' || tagName === 'input') { await control.focus(); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); } else { await control.click(); } } catch { // State nudging is opportunistic. The share feature is still required to // encode the current default viewer state even when no mutable control is visible. } } export async function visibleFocusSnapshot(page: Page): Promise { return page.evaluate(() => { const active = document.activeElement as HTMLElement | null; if (!active || active === document.body || active === document.documentElement) { return null; } const rect = active.getBoundingClientRect(); const style = window.getComputedStyle(active); const label = active.getAttribute('aria-label') ?? active.getAttribute('title') ?? (active.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80); return { tagName: active.tagName.toLowerCase(), label, visible: rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none', outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, }; }); } export async function expectNoHorizontalOverflow(page: Page, tolerancePx = 2): Promise { const overflow = await page.evaluate(() => { const root = document.documentElement; return Math.max(0, Math.ceil(root.scrollWidth - root.clientWidth)); }); expect( overflow, `Expected no horizontal page overflow, but document overflowed by ${overflow}px.`, ).toBeLessThanOrEqual(tolerancePx); }