/** * uat-ui/playwright-driver.ts — The real-browser implementation of UiDriver. * * Selector strategy (verified against @atlashub/smartstack + the CLI scaffolder * contract): scaffolder data-testids FIRST (form-submit, ss-list-row, row-edit, * row-delete, permission-denied — emitted by scaffold-component, ui-primitives * and scaffold-frontend-auth), STRUCTURAL fallbacks second (#email/#password, * button[type=submit], `table tbody tr` rows, lucide-pencil + lucide-trash-2 row * icons, .ss-nav-item sidebar items, `.animate-spin` readiness). Denial is * detected THREE ways because a generated route's denial * has no single signal: (1) the permission-denied testid / "Accès refusé" * text (fr/en/it/de), (2) a redirect AWAY from the requested route (the platform * ProtectedRoute redirects module/section denials to /applications), (3) the * /login redirect for unauthenticated. A blank PermissionGuard fallback is * covered by (1) once the scaffolder emits the marker. * * Playwright is a runtime dependency of the deployed skills tree; it is imported * DYNAMICALLY so every other uat CLI works without it. A missing module (or a * missing Chromium download) surfaces as PlaywrightMissingError with the install * commands — never a raw stack. */ import type { Browser, BrowserContext, Page } from 'playwright'; import type { FlowOutcome, LoginOutcome, NavOutcome, Observation, UiDriver } from './driver-types.js'; import type { JourneyStep } from './walker.js'; export class PlaywrightMissingError extends Error { constructor(cause: string) { super( `Playwright is not usable (${cause}). From the deployed skills directory run:\n` + ` npm install # provisions the playwright package\n` + ` npx playwright install chromium # downloads the browser\n` + `then re-run /uat ui.`, ); this.name = 'PlaywrightMissingError'; } } /** Benign dev-mode console noise that must not fail an allowed page. */ export const CONSOLE_WHITELIST: RegExp[] = [ /vite|hmr/i, /react devtools/i, /mismatching versions of react/i, /failed to fetch dynamically imported module/i, /\/api\/auth\/me/i, /favicon/i, /net::ERR_ABORTED/i, ]; export interface PlaywrightDriverOptions { frontendUrl: string; headless: boolean; slowMo: number; /** Full readiness budget per page (plan readiness.timeout_ms). */ readinessTimeoutMs: number; /** Extra spinner re-waits when the page never settles (plan readiness.retry_on_not_ready). */ retryOnNotReady: number; } interface NetWindow { count: number; bytes: number; failed: { url: string; status: number }[]; } const now = (): number => performance.now(); /** Raw page signals read in-page by observe(), before verdict classification. */ export interface RawPageState { /** location.pathname. */ path: string; /** A denial marker/text was present (permission-denied testid or "Accès refusé"…). */ denied: boolean; /** A dev-server error overlay was present. */ overlay: boolean; } /** * Classify the access verdict from the raw page signals + the CONCRETE requested * path (null for a dynamic click_row destination). PURE + unit-tested. A generated * route's denial has no single signal, so a redirect AWAY from the requested path * (e.g. the platform sends module/section denials to /applications) is treated as * a denial — otherwise a correctly-secured page reads as "allowed". */ export function classifyAccessState( state: RawPageState, requestedPath: string | null, ): Exclude { if (state.path.startsWith('/login')) return 'redirect_login'; if (state.overlay) return 'error'; if (state.denied) return 'denied'; const req = requestedPath; const redirectedAway = req !== null && state.path !== '' && state.path !== req && !state.path.startsWith(`${req}/`) && !`${req}`.startsWith(`${state.path}/`); return redirectedAway ? 'denied' : 'allowed'; } export class PlaywrightDriver implements UiDriver { private browser: Browser | null = null; private context: BrowserContext | null = null; private page: Page | null = null; private net: NetWindow = { count: 0, bytes: 0, failed: [] }; private consoleErrors: string[] = []; private navStart = 0; private navMs = 0; private fullyReadyMs = 0; private notReady = false; private lastNavWasFull = false; /** Path of the last CONCRETE navigation target (goto/menu_click); null for a * dynamic click_row destination. Used to detect a denial redirect-away. */ private lastRequestedPath: string | null = null; constructor(private readonly opts: PlaywrightDriverOptions) {} /** Path portion of a URL/route (drops query + hash). */ private pathOf(url: string): string { return url.split(/[?#]/)[0]; } async start(): Promise { let pw: typeof import('playwright'); try { pw = await import('playwright'); } catch (e) { throw new PlaywrightMissingError(`module not installed: ${(e as Error).message}`); } try { this.browser = await pw.chromium.launch({ headless: this.opts.headless, slowMo: this.opts.slowMo || undefined }); } catch (e) { throw new PlaywrightMissingError(`Chromium failed to launch: ${(e as Error).message}`); } } async stop(): Promise { await this.context?.close().catch(() => undefined); await this.browser?.close().catch(() => undefined); this.context = null; this.page = null; this.browser = null; } async openRoleSession(_role: string): Promise { if (!this.browser) throw new Error('driver not started'); await this.context?.close().catch(() => undefined); this.context = await this.browser.newContext({ viewport: { width: 1440, height: 900 } }); this.context.on('response', (response) => { void (async () => { this.net.count += 1; const status = response.status(); try { const header = response.headers()['content-length']; if (header) { this.net.bytes += Number(header) || 0; } else if (status !== 204 && status < 300 && status >= 200) { const body = await response.body(); this.net.bytes += body.byteLength; } } catch { /* redirects / closed contexts have no readable body */ } if (status >= 400) { this.net.failed.push({ url: response.url().slice(0, 200), status }); } })(); }); this.page = await this.context.newPage(); this.page.on('console', (msg) => { if (msg.type() !== 'error') return; const text = msg.text(); if (CONSOLE_WHITELIST.some((re) => re.test(text))) return; this.consoleErrors.push(text.slice(0, 300)); }); this.page.on('pageerror', (err) => { this.consoleErrors.push(`pageerror: ${String(err.message ?? err).slice(0, 300)}`); }); } private mustPage(): Page { if (!this.page) throw new Error('no open role session'); return this.page; } private resetWindow(): void { this.net = { count: 0, bytes: 0, failed: [] }; this.consoleErrors = []; this.navStart = now(); this.navMs = 0; this.fullyReadyMs = 0; this.notReady = false; this.lastNavWasFull = false; } private absoluteUrl(url: string): string { return `${this.opts.frontendUrl}${url.startsWith('/') ? '' : '/'}${url}`; } /** Wait until the SPA settled: DOM loaded, spinners gone, network idle. */ private async waitReady(): Promise { const page = this.mustPage(); const deadline = this.navStart + this.opts.readinessTimeoutMs; const remaining = (): number => Math.max(250, deadline - now()); await page.waitForLoadState('domcontentloaded', { timeout: remaining() }).catch(() => undefined); let settled = false; for (let attempt = 0; attempt <= this.opts.retryOnNotReady && !settled; attempt++) { settled = await page .waitForFunction(() => document.querySelectorAll('.animate-spin').length === 0, undefined, { timeout: remaining(), }) .then(() => true) .catch(() => false); } await page.waitForLoadState('networkidle', { timeout: Math.min(remaining(), 4000) }).catch(() => undefined); this.notReady = !settled; this.fullyReadyMs = Math.round(now() - this.navStart); } private async gotoUrl(url: string): Promise { const page = this.mustPage(); this.resetWindow(); this.lastNavWasFull = true; this.lastRequestedPath = this.pathOf(url); try { await page.goto(this.absoluteUrl(url), { waitUntil: 'domcontentloaded', timeout: this.opts.readinessTimeoutMs, }); } catch (e) { return { used: 'goto', ok: false, error: (e as Error).message }; } this.navMs = Math.round(now() - this.navStart); await this.waitReady(); return { used: 'goto', ok: true }; } /** Expand the sidebar then click the nav link whose href ends with the target. */ private async menuNavigate(targetUrl: string): Promise { const page = this.mustPage(); this.resetWindow(); this.lastRequestedPath = this.pathOf(targetUrl); const findLink = (): ReturnType => page.locator(`nav a[href$="${targetUrl}"], aside a[href$="${targetUrl}"]`).first(); try { // Un-collapse the sidebar when needed (collapsed = w-16). const collapsed = await page .locator('aside.w-16, aside[class*="w-16"]') .first() .isVisible({ timeout: 400 }) .catch(() => false); if (collapsed) { await page.locator('.ss-sidebar-toggle').first().click({ timeout: 1500 }).catch(() => undefined); } let link = findLink(); if (!(await link.isVisible({ timeout: 800 }).catch(() => false))) { // Expand modules (non-link nav items) until the link shows, bounded. const expanders = page.locator('button.ss-nav-item'); const count = Math.min(await expanders.count().catch(() => 0), 12); for (let i = 0; i < count; i++) { await expanders.nth(i).click({ timeout: 1200 }).catch(() => undefined); link = findLink(); if (await link.isVisible({ timeout: 300 }).catch(() => false)) break; } } if (!(await link.isVisible({ timeout: 400 }).catch(() => false))) { const fallback = await this.gotoUrl(targetUrl); return { ...fallback, note: `menu link for ${targetUrl} not found in the sidebar — fell back to goto` }; } this.resetWindow(); // measure from the real navigation click await link.click({ timeout: 3000 }); await page .waitForURL((u) => u.pathname.endsWith(targetUrl), { timeout: 6000 }) .catch(() => undefined); this.navMs = Math.round(now() - this.navStart); await this.waitReady(); return { used: 'menu_click', ok: true }; } catch (e) { return { used: 'menu_click', ok: false, error: (e as Error).message }; } } /** Rows of the current list (UAT-marked when a marker is given). */ private rows(marker?: string): ReturnType { const page = this.mustPage(); const base = page.locator('[data-testid$="-list-row"], table tbody tr'); return marker ? base.filter({ hasText: marker }) : base; } private async clickRowInParent(step: JourneyStep): Promise { const page = this.mustPage(); const parent = step.parentRoute ?? ''; try { if (!new URL(page.url()).pathname.endsWith(parent)) { const nav = await this.menuNavigate(parent); if (!nav.ok) return { used: 'click_row', ok: false, error: `cannot reach parent list: ${nav.error}` }; } const rows = this.rows(); const count = await rows.count().catch(() => 0); if (count === 0) return { used: 'click_row', ok: true, emptyParent: true }; const first = rows.first(); const isEdit = step.view === 'edit'; this.resetWindow(); // click_row lands on a dynamic id — no fixed target, so disable redirect-away detection. this.lastRequestedPath = null; if (isEdit) { const editBtn = first.locator('[data-testid^="row-edit"], button:has(svg.lucide-pencil)').first(); if (!(await editBtn.isVisible({ timeout: 800 }).catch(() => false))) { return { used: 'click_row', ok: false, note: 'edit affordance (row-edit) not found in the row' }; } await editBtn.click({ timeout: 3000 }); } else { await first.click({ timeout: 3000 }); } const pattern = new RegExp(`${parent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+${isEdit ? '/edit' : ''}$`); await page.waitForURL((u) => pattern.test(u.pathname), { timeout: 6000 }).catch(() => undefined); this.navMs = Math.round(now() - this.navStart); await this.waitReady(); return { used: 'click_row', ok: true }; } catch (e) { return { used: 'click_row', ok: false, error: (e as Error).message }; } } async navigate(step: JourneyStep): Promise { if (step.strategy === 'menu_click') return this.menuNavigate(step.url); if (step.strategy === 'click_row_in_parent') return this.clickRowInParent(step); return this.gotoUrl(step.url); } async login(email: string, password: string): Promise { const page = this.mustPage(); const nav = await this.gotoUrl('/login'); if (!nav.ok) return { ok: false, error: `cannot open /login: ${nav.error}` }; try { const emailField = page.locator('[data-testid="login-email"], #email').first(); const passwordField = page.locator('[data-testid="login-password"], #password').first(); await emailField.fill(email, { timeout: 5000 }); await passwordField.fill(password, { timeout: 5000 }); await page.locator('[data-testid="login-submit"], button[type="submit"]').first().click({ timeout: 5000 }); const loggedIn = await page .waitForFunction(() => localStorage.getItem('user') !== null, undefined, { timeout: 10000 }) .then(() => true) .catch(() => false); const path = new URL(page.url()).pathname; if (path.includes('/force-change-password')) { return { ok: false, mustChangePassword: true, error: 'account is forced to change its password — re-run /uat provision' }; } if (!loggedIn) { const errText = await page .locator('form ~ div, [class*="error"]') .first() .innerText({ timeout: 500 }) .catch(() => ''); return { ok: false, error: `login did not complete${errText ? `: ${errText.slice(0, 160)}` : ''}` }; } await this.waitReady(); return { ok: true }; } catch (e) { return { ok: false, error: (e as Error).message }; } } async observe(): Promise { const page = this.mustPage(); const state = await page .evaluate(() => { const bodyText = document.body?.innerText ?? ''; const paint = performance .getEntriesByType('paint') .find((p) => p.name === 'first-contentful-paint'); return { path: location.pathname, denied: // fr / en / it / de denial copy, or the scaffolded permission-denied marker. /Accès refusé|Access denied|Accesso negato|Zugriff verweigert/i.test(bodyText) || document.querySelector('[data-testid$="permission-denied"]') !== null, overlay: document.querySelector('vite-error-overlay') !== null, fcp: paint ? Math.round(paint.startTime) : null, }; }) .catch(() => ({ path: '', denied: false, overlay: true, fcp: null })); // A denial on a generated route often has NO in-page marker: ProtectedRoute // redirects module/section denials AWAY (to /applications). classifyAccessState // folds that redirect-away signal in with the marker/text + /login checks. const accessState = classifyAccessState( { path: state.path, denied: state.denied, overlay: state.overlay }, this.lastRequestedPath, ); return { url: page.url(), accessState, perf: { navMs: this.navMs, fullyReadyMs: this.fullyReadyMs, ...(this.lastNavWasFull && state.fcp !== null ? { ttfpMs: state.fcp } : {}), }, network: { requestCount: this.net.count, transferredBytes: this.net.bytes, failed: [...this.net.failed] }, consoleErrors: [...this.consoleErrors], ...(this.notReady ? { notReady: true } : {}), }; } /** Fill every visible field of the page's form with marker-derived values. */ private async fillForm(marker: string): Promise { const page = this.mustPage(); const form = page.locator('form').first(); let markerPlaced = false; const selects = form.locator('select:visible'); for (let i = 0, n = await selects.count().catch(() => 0); i < n; i++) { const sel = selects.nth(i); await sel.selectOption({ index: 1 }).catch(async () => sel.selectOption({ index: 0 }).catch(() => undefined)); } const inputs = form.locator('input:visible, textarea:visible'); for (let i = 0, n = await inputs.count().catch(() => 0); i < n; i++) { const input = inputs.nth(i); const type = ((await input.getAttribute('type').catch(() => '')) ?? 'text').toLowerCase(); const role = (await input.getAttribute('role').catch(() => '')) ?? ''; if (['checkbox', 'radio', 'file', 'hidden', 'submit', 'button'].includes(type)) continue; try { if (role === 'combobox' || (await input.getAttribute('aria-autocomplete').catch(() => null))) { // EntityLookup-style FK picker: open, then take the first option. await input.click({ timeout: 1500 }); await input.pressSequentially('a', { delay: 50 }).catch(() => undefined); const option = page.locator('[role="option"]').first(); if (await option.isVisible({ timeout: 2000 }).catch(() => false)) await option.click(); continue; } if (type === 'email') { await input.fill(`${marker.toLowerCase().replace(/[^a-z0-9-]/g, '')}@uat.local`, { timeout: 1500 }); } else if (type === 'number') { await input.fill('1', { timeout: 1500 }); } else if (type === 'date') { await input.fill('2026-01-15', { timeout: 1500 }); } else if (type === 'datetime-local') { await input.fill('2026-01-15T10:00', { timeout: 1500 }); } else if (type === 'password') { await input.fill('Uat!Form9aa', { timeout: 1500 }); } else { const name = (await input.getAttribute('name').catch(() => null)) ?? `f${i}`; await input.fill(markerPlaced ? `${marker} ${name}` : marker, { timeout: 1500 }); markerPlaced = true; } } catch { /* a stubborn widget must not sink the whole flow */ } } } /** * Read-first edit pages (SectionCard) hide the controls behind per-section * "Modifier"/"Terminer" toggles. Open every section BEFORE the field * discovery — bounded (≤8, a fiche never carries more category cards), each * click swaps that toggle to `section-done-*` so `.first()` always targets * the next unopened one. No toggles → direct-edit page, no-op. Structural * fallback: a localized per-section "Modifier" text button. */ private async openReadFirstSections(): Promise { const page = this.mustPage(); const toggles = page.locator('[data-testid^="section-edit-"]'); const count = Math.min(await toggles.count().catch(() => 0), 8); if (count > 0) { for (let i = 0; i < count; i++) { await toggles.first().click({ timeout: 1200 }).catch(() => undefined); } return; } const fallback = page .locator('form') .locator('button', { hasText: /^(modifier|edit|bearbeiten|modifica)$/i }); const fbCount = Math.min(await fallback.count().catch(() => 0), 8); for (let i = 0; i < fbCount; i++) { await fallback.first().click({ timeout: 1200 }).catch(() => undefined); } } private async submitFormAndWait(methods: readonly string[]): Promise<{ status: number | null; submitDisabled?: boolean }> { const page = this.mustPage(); const submit = page.locator('[data-testid="form-submit"], form button[type="submit"]').first(); // Read-first forms keep Save disabled until the form is dirty — a disabled // submit is a FACT to report (indeterminate upstream), never a 4s click // timeout that would paint the whole run red. const enabled = await submit.isEnabled({ timeout: 1500 }).catch(() => false); if (!enabled) return { status: null, submitDisabled: true }; const responsePromise = page .waitForResponse( (r) => methods.includes(r.request().method()) && r.url().includes('/api/'), { timeout: 12000 }, ) .catch(() => null); await submit.click({ timeout: 4000 }); const response = await responsePromise; return { status: response ? response.status() : null }; } async runCreateFlow(step: JourneyStep): Promise { const marker = step.marker ?? 'UAT'; try { const nav = await this.gotoUrl(step.url); if (!nav.ok) return { ok: false, detail: `cannot open create page: ${nav.error}` }; const hasForm = await this.mustPage() .locator('form') .first() .isVisible({ timeout: 2500 }) .catch(() => false); if (!hasForm) return { ok: false, indeterminate: true, detail: 'no form found on the create page' }; await this.fillForm(marker); const { status, submitDisabled } = await this.submitFormAndWait(['POST']); if (submitDisabled) return { ok: false, indeterminate: true, detail: 'submit disabled — form not dirty' }; if (status === null) return { ok: false, indeterminate: true, detail: 'no POST observed after submit' }; if (status >= 200 && status < 300) return { ok: true, status, detail: `created (${status})` }; if (status === 400 || status === 422) { return { ok: false, indeterminate: true, status, detail: 'validation rejected the synthetic payload' }; } if (status === 401 || status === 403) { return { ok: false, status, detail: `authorization rejected a permitted create (${status})` }; } return { ok: false, status, detail: `create failed (${status})` }; } catch (e) { return { ok: false, detail: (e as Error).message }; } } async runEditFlow(step: JourneyStep): Promise { const marker = step.marker ?? 'UAT'; try { const nav = await this.gotoUrl(step.parentRoute ?? step.url); if (!nav.ok) return { ok: false, detail: `cannot open parent list: ${nav.error}` }; const row = this.rows(marker).first(); if (!(await row.isVisible({ timeout: 2500 }).catch(() => false))) { return { ok: false, indeterminate: true, detail: `no row carrying "${marker}" (create may not have persisted)` }; } const editBtn = row.locator('[data-testid^="row-edit"], button:has(svg.lucide-pencil)').first(); if (!(await editBtn.isVisible({ timeout: 1200 }).catch(() => false))) { return { ok: false, indeterminate: true, detail: 'edit affordance (row-edit) not found' }; } await editBtn.click({ timeout: 3000 }); await this.waitReady(); // Read-first fiche: open every section before looking for an input — // the controls only exist while their section is editing. await this.openReadFirstSections(); const page = this.mustPage(); const field = page.locator('form input[type="text"]:visible, form input:not([type]):visible').first(); if (await field.isVisible({ timeout: 1500 }).catch(() => false)) { const current = await field.inputValue().catch(() => ''); await field.fill(`${current}-e`.slice(0, 80), { timeout: 1500 }).catch(() => undefined); } const { status, submitDisabled } = await this.submitFormAndWait(['PUT', 'PATCH', 'POST']); if (submitDisabled) return { ok: false, indeterminate: true, detail: 'submit disabled — form not dirty (read-first)' }; if (status === null) return { ok: false, indeterminate: true, detail: 'no write request observed after submit' }; if (status >= 200 && status < 300) return { ok: true, status, detail: `edited (${status})` }; if (status === 400 || status === 422) { return { ok: false, indeterminate: true, status, detail: 'validation rejected the edit' }; } return { ok: false, status, detail: `edit failed (${status})` }; } catch (e) { return { ok: false, detail: (e as Error).message }; } } async runDeleteFlow(step: JourneyStep): Promise { const marker = step.marker ?? 'UAT'; const page = this.mustPage(); try { const nav = await this.gotoUrl(step.parentRoute ?? step.url); if (!nav.ok) return { ok: false, detail: `cannot open parent list: ${nav.error}` }; let initial = await this.rows(marker).count().catch(() => 0); if (initial === 0) { return { ok: false, indeterminate: true, detail: `no "${marker}" rows to delete (create may not have persisted)` }; } let deleted = 0; let lastStatus: number | null = null; for (let guard = 0; guard < 5 && initial > 0; guard++) { const row = this.rows(marker).first(); const deleteBtn = row.locator('[data-testid^="row-delete"], button:has(svg.lucide-trash-2)').first(); if (!(await deleteBtn.isVisible({ timeout: 1200 }).catch(() => false))) { return { ok: false, indeterminate: true, detail: 'delete affordance (row-delete) not found' }; } await deleteBtn.click({ timeout: 3000 }); const confirmByTestId = page.locator('[data-testid="confirm-delete"]').first(); const confirmByText = page .locator('[role="dialog"] button') .filter({ hasText: /supprimer|delete|confirmer|confirm/i }) .first(); const responsePromise = page .waitForResponse((r) => ['DELETE', 'POST'].includes(r.request().method()) && r.url().includes('/api/'), { timeout: 10000, }) .catch(() => null); if (await confirmByTestId.isVisible({ timeout: 2500 }).catch(() => false)) { await confirmByTestId.click({ timeout: 3000 }); } else if (await confirmByText.isVisible({ timeout: 800 }).catch(() => false)) { await confirmByText.click({ timeout: 3000 }); } const response = await responsePromise; lastStatus = response ? response.status() : null; await page.waitForTimeout(400); const remaining = await this.rows(marker).count().catch(() => 0); if (remaining < initial) deleted += initial - remaining; initial = remaining; } if (initial === 0) return { ok: true, status: lastStatus ?? undefined, detail: `deleted ${deleted} UAT row(s)` }; return { ok: false, status: lastStatus ?? undefined, detail: `${initial} "${marker}" row(s) survived deletion${lastStatus ? ` (last status ${lastStatus})` : ''}`, }; } catch (e) { return { ok: false, detail: (e as Error).message }; } } async screenshot(absPath: string): Promise { try { await this.mustPage().screenshot({ path: absPath }); return true; } catch { return false; } } }