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) })