import assert from 'node:assert/strict' import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' import { chromium } from 'playwright' function parseArgs(argv) { const parsed = { baseUrl: process.env.GEOINTEL_BASE_URL || 'http://127.0.0.1:1202', output: process.env.GEOINTEL_RC9_OUTPUT || '../artifacts/rc9-ux-audit', } for (let index = 2; index < argv.length; index += 1) { if (argv[index] === '--base-url') parsed.baseUrl = argv[++index] else if (argv[index] === '--output') parsed.output = argv[++index] } parsed.baseUrl = parsed.baseUrl.replace(/\/$/, '') return parsed } async function auditInteractiveNames(page, label) { const unnamed = await page.locator('button, input, select, textarea, a[href]').evaluateAll((elements) => { const visible = (element) => { const style = window.getComputedStyle(element) return style.visibility !== 'hidden' && style.display !== 'none' && element.getClientRects().length > 0 && element.getAttribute('aria-hidden') !== 'true' } const textForIdList = (value) => (value || '') .split(/\s+/) .map((id) => document.getElementById(id)?.textContent?.trim() || '') .filter(Boolean) .join(' ') const accessibleName = (element) => { const ariaLabel = element.getAttribute('aria-label')?.trim() if (ariaLabel) return ariaLabel const labelled = textForIdList(element.getAttribute('aria-labelledby')) if (labelled) return labelled if ('labels' in element && element.labels?.length) { const labelText = [...element.labels] .map((item) => item.textContent?.trim() || '') .filter(Boolean) .join(' ') if (labelText) return labelText } if (element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement) { const text = element.textContent?.trim() if (text) return text } return element.getAttribute('title')?.trim() || '' } return elements .filter(visible) .filter((element) => !accessibleName(element)) .map((element) => ({ tag: element.tagName.toLowerCase(), type: element.getAttribute('type'), className: element.className, testId: element.getAttribute('data-testid'), })) }) assert.deepEqual(unnamed, [], `${label} has visible controls without an accessible name`) return unnamed.length } async function prepareAuditSession(page, baseUrl) { const sessionResponse = await page.request.get(`${baseUrl}/api/v1/auth/session`) assert(sessionResponse.ok(), `Session preflight failed with HTTP ${sessionResponse.status()}`) const sessionEnvelope = await sessionResponse.json() const session = sessionEnvelope?.data if (!session?.authentication_required || session.authenticated) return session assert.equal( session.guest_access_enabled, true, 'UX audit needs an authenticated session or enabled guest access', ) const guestResponse = await page.request.post(`${baseUrl}/api/v1/auth/guest`) assert(guestResponse.ok(), `Guest audit session failed with HTTP ${guestResponse.status()}`) const guestEnvelope = await guestResponse.json() return guestEnvelope?.data } async function layoutEvidence(page) { return page.evaluate(() => { const root = document.documentElement const rect = (selector) => { const bounds = document.querySelector(selector)?.getBoundingClientRect() return bounds ? { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, width: bounds.width, height: bounds.height } : null } const main = document.querySelector('.workbench-main')?.getBoundingClientRect() const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect() const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect() return { viewport_width: window.innerWidth, viewport_height: window.innerHeight, document_width: root.scrollWidth, body_width: document.body.scrollWidth, horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth), shell_navigation: rect('.workbench-sidebar'), topbar: rect('.workbench-topbar'), guest_banner: rect('.guest-mode-banner'), explorer_header: rect('.geo-explorer-header'), live_analysis_journey: rect('.live-analysis-journey'), main: main ? { left: main.left, right: main.right, width: main.width } : null, map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null, theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null, } }) } async function runLandingViewport(browser, baseUrl, outputDir, viewport) { const page = await browser.newPage({ viewport }) const consoleErrors = [] const failedRequests = [] page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) page.on('pageerror', (error) => consoleErrors.push(error.message)) page.on('requestfailed', (request) => { if (request.url().startsWith(baseUrl)) { failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`) } }) try { await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) await page.locator('.landing-page').waitFor({ state: 'visible', timeout: 15_000 }) await auditInteractiveNames(page, `${viewport.width}px landing`) const horizontalOverflow = await page.evaluate(() => ( Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth) )) assert.equal(horizontalOverflow, 0, `${viewport.width}px landing overflows horizontally`) assert.equal( await page.getByRole('heading', { level: 1 }).count(), 1, `${viewport.width}px landing needs one clear primary heading`, ) if (viewport.width <= 760) { const menu = page.locator('.landing-menu-toggle') assert.equal(await menu.getAttribute('aria-label'), 'Navigatie openen') await menu.click() assert.equal(await menu.getAttribute('aria-expanded'), 'true') await page.getByRole('navigation', { name: 'Landingspagina' }).waitFor({ state: 'visible' }) await page.getByRole('button', { name: 'Navigatie sluiten' }).click() } await page.screenshot({ path: path.join(outputDir, `landing-${viewport.width}x${viewport.height}.png`), fullPage: true, }) return { viewport, horizontal_overflow_px: horizontalOverflow, console_errors: consoleErrors, failed_requests: failedRequests, } } finally { await page.close() } } async function runViewport(browser, baseUrl, outputDir, viewport) { const page = await browser.newPage({ viewport }) const consoleErrors = [] const failedRequests = [] page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) page.on('pageerror', (error) => consoleErrors.push(error.message)) page.on('requestfailed', (request) => { if (request.url().startsWith(baseUrl) && request.url().includes('/api/')) { failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`) } }) try { await prepareAuditSession(page, baseUrl) const startedAt = Date.now() await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 }) const readyMs = Date.now() - startedAt await auditInteractiveNames(page, `${viewport.width}px map explorer`) const layout = await layoutEvidence(page) const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels .filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1) .map((label) => label.textContent?.trim() || '')) assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`) assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`) assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`) if (layout.topbar && layout.guest_banner) { assert( layout.topbar.bottom <= layout.guest_banner.top + 1, `${viewport.width}px topbar overlaps the guest access banner`, ) } if (layout.guest_banner && layout.explorer_header) { assert( layout.guest_banner.bottom <= layout.explorer_header.top + 1, `${viewport.width}px guest access banner overlaps the explorer heading`, ) } if (layout.shell_navigation && layout.live_analysis_journey) { const verticalOverlap = Math.min(layout.shell_navigation.bottom, layout.live_analysis_journey.bottom) - Math.max(layout.shell_navigation.top, layout.live_analysis_journey.top) const horizontalOverlap = Math.min(layout.shell_navigation.right, layout.live_analysis_journey.right) - Math.max(layout.shell_navigation.left, layout.live_analysis_journey.left) assert( verticalOverlap <= 1 || horizontalOverlap <= 1, `${viewport.width}px navigation overlaps the live analysis journey`, ) } const currentTab = page.getByRole('tab', { name: 'Laatste toestand' }) const evolutionTab = page.getByRole('tab', { name: 'Evolutie' }) await currentTab.focus() await currentTab.press('ArrowRight') await assert.doesNotReject(() => evolutionTab.waitFor({ state: 'visible' })) assert.equal(await evolutionTab.getAttribute('aria-selected'), 'true') await evolutionTab.press('ArrowLeft') assert.equal(await currentTab.getAttribute('aria-selected'), 'true') await page.screenshot({ path: path.join(outputDir, `viewport-${viewport.width}x${viewport.height}.png`), fullPage: viewport.width <= 480, }) await page.locator('.skip-link').focus() await page.keyboard.press('Enter') assert.equal(await page.locator('#workspace-main').evaluate((element) => document.activeElement === element), true) await page.waitForTimeout(200) return { viewport, ready_ms: readyMs, layout, clipped_navigation_labels: clippedNavigationLabels, console_errors: consoleErrors, failed_requests: failedRequests, } } finally { await page.close() } } async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) { const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }) let delayedDatasetRequests = 0 await page.route('**/api/v1/projects/*/datasets*', async (route) => { delayedDatasetRequests += 1 await new Promise((resolve) => setTimeout(resolve, 1_500)) await route.continue() }) try { const auditSession = await prepareAuditSession(page, baseUrl) await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }) const loadingStatus = page.getByRole('status', { name: '' }).filter({ hasText: 'Databronnen worden gecontroleerd', }) await loadingStatus.waitFor({ state: 'visible', timeout: 15_000 }) const themeStates = await page.locator('.geo-theme-option i').allTextContents() assert(themeStates.length > 0, 'Theme controls did not render during bootstrap') assert(themeStates.every((state) => state.trim() === 'Laden'), 'Bootstrap rendered a definitive missing state') await page.screenshot({ path: path.join(outputDir, 'loading-state.png') }) await loadingStatus.waitFor({ state: 'hidden', timeout: 60_000 }) await page.getByRole('button', { name: 'Geavanceerde werkbank' }).click() await page.getByTestId('map-area-select').waitFor({ state: 'visible', timeout: 30_000 }) await page.waitForFunction( () => document.querySelectorAll('[data-testid="map-area-select"] option').length > 1, undefined, { timeout: 30_000 }, ) await auditInteractiveNames(page, 'advanced map workbench') const areaSelect = page.getByTestId('map-area-select') const options = await areaSelect.locator('option').count() assert(options > 1, 'No persisted Area is available for the performance audit') await areaSelect.selectOption({ index: 1 }) const useArea = page.getByRole('button', { name: 'Begrenzing werkgebied gebruiken' }) await useArea.waitFor({ state: 'visible' }) await useArea.click() await page.getByText(/Dekkingscontrole voltooid in/).waitFor({ state: 'visible', timeout: 30_000 }) await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') }) const auditedWorkspaces = [] const workspaceKeys = ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview'] if (auditSession?.role === 'guest') { assert.equal( await page.getByTestId('workspace-nav-system').count(), 0, 'Guest session exposes operator-only system settings', ) } else { workspaceKeys.push('system') } for (const workspace of workspaceKeys) { await page.getByTestId(`workspace-nav-${workspace}`).click() await page.waitForTimeout(100) await auditInteractiveNames(page, `${workspace} workspace`) if (workspace === 'ai') { await page.screenshot({ path: path.join(outputDir, 'ai-workspace.png'), fullPage: true }) const segmentationDisclosure = page.locator('.segmentation-disclosure') await segmentationDisclosure.scrollIntoViewIfNeeded() await segmentationDisclosure.locator('summary').first().click() await page.waitForTimeout(150) await auditInteractiveNames(page, 'open segmentation lab') await segmentationDisclosure.locator('.ai-lab-run-surface').scrollIntoViewIfNeeded() await page.screenshot({ path: path.join(outputDir, 'ai-segmentation.png') }) } auditedWorkspaces.push(workspace) } return { delayed_dataset_requests: delayedDatasetRequests, loading_state_verified: true, advanced_accessible_names_verified: true, coverage_budget_feedback_verified: true, accessible_workspace_controls_verified: auditedWorkspaces, } } finally { await page.close() } } async function main() { const args = parseArgs(process.argv) const outputDir = path.resolve(args.output) await mkdir(outputDir, { recursive: true }) const browser = await chromium.launch({ headless: true }) const evidence = { schema_version: 1, base_url: args.baseUrl, started_at: new Date().toISOString(), landing_viewports: [], viewports: [], bootstrap: null, status: 'running', } try { const viewports = [ { width: 390, height: 844 }, { width: 1366, height: 768 }, { width: 2560, height: 1080 }, ] for (const viewport of viewports) { evidence.landing_viewports.push(await runLandingViewport(browser, args.baseUrl, outputDir, viewport)) } for (const viewport of viewports) { evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport)) } evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir) const auditedPages = [...evidence.landing_viewports, ...evidence.viewports] const unexpectedConsoleErrors = auditedPages.flatMap((item) => item.console_errors) const unexpectedFailedRequests = auditedPages.flatMap((item) => item.failed_requests) assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors') assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests') evidence.status = 'passed' } catch (error) { evidence.status = 'failed' evidence.error = error instanceof Error ? error.stack || error.message : String(error) throw error } finally { evidence.completed_at = new Date().toISOString() await writeFile(path.join(outputDir, 'manifest.json'), `${JSON.stringify(evidence, null, 2)}\n`) await browser.close() } process.stdout.write(`RC9 UX audit passed: ${path.join(outputDir, 'manifest.json')}\n`) } await main()