SPB Git

Repository Public

qa-helpers.ts 4.9 KB 58 lines typescript

1// Shared by qa-real.spec.ts and the manual QA walk: a Chromium VIRTUAL authenticator whose credential can be saved to a file2// and loaded again, so several browser sessions can use the one-shot owner setup of a single stack. The credential is a3// throwaway test passkey for a throwaway wallet; the file lives under test-results/ (git-ignored).4import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';5import { tmpdir } from 'node:os';6import { dirname, join } from 'node:path';7import type { BrowserContext, CDPSession, Page } from '@playwright/test';89/** Where the throwaway virtual passkey of the stack on `port` is kept between browser sessions (deleted by qa-real-server.mts at boot). */10export const passkeyFile = (port: number | string): string => join(tmpdir(), 'spw-qa', `passkey-${port}.json`);11export interface QaPasskey { cdp: CDPSession; authenticatorId: string; save: () => Promise<void> }12export async function virtualPasskey(context: BrowserContext, page: Page, file: string): Promise<QaPasskey> {13  const cdp = await context.newCDPSession(page);14  await cdp.send('WebAuthn.enable');15  const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });16  if (existsSync(file)) for (const credential of JSON.parse(readFileSync(file, 'utf8')) as never[]) await cdp.send('WebAuthn.addCredential', { authenticatorId, credential });17  const save = async () => { const { credentials } = await cdp.send('WebAuthn.getCredentials', { authenticatorId }); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(credentials)); };18  return { cdp, authenticatorId, save };19}20/** Open the wallet: first-time setup when the stack is fresh, passkey unlock otherwise. Works in French and English. */21export async function enter(page: Page, pk: QaPasskey, setupToken: string): Promise<'setup' | 'unlock' | 'open'> {22  await page.goto('/');23  const token = page.getByRole('textbox'), unlock = page.getByRole('button', { name: /Déverrouiller|Unlock/ }), home = page.getByText(/Valeur totale estimée|Estimated total value/);24  await token.or(unlock).or(home).first().waitFor({ timeout: 30_000 });25  if (await home.isVisible()) return 'open';26  if (await token.isVisible()) {27    await token.fill(setupToken);28    await page.getByRole('button', { name: /Créer ma clé|Create my passkey/ }).click();29    await home.waitFor({ timeout: 30_000 });30    await pk.save();31    return 'setup';32  }33  await unlock.click();34  await home.waitFor({ timeout: 30_000 });35  await pk.save();   // the signature counter moved36  return 'unlock';37}38/** Console errors, page errors and failed/4xx/5xx requests of a page, for the QA report. */39export function watch(page: Page): { errors: string[]; failed: string[] } {40  const errors: string[] = [], failed: string[] = [];41  page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 300)); });42  page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 300)}`));43  page.on('requestfailed', (r) => { if (!/quote\/stream/.test(r.url())) failed.push(`${r.method()} ${new URL(r.url()).pathname} ${r.failure()?.errorText ?? ''}`); });44  page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.request().method()} ${new URL(r.url()).pathname}`); });45  return { errors, failed };46}47/** Layout probes run inside the page: horizontal overflow, elements leaving the viewport, interactive elements under 44 px.48 *  (A string, not a function: tsx/esbuild wraps named inner functions with a helper that does not exist in the page.) */49const PROBE = `(() => {50  const vw = window.innerWidth, out = { overflow: document.documentElement.scrollWidth > vw, wide: [], small: [] };51  const label = (e) => e.tagName.toLowerCase() + '[' + (e.getAttribute('aria-label') || e.innerText || '').trim().slice(0, 40).replace(/\\s+/g, ' ') + ']';52  for (const e of Array.from(document.querySelectorAll('main *, [role=dialog] *'))) { const r = e.getBoundingClientRect(); if (r.width > 0 && (r.right > vw + 1 || r.left < -1) && !e.closest('.overflow-x-auto, .no-scrollbar')) out.wide.push(label(e) + ' ' + Math.round(r.left) + '..' + Math.round(r.right)); }53  for (const e of Array.from(document.querySelectorAll('button, a[href], select, input:not([type=hidden]):not([type=checkbox]), [role=radio], [role=tab], [role=checkbox], [role=switch]'))) { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0 && (r.height < 43.5 || r.width < 43.5)) out.small.push(label(e) + ' ' + Math.round(r.width) + 'x' + Math.round(r.height)); }54  out.wide = out.wide.slice(0, 12); out.small = Array.from(new Set(out.small)).slice(0, 40);55  return out;56})()`;57export const probe = (page: Page): Promise<{ overflow: boolean; wide: string[]; small: string[] }> => page.evaluate(PROBE);58
Files main
Menu

Theme