423 lines
19 KiB
JavaScript
423 lines
19 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 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()
|
|
const themeList = document.querySelector('.geo-theme-list')?.getBoundingClientRect()
|
|
const firstTheme = document.querySelector('.geo-theme-option')?.getBoundingClientRect()
|
|
const sourceSummary = document.querySelector('.geo-source-summary')?.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,
|
|
theme_list: themeList ? { top: themeList.top, bottom: themeList.bottom, height: themeList.height } : null,
|
|
first_theme: firstTheme ? { top: firstTheme.top, bottom: firstTheme.bottom, height: firstTheme.height } : null,
|
|
source_summary: sourceSummary ? { top: sourceSummary.top, bottom: sourceSummary.bottom, height: sourceSummary.height } : 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 {
|
|
const auditSession = 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`)
|
|
if (auditSession?.role === 'guest') {
|
|
const exposedAcquisitionActions = await page.locator('.geo-theme-option').evaluateAll((buttons) => buttons
|
|
.filter((button) => button.textContent?.includes('Op aanvraag') && !button.disabled)
|
|
.map((button) => button.textContent?.trim() || ''))
|
|
assert(
|
|
exposedAcquisitionActions.length > 0,
|
|
'Guest UI must expose bounded official source acquisition inside the signed demo project',
|
|
)
|
|
}
|
|
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 (viewport.width === 1366 && layout.theme && layout.theme_list && layout.first_theme) {
|
|
assert(
|
|
layout.theme_list.height >= layout.first_theme.height,
|
|
'1366px theme list is shorter than one selectable theme row',
|
|
)
|
|
assert(
|
|
!layout.source_summary || layout.source_summary.bottom <= layout.theme.bottom + 1,
|
|
'1366px source summary falls outside the theme column',
|
|
)
|
|
}
|
|
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')
|
|
|
|
const coordinateSelection = page.locator('.geo-coordinate-selection')
|
|
await coordinateSelection.locator('summary').focus()
|
|
await coordinateSelection.locator('summary').press('Enter')
|
|
assert.equal(await coordinateSelection.getAttribute('open'), '')
|
|
await coordinateSelection.locator('summary').press('Enter')
|
|
|
|
const insightsToggle = page.getByRole('button', { name: 'Open inzichten' })
|
|
await insightsToggle.click()
|
|
const openDrawer = await page.locator('#geo-explorer-results').evaluate((element) => {
|
|
const bounds = element.getBoundingClientRect()
|
|
return { left: bounds.left, right: bounds.right, width: bounds.width, expanded: element.getAttribute('aria-hidden') }
|
|
})
|
|
assert(openDrawer.left < viewport.width - 100, 'Explicitly opened insights drawer remains off-screen')
|
|
assert(openDrawer.right <= viewport.width + 1, 'Explicitly opened insights drawer exceeds the viewport')
|
|
assert.equal(openDrawer.expanded, 'false')
|
|
await page.getByRole('button', { name: 'Sluit inzichten' }).click()
|
|
|
|
const themeToggle = page.locator('.workbench-theme-toggle')
|
|
await themeToggle.click()
|
|
const mapControlIcon = page.locator('.maplibregl-ctrl-icon').first()
|
|
if (await mapControlIcon.count()) {
|
|
assert.equal(await mapControlIcon.evaluate((element) => getComputedStyle(element).filter), 'none')
|
|
}
|
|
await themeToggle.click()
|
|
|
|
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()
|