Files
geointel/frontend/e2e/uxAudit.mjs
T
Codex 9b0239747d
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
Harden RC9 UX and performance states
2026-07-18 06:37:54 +02:00

237 lines
9.8 KiB
JavaScript

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 layoutEvidence(page) {
return page.evaluate(() => {
const root = document.documentElement
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),
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 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 {
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)
assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`)
assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`)
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,
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 {
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 = []
for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) {
await page.getByTestId(`workspace-nav-${workspace}`).click()
await page.waitForTimeout(100)
await auditInteractiveNames(page, `${workspace} workspace`)
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(),
viewports: [],
bootstrap: null,
status: 'running',
}
try {
for (const viewport of [
{ width: 390, height: 844 },
{ width: 1366, height: 768 },
{ width: 2560, height: 1080 },
]) {
evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport))
}
evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir)
const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors)
const unexpectedFailedRequests = evidence.viewports.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()