Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
import { chromium, request } from 'playwright'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
function parseArguments(argv) {
|
||||
const parsed = {
|
||||
baseUrl: process.env.GE_INTEL_BASE_URL || 'http://127.0.0.1:1202',
|
||||
goldenManifest: process.env.GEOINTEL_GOLDEN_AREA_MANIFEST || '../artifacts/rc8-golden-areas.json',
|
||||
output: process.env.GEOINTEL_RELEASE_JOURNEY_OUTPUT || '../artifacts/rc8-release-journeys',
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === '--base-url') parsed.baseUrl = argv[++index]
|
||||
else if (value === '--golden-manifest') parsed.goldenManifest = argv[++index]
|
||||
else if (value === '--output') parsed.output = argv[++index]
|
||||
else throw new Error(`Unknown release journey argument: ${value}`)
|
||||
}
|
||||
parsed.baseUrl = parsed.baseUrl.replace(/\/$/, '')
|
||||
return parsed
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function responseJson(response, label, { envelope = true } = {}) {
|
||||
const text = await response.text()
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`${label} returned non-JSON data: ${text.slice(0, 300)}`)
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${label} failed with HTTP ${response.status()}: ${JSON.stringify(payload)}`)
|
||||
}
|
||||
if (!envelope) return payload
|
||||
assert(payload && Object.hasOwn(payload, 'data'), `${label} did not return the canonical data envelope`)
|
||||
return payload.data
|
||||
}
|
||||
|
||||
async function apiCall(api, method, url, data, options = {}) {
|
||||
const response = await api.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
timeout: options.timeout || 60_000,
|
||||
})
|
||||
return responseJson(response, options.label || `${method} ${url}`, options)
|
||||
}
|
||||
|
||||
function bboxForArea(area) {
|
||||
return {
|
||||
min_x: area.bbox.minx,
|
||||
min_y: area.bbox.miny,
|
||||
max_x: area.bbox.maxx,
|
||||
max_y: area.bbox.maxy,
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
function coverageBbox(area) {
|
||||
return {
|
||||
minx: area.bbox.minx,
|
||||
miny: area.bbox.miny,
|
||||
maxx: area.bbox.maxx,
|
||||
maxy: area.bbox.maxy,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilEnabled(locator, timeout = 20_000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeout) {
|
||||
if (await locator.isEnabled().catch(() => false)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error(`Control did not become enabled: ${await locator.getAttribute('aria-label').catch(() => '')}`)
|
||||
}
|
||||
|
||||
async function selectProject(page, projectId) {
|
||||
await page.getByTestId('workspace-nav-data').click()
|
||||
await page.getByTestId('project-panel').waitFor({ state: 'visible' })
|
||||
const selector = page.getByTestId(`project-select-${projectId}`)
|
||||
if (!await selector.isVisible().catch(() => false)) {
|
||||
const management = page.locator('details.technical-management-block')
|
||||
if (!await management.getAttribute('open')) {
|
||||
await management.locator(':scope > summary').click()
|
||||
}
|
||||
const alternatives = management.locator('details.technical-run-list')
|
||||
if (await alternatives.count() && !await alternatives.getAttribute('open')) {
|
||||
await alternatives.locator(':scope > summary').click()
|
||||
}
|
||||
}
|
||||
await selector.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
const dataResponse = page.waitForResponse(
|
||||
(response) => response.url().includes(`/api/v1/projects/${projectId}/datasets`) && response.ok(),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
await selector.click()
|
||||
await dataResponse
|
||||
}
|
||||
|
||||
async function runApiJourneys(api, goldenManifest, evidence) {
|
||||
const ready = await apiCall(api, 'GET', '/health/ready', undefined, {
|
||||
label: 'readiness',
|
||||
envelope: false,
|
||||
})
|
||||
assert(ready.status === 'ok', `Runtime readiness is ${ready.status}`)
|
||||
evidence.runtime = {
|
||||
build_sha: ready.build_sha,
|
||||
build_time: ready.build_time,
|
||||
postgis: ready.postgis,
|
||||
migration: ready.migration,
|
||||
}
|
||||
|
||||
const projects = await apiCall(api, 'GET', '/api/v1/projects?limit=200')
|
||||
const projectsByName = new Map(projects.items.map((project) => [project.name, project]))
|
||||
const nationalProject = projectsByName.get('Belgium and North Sea Workbench')
|
||||
const molProject = projectsByName.get('Mol Municipality Workbench')
|
||||
assert(nationalProject, 'National release workspace is missing')
|
||||
assert(molProject, 'Mol regression workspace is missing')
|
||||
|
||||
const coverageResults = []
|
||||
for (const area of goldenManifest.areas) {
|
||||
const result = await apiCall(api, 'POST', '/api/v1/external/coverage/resolve', {
|
||||
project_id: nationalProject.id,
|
||||
bbox: coverageBbox(area),
|
||||
themes: ['admin', 'buildings', 'population', 'bathymetry'],
|
||||
})
|
||||
for (const zone of area.expected_zones) {
|
||||
assert(result.intersected_zones.includes(zone), `${area.key} did not resolve expected zone ${zone}`)
|
||||
}
|
||||
coverageResults.push({
|
||||
key: area.key,
|
||||
zones: result.intersected_zones,
|
||||
outside_supported_scope: result.outside_supported_scope,
|
||||
statuses: result.items.map((item) => ({
|
||||
zone: item.zone,
|
||||
theme: item.theme,
|
||||
status: item.status,
|
||||
})),
|
||||
warnings: result.warnings,
|
||||
})
|
||||
}
|
||||
evidence.coverage = coverageResults
|
||||
|
||||
const outsideCoverage = await apiCall(api, 'POST', '/api/v1/external/coverage/resolve', {
|
||||
project_id: nationalProject.id,
|
||||
bbox: { minx: 10.0, miny: 55.0, maxx: 10.1, maxy: 55.1 },
|
||||
themes: ['admin'],
|
||||
})
|
||||
assert(outsideCoverage.outside_supported_scope, 'Outside-scope selection was not marked outside')
|
||||
assert(outsideCoverage.intersected_zones.length === 0, 'Outside-scope selection resolved a Belgian zone')
|
||||
|
||||
const northSeaArea = goldenManifest.areas.find((area) => area.key === 'north_sea_multi_zone')
|
||||
const northSeaCoverage = coverageResults.find((result) => result.key === 'north_sea_multi_zone')
|
||||
assert(northSeaArea && northSeaCoverage, 'North Sea golden Area evidence is missing')
|
||||
assert(
|
||||
northSeaCoverage.statuses.some((item) => item.theme === 'population' && item.status === 'unsupported'),
|
||||
'Unsupported North Sea population metric was not explicit',
|
||||
)
|
||||
assert(
|
||||
coverageResults.some((result) => result.statuses.some((item) => item.status === 'partial')),
|
||||
'No golden selection exercised partial source coverage',
|
||||
)
|
||||
evidence.edge_cases = {
|
||||
outside_scope: {
|
||||
zones: outsideCoverage.intersected_zones,
|
||||
warning: outsideCoverage.warnings[0],
|
||||
},
|
||||
partial_coverage: true,
|
||||
unsupported_north_sea_population: true,
|
||||
}
|
||||
|
||||
const molArea = goldenManifest.areas.find((area) => area.key === 'mol_municipality')
|
||||
assert(molArea, 'Mol golden Area evidence is missing')
|
||||
assert(molArea.source_area_id, 'Mol regression source Area evidence is missing')
|
||||
const temporalSeries = await apiCall(api, 'GET', `/api/v1/projects/${molProject.id}/temporal/series`)
|
||||
const forestSeries = temporalSeries.items.find(
|
||||
(series) => series.temporal_series_key === 'department-omgeving:land-use:forest:mol',
|
||||
)
|
||||
assert(forestSeries && forestSeries.datasets.length >= 2, 'Mol forest history has fewer than two snapshots')
|
||||
const earlier = forestSeries.datasets[0]
|
||||
const later = forestSeries.datasets[forestSeries.datasets.length - 1]
|
||||
const temporal = await apiCall(api, 'POST', `/api/v1/projects/${molProject.id}/temporal/compare`, {
|
||||
earlier_dataset_id: earlier.id,
|
||||
later_dataset_id: later.id,
|
||||
bbox: bboxForArea(molArea),
|
||||
area_id: molArea.source_area_id,
|
||||
preview_limit: 100,
|
||||
}, { timeout: 120_000, label: 'Mol temporal comparison' })
|
||||
assert(temporal.metric && Number.isFinite(temporal.metric.earlier_value), 'Temporal comparison has no governed metric')
|
||||
evidence.temporal = {
|
||||
series: temporal.temporal_series_key,
|
||||
earlier: temporal.earlier,
|
||||
later: temporal.later,
|
||||
metric: temporal.metric,
|
||||
warning_count: temporal.warnings.length,
|
||||
}
|
||||
|
||||
const molDatasets = await apiCall(api, 'GET', `/api/v1/projects/${molProject.id}/datasets?limit=200`)
|
||||
const exportDataset = molDatasets.items.find(
|
||||
(dataset) => dataset.source_name === 'statbel' && dataset.dataset_type === 'vector',
|
||||
)
|
||||
assert(exportDataset, 'No bounded Mol vector dataset is available for export')
|
||||
const exported = await apiCall(api, 'POST', '/api/v1/exports/map-result', {
|
||||
project_id: molProject.id,
|
||||
mode: 'current',
|
||||
bbox: bboxForArea(molArea),
|
||||
dataset_id: exportDataset.id,
|
||||
area_id: molArea.source_area_id,
|
||||
theme_id: 'population',
|
||||
name: 'RC8 Mol population map result',
|
||||
}, { timeout: 120_000, label: 'bounded map export' })
|
||||
assert(exported.status === 'ready', `Map result export status is ${exported.status}`)
|
||||
const exportContent = await apiCall(api, 'GET', `/api/v1/exports/${exported.export_id}/content`)
|
||||
assert(exportContent.content?.type === 'FeatureCollection', 'Persisted map export is not GeoJSON')
|
||||
evidence.export = {
|
||||
export_id: exported.export_id,
|
||||
export_type: exported.export_type,
|
||||
feature_count: exportContent.content.features.length,
|
||||
}
|
||||
|
||||
const assistantStatus = await apiCall(api, 'GET', '/api/v1/assistant/status')
|
||||
assert(assistantStatus.enabled && assistantStatus.reachable, 'Configured Ollama assistant is not reachable')
|
||||
evidence.assistant = {
|
||||
status: assistantStatus.status,
|
||||
model: assistantStatus.default_model,
|
||||
api_context_verified: false,
|
||||
}
|
||||
|
||||
const demo = await apiCall(api, 'POST', '/api/v1/demo/workflow', {})
|
||||
assert(demo.status === 'ready' && demo.raster_dataset_id, 'Demo PostGIS fixture is not ready')
|
||||
const modelAssets = await apiCall(api, 'GET', '/api/v1/detection/model-assets')
|
||||
const preferredAsset = modelAssets.items.find((asset) => asset.active)
|
||||
|| modelAssets.items.find((asset) => asset.status === 'available')
|
||||
assert(preferredAsset, 'No local YOLO model asset is available')
|
||||
evidence.detection = {
|
||||
project_id: demo.project_id,
|
||||
raster_dataset_id: demo.raster_dataset_id,
|
||||
model_asset_id: preferredAsset.model_asset_id,
|
||||
analysis_run_id: null,
|
||||
status: 'pending_browser_run',
|
||||
}
|
||||
|
||||
return {
|
||||
nationalProject,
|
||||
molProject,
|
||||
molArea,
|
||||
demo,
|
||||
preferredAsset,
|
||||
}
|
||||
}
|
||||
|
||||
async function runBrowserJourneys(baseUrl, browserData, goldenManifest, outputDir, evidence) {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
|
||||
const consoleErrors = []
|
||||
const failedRequests = []
|
||||
let expectedCoverageFailure = false
|
||||
let expectedCoverageConsoleErrors = 0
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return
|
||||
if (
|
||||
expectedCoverageConsoleErrors > 0
|
||||
&& /Failed to load resource: net::ERR_CONNECTION_FAILED/i.test(message.text())
|
||||
) {
|
||||
expectedCoverageConsoleErrors -= 1
|
||||
return
|
||||
}
|
||||
consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => consoleErrors.push(error.message))
|
||||
page.on('requestfailed', (requestValue) => {
|
||||
if (requestValue.url().startsWith(baseUrl) && requestValue.url().includes('/api/')) {
|
||||
if (!(expectedCoverageFailure && requestValue.url().includes('/external/coverage/resolve'))) {
|
||||
failedRequests.push(`${requestValue.method()} ${requestValue.url()}: ${requestValue.failure()?.errorText}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
page.on('response', (response) => {
|
||||
if (response.url().startsWith(baseUrl) && response.url().includes('/api/') && response.status() >= 500) {
|
||||
failedRequests.push(`${response.request().method()} ${response.url()}: HTTP ${response.status()}`)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
|
||||
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await page.screenshot({ path: path.join(outputDir, '01-belgium-map.png') })
|
||||
|
||||
await page.getByRole('button', { name: 'Geavanceerde werkbank' }).click()
|
||||
await page.getByTestId('map-area-select').waitFor({ state: 'visible' })
|
||||
const nationalAreas = goldenManifest.areas
|
||||
const browserCoverage = []
|
||||
for (const area of nationalAreas) {
|
||||
await page.getByTestId('map-area-select').selectOption(area.area_id)
|
||||
const useAreaButton = page.getByRole('button', { name: 'Begrenzing werkgebied gebruiken' })
|
||||
await waitUntilEnabled(useAreaButton)
|
||||
const coverageResponse = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/external/coverage/resolve') && response.ok(),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
await useAreaButton.click()
|
||||
const response = await coverageResponse
|
||||
const coverage = await responseJson(response, `browser coverage ${area.key}`)
|
||||
for (const zone of area.expected_zones) {
|
||||
assert(coverage.intersected_zones.includes(zone), `Browser journey ${area.key} missed ${zone}`)
|
||||
}
|
||||
await page.locator('.coverage-resolution-surface').waitFor({ state: 'visible' })
|
||||
browserCoverage.push({ key: area.key, zones: coverage.intersected_zones })
|
||||
}
|
||||
evidence.browser.coverage = browserCoverage
|
||||
await page.screenshot({ path: path.join(outputDir, '02-national-coverage.png') })
|
||||
|
||||
const providerFailureArea = nationalAreas[0]
|
||||
expectedCoverageFailure = true
|
||||
expectedCoverageConsoleErrors = 1
|
||||
await page.route('**/api/v1/external/coverage/resolve', (route) => route.abort('connectionfailed'))
|
||||
await page.getByTestId('map-area-select').selectOption(providerFailureArea.area_id)
|
||||
const failedUseAreaButton = page.getByRole('button', { name: 'Begrenzing werkgebied gebruiken' })
|
||||
await waitUntilEnabled(failedUseAreaButton)
|
||||
await failedUseAreaButton.click()
|
||||
await page.locator('.coverage-resolution-surface .error').waitFor({ state: 'visible', timeout: 15_000 })
|
||||
evidence.browser.provider_failure_state = await page.locator('.coverage-resolution-surface .error').innerText()
|
||||
await page.unroute('**/api/v1/external/coverage/resolve')
|
||||
expectedCoverageFailure = false
|
||||
|
||||
await selectProject(page, browserData.molProject.id)
|
||||
await page.getByTestId('workspace-nav-map').click()
|
||||
const backToExplorer = page.getByRole('button', { name: 'Terug naar gebiedsverkenner' })
|
||||
if (await backToExplorer.isVisible().catch(() => false)) await backToExplorer.click()
|
||||
await page.getByLabel('Werkgebied').selectOption(browserData.molArea.source_area_id)
|
||||
const forestTheme = page.getByRole('button', { name: /^Bos & groen/ })
|
||||
await waitUntilEnabled(forestTheme)
|
||||
await forestTheme.click()
|
||||
const fullArea = page.getByRole('button', { name: 'Volledig werkgebied' })
|
||||
await waitUntilEnabled(fullArea)
|
||||
await fullArea.click()
|
||||
await page.locator('.geo-primary-metrics').waitFor({ state: 'visible', timeout: 120_000 })
|
||||
const currentMetrics = await page.locator('.geo-primary-metrics').innerText()
|
||||
assert(/ha|km2|objecten|inwoners/i.test(currentMetrics), 'Mol map did not render a governed metric')
|
||||
const sourceSummary = await page.locator('.geo-source-summary').innerText()
|
||||
assert(!/Geen databron beschikbaar/i.test(sourceSummary), 'Mol map provenance has no active source')
|
||||
evidence.browser.current_metric = currentMetrics
|
||||
evidence.browser.current_provenance = sourceSummary
|
||||
await page.screenshot({ path: path.join(outputDir, '03-mol-current-metric.png') })
|
||||
|
||||
await page.getByRole('tab', { name: 'Evolutie' }).click()
|
||||
const evolutionForest = page.getByRole('button', { name: /^Bos & groen/ })
|
||||
await waitUntilEnabled(evolutionForest)
|
||||
await evolutionForest.click()
|
||||
const compareResponse = page.waitForResponse(
|
||||
(response) => response.url().includes('/temporal/compare') && response.ok(),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
await page.getByRole('button', { name: 'Volledig werkgebied' }).click()
|
||||
await compareResponse
|
||||
await page.locator('.geo-temporal-metrics').waitFor({ state: 'visible', timeout: 120_000 })
|
||||
evidence.browser.temporal_metric = await page.locator('.geo-temporal-metrics').innerText()
|
||||
await page.screenshot({ path: path.join(outputDir, '04-mol-evolution.png') })
|
||||
|
||||
const exportResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/exports/map-result') && response.ok(),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
await page.getByRole('button', { name: 'Bewaar in downloads' }).click()
|
||||
const browserExportResponse = await exportResponsePromise
|
||||
const browserExport = await responseJson(browserExportResponse, 'browser map export')
|
||||
evidence.browser.export_id = browserExport.export_id
|
||||
await page.getByTestId('workspace-nav-exports').waitFor({ state: 'visible' })
|
||||
await page.screenshot({ path: path.join(outputDir, '05-export-center.png') })
|
||||
|
||||
await page.getByTestId('workspace-nav-assistant').click()
|
||||
await page.getByTestId('geo-assistant-panel').waitFor({ state: 'visible' })
|
||||
const question = 'Vat de gemeten evolutie en belangrijkste bronnen voor dit geselecteerde gebied samen in maximaal drie zinnen.'
|
||||
await page.getByLabel('Vraag over het actieve gebied').fill(question)
|
||||
const assistantResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/assistant/query') && response.ok(),
|
||||
{ timeout: 240_000 },
|
||||
)
|
||||
await page.getByRole('button', { name: 'Stel vraag' }).click()
|
||||
const assistantResponse = await assistantResponsePromise
|
||||
const assistant = await responseJson(assistantResponse, 'browser Ollama query')
|
||||
assert(assistant.answer?.trim().length > 20, 'Ollama returned no usable answer')
|
||||
await page.locator('.assistant-message-assistant').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
evidence.assistant.api_context_verified = true
|
||||
evidence.assistant.answer_length = assistant.answer.length
|
||||
evidence.assistant.context_metric_count = assistant.context_metrics.length
|
||||
evidence.assistant.temporal_series_count = assistant.temporal_series.length
|
||||
evidence.assistant.source_dataset_count = assistant.source_dataset_ids.length
|
||||
await page.screenshot({ path: path.join(outputDir, '06-ollama-context.png') })
|
||||
|
||||
await selectProject(page, browserData.demo.project_id)
|
||||
await page.getByTestId('workspace-nav-ai').click()
|
||||
const detectionPanel = page.locator('.detection-lab-shell')
|
||||
await detectionPanel.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
const detectionRunSelects = detectionPanel.locator('.lab-form-grid select')
|
||||
await detectionRunSelects.first().waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await detectionRunSelects.nth(0).selectOption(browserData.demo.raster_dataset_id)
|
||||
await detectionRunSelects.nth(1).selectOption('yolo-configured')
|
||||
const localModelDetails = detectionPanel.locator('details[aria-label="Lokale modelkeuze"]')
|
||||
if (!await localModelDetails.getAttribute('open')) {
|
||||
await localModelDetails.locator(':scope > summary').click()
|
||||
}
|
||||
await localModelDetails.locator('select').selectOption(browserData.preferredAsset.model_asset_id)
|
||||
await detectionPanel.locator('.ai-lab-run-surface .lab-form-grid input[type="number"]').fill('0.50')
|
||||
const detectionAction = detectionPanel.getByRole('button', { name: 'Gebouwen zoeken en op kaart tonen' })
|
||||
await waitUntilEnabled(detectionAction, 30_000)
|
||||
const detectionResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/detection/run') && response.ok(),
|
||||
{ timeout: 240_000 },
|
||||
)
|
||||
await detectionAction.click()
|
||||
const detectionResponse = await detectionResponsePromise
|
||||
const detection = await responseJson(detectionResponse, 'browser configured YOLO run')
|
||||
assert(detection.status === 'success', `Configured YOLO browser run status is ${detection.status}`)
|
||||
evidence.detection.analysis_run_id = detection.analysis_run_id
|
||||
evidence.detection.job_id = detection.job_id
|
||||
evidence.detection.detection_count = detection.detection_count
|
||||
evidence.detection.status = detection.status
|
||||
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 60_000 })
|
||||
const postDetectionMapText = await page.getByTestId('map-workspace').innerText()
|
||||
assert(
|
||||
/detectie|gebouw|analyse/i.test(postDetectionMapText),
|
||||
'Successful detection did not hand its persisted result back to the map',
|
||||
)
|
||||
evidence.detection.map_handoff_verified = true
|
||||
await page.screenshot({ path: path.join(outputDir, '07-configured-yolo.png') })
|
||||
|
||||
const frameworkErrorText = await page.locator('body').innerText()
|
||||
assert(
|
||||
!/Unhandled Runtime Error|Cannot read properties|ReferenceError|TypeError:/i.test(frameworkErrorText),
|
||||
'A frontend runtime error is visible',
|
||||
)
|
||||
} finally {
|
||||
evidence.browser.console_errors = consoleErrors
|
||||
evidence.browser.failed_requests = failedRequests
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
assert(consoleErrors.length === 0, `Browser console errors: ${consoleErrors.join('\n')}`)
|
||||
assert(failedRequests.length === 0, `Unexpected API failures: ${failedRequests.join('\n')}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArguments(process.argv.slice(2))
|
||||
const goldenManifest = JSON.parse(await readFile(args.goldenManifest, 'utf8'))
|
||||
assert(goldenManifest.area_count === 7, `Expected seven golden Areas, got ${goldenManifest.area_count}`)
|
||||
await mkdir(args.output, { recursive: true })
|
||||
const evidence = {
|
||||
schema_version: 1,
|
||||
started_at: new Date().toISOString(),
|
||||
base_url: args.baseUrl,
|
||||
golden_area_manifest: path.resolve(args.goldenManifest),
|
||||
runtime: null,
|
||||
coverage: [],
|
||||
edge_cases: {},
|
||||
temporal: null,
|
||||
export: null,
|
||||
assistant: null,
|
||||
detection: null,
|
||||
browser: {
|
||||
coverage: [],
|
||||
provider_failure_state: null,
|
||||
current_metric: null,
|
||||
current_provenance: null,
|
||||
temporal_metric: null,
|
||||
export_id: null,
|
||||
console_errors: [],
|
||||
failed_requests: [],
|
||||
},
|
||||
status: 'running',
|
||||
}
|
||||
const evidencePath = path.join(args.output, 'manifest.json')
|
||||
const api = await request.newContext({ baseURL: args.baseUrl })
|
||||
try {
|
||||
const browserData = await runApiJourneys(api, goldenManifest, evidence)
|
||||
await runBrowserJourneys(args.baseUrl, browserData, goldenManifest, args.output, evidence)
|
||||
evidence.status = 'passed'
|
||||
evidence.completed_at = new Date().toISOString()
|
||||
} catch (error) {
|
||||
evidence.status = 'failed'
|
||||
evidence.completed_at = new Date().toISOString()
|
||||
evidence.error = error instanceof Error ? error.stack || error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
await api.dispose()
|
||||
await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8')
|
||||
console.log(`Release journey evidence: ${evidencePath}`)
|
||||
}
|
||||
console.log('RC8 Belgium/North Sea release journeys passed')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,422 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user