Automate RC8 release journeys
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

This commit is contained in:
Codex
2026-07-18 06:07:44 +02:00
parent 0fae53a7de
commit 55685ba1bd
22 changed files with 2665 additions and 11 deletions
+15
View File
@@ -34,6 +34,21 @@
download.
- Passed the RC-7 release gate with 1,008 backend tests, frontend
typecheck/build, one Alembic head and offline migration SQL generation.
- Added 12 executable frontend unit tests for map selection, coverage,
temporal comparison and workbench bootstrap behavior.
- Added deterministic provisioning for seven Belgium/North Sea golden areas:
Mol, Kempen, Wallonia, Brussels, the language boundary, the coast and the
offshore multi-zone scope.
- Added a Playwright release runner that proves governed metrics/provenance,
historical comparison, no-data, partial/unsupported coverage, provider
failure, persisted export, real local Ollama context and an explicit
configured-YOLO run against live PostGIS.
- Fixed explicit demo provisioning so an archived technical demo project is
reactivated and remains selectable instead of being silently reused while
hidden.
- Passed the RC-8 release gate with 1,012 backend tests, 12 frontend unit
tests, frontend typecheck/build, one Alembic head and a clean live E2E
console/request audit.
- Added a read-only release-evidence manifest command with Git, migration,
dependency, configuration checksum and optional live endpoint evidence.
- Replaced the obsolete pre-build status with the current implemented
+31
View File
@@ -1889,3 +1889,34 @@ python scripts/verify_python_lock.py
The complete gate and vulnerability/SBOM policy are documented in
`docs/CI_SUPPLY_CHAIN.md`.
## Release golden areas
The RC browser suite uses seven deterministic, bounded regression areas across
Belgium and the Belgian North Sea. Preview the required changes without
mutating the runtime:
```bash
python scripts/provision_release_golden_areas.py \
--base-url http://127.0.0.1:8000 \
--output artifacts/rc8-golden-areas.json
```
Create only missing Areas through the canonical project/area APIs:
```bash
python scripts/provision_release_golden_areas.py \
--base-url http://127.0.0.1:8000 \
--output artifacts/rc8-golden-areas.json \
--apply
```
The operator copies the governed Mol and Kempen geometries into the national
workbench with their source project/Area identifiers and provisions bounded
Wallonia, Brussels, language-boundary, coast and offshore multi-zone Areas.
Every geometry receives a deterministic SHA-256 fingerprint in the evidence
file. It never imports provider data or writes directly to database tables.
Explicit demo seeding also reactivates its own archived technical project.
This keeps the opt-in fixture workflow selectable without changing the normal
active-project lifecycle.
+14 -1
View File
@@ -102,6 +102,16 @@ class DemoWorkflowService:
return project
return projects[0] if projects else None
@staticmethod
def _activate_explicit_demo_project(db: Session, project: Project | None) -> Project | None:
if project is None or project.status == "active":
return project
project.status = "active"
db.add(project)
db.commit()
db.refresh(project)
return project
@staticmethod
def _has_complete_demo_state(db: Session, project_id: UUID) -> bool:
area = db.query(Area).filter(Area.project_id == project_id).first()
@@ -398,7 +408,10 @@ class DemoWorkflowService:
@staticmethod
def seed(db: Session) -> DemoWorkflowResponse:
existing = DemoWorkflowService._find_existing_project(db)
existing = DemoWorkflowService._activate_explicit_demo_project(
db,
DemoWorkflowService._find_existing_project(db),
)
reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson")
candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson")
if existing:
@@ -0,0 +1,74 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = ROOT / "scripts" / "provision_release_golden_areas.py"
def load_operator():
spec = importlib.util.spec_from_file_location("provision_release_golden_areas_test", SCRIPT_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_release_golden_area_contract_covers_belgium_and_north_sea() -> None:
module = load_operator()
created_keys = {item["key"] for item in module.GOLDEN_AREAS}
source_keys = {item["key"] for item in module.SOURCE_AREAS}
assert created_keys == {
"wallonia_urban_rural",
"brussels_urban",
"language_boundary",
"coast_land_sea",
"north_sea_multi_zone",
}
assert source_keys == {"mol_municipality", "kempen_region"}
assert len(created_keys | source_keys) == 7
assert all(item["source_project"] != module.NATIONAL_PROJECT for item in module.SOURCE_AREAS)
expected_zones = {
zone
for definition in (*module.GOLDEN_AREAS, *module.SOURCE_AREAS)
for zone in definition["expected_zones"]
}
assert {
"flanders",
"wallonia",
"brussels",
"territorial_sea",
"exclusive_economic_zone",
"continental_shelf",
} <= expected_zones
def test_release_golden_area_geometries_are_bounded_and_fingerprintable() -> None:
module = load_operator()
hashes = set()
for definition in module.GOLDEN_AREAS:
bbox = module.geometry_bbox(definition["geometry"])
assert -180 <= bbox["minx"] < bbox["maxx"] <= 180
assert -90 <= bbox["miny"] < bbox["maxy"] <= 90
assert bbox["maxx"] - bbox["minx"] <= 0.25
assert bbox["maxy"] - bbox["miny"] <= 0.20
digest = module.canonical_hash(definition["geometry"])
assert len(digest) == 64
hashes.add(digest)
assert len(hashes) == len(module.GOLDEN_AREAS)
def test_rc8_runner_and_container_operator_are_release_wired() -> None:
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
package = (ROOT / "frontend" / "package.json").read_text(encoding="utf-8")
assert "npm run test:unit" in readiness
assert '--check frontend/e2e/releaseJourneys.mjs' in readiness
assert "bash -n scripts/run_rc8_release_journeys.sh" in readiness
assert "COPY scripts/provision_release_golden_areas.py" in dockerfile
assert '"test:e2e": "node e2e/releaseJourneys.mjs"' in package
@@ -5,6 +5,7 @@ from uuid import uuid4
from fastapi.testclient import TestClient
from app.main import app
from app.models import Project
from app.schemas.demo import DemoWorkflowResponse
from app.services.demo_workflow_service import DemoWorkflowService
@@ -80,3 +81,30 @@ def test_demo_workflow_prefers_complete_existing_demo_project() -> None:
assert "order_by(Project.created_at.asc())" in service
assert "if DemoWorkflowService._has_complete_demo_state(db, project.id):" in service
assert "return projects[0] if projects else None" in service
def test_explicit_demo_seed_reactivates_an_archived_fixture_project() -> None:
project = Project(id=uuid4(), name=DemoWorkflowService.PROJECT_NAME, status="archived")
class Session:
added = []
commits = 0
refreshed = []
def add(self, value) -> None:
self.added.append(value)
def commit(self) -> None:
self.commits += 1
def refresh(self, value) -> None:
self.refreshed.append(value)
db = Session()
result = DemoWorkflowService._activate_explicit_demo_project(db, project)
assert result is project
assert project.status == "active"
assert db.added == [project]
assert db.commits == 1
assert db.refreshed == [project]
+1
View File
@@ -111,6 +111,7 @@ COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_ti
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
COPY scripts/provision_release_golden_areas.py /app/scripts/provision_release_golden_areas.py
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
COPY scripts/provision_regional_grb_context.py /app/scripts/provision_regional_grb_context.py
COPY scripts/audit_source_freshness.py /app/scripts/audit_source_freshness.py
+36
View File
@@ -10546,3 +10546,39 @@ Decision:
- RC-7 is complete. RC-8 automated frontend and browser release journeys are
active.
## 2026-07-18 - Belgium/North Sea RC-8 release journey automation
Implemented:
- Added four Vitest suites with 12 executable tests for map selection,
coverage resolution, temporal comparison and bootstrap state.
- Added a dry-run-first golden-area operator. It reuses Mol/Kempen and creates
only missing bounded Wallonia, Brussels, language-boundary, coast and
offshore Areas through canonical APIs, with deterministic geometry hashes.
- Added a Playwright release runner and shell wrapper covering every golden
area, governed Mol metrics/provenance, compatible forest history, no-data,
partial coverage, unsupported maritime metrics, simulated provider failure,
persisted map export, real local Ollama context and explicit configured
local YOLO execution.
- Fixed explicit demo seeding so its archived technical project is reactivated
before reuse. A direct service regression protects this lifecycle behavior.
- Added the frontend tests and static E2E/script checks to readiness and copied
the golden-area operator into the all-in-one image.
Validation:
- Repository readiness passed backend compilation, 1,012 backend tests, four
frontend test files with 12 tests, frontend typecheck/build and Alembic head
`202607160001`.
- The live runner completed all Belgium/North Sea journeys against PostGIS
3.6. It persisted an export, received a grounded Ollama answer and completed
configured local YOLO execution through Job and AnalysisRun persistence.
- The successful evidence manifest reports no unexpected browser-console or
failed-request events. Re-running the area operator created zero duplicate
Areas and retained seven unique geometry fingerprints.
Decision:
- RC-8 is complete. RC-9 loading, accessibility and performance hardening is
active.
+28 -2
View File
@@ -151,8 +151,8 @@ editions and licences must still pass source-specific probes before activation.
| RC-5 | complete | deployment, secrets, configuration, fresh install and rollback |
| RC-6 | complete | complete CI, dependency and supply-chain gates |
| RC-7 | complete | critical API envelope typing and contract validation |
| RC-8 | in progress | frontend and browser E2E release journeys |
| RC-9 | pending | loading, accessibility and performance hardening |
| RC-8 | complete | frontend and browser E2E release journeys |
| RC-9 | in progress | loading, accessibility and performance hardening |
| RC-10 | pending | retention, cleanup and national data operations |
| RC-11 | pending | final package, upgrade proof, release tag and handoff |
@@ -428,6 +428,16 @@ offline migration SQL generation.
## RC-8 - Release journey automation
**State: complete.** Four Vitest suites protect selection, coverage, temporal
comparison and bootstrap behavior with 12 tests. The Playwright release runner
provisions seven deterministic land, cross-region, coastal and maritime
golden areas in the national workbench through canonical APIs and executes the complete browser/API
journey against the live PostGIS runtime. Evidence includes governed Mol
metrics and provenance, compatible forest history, no-data, partial coverage,
unsupported maritime metrics, simulated provider failure, persistent export,
real local Ollama context and an explicit configured-YOLO run. The green run
contains no unexpected browser-console or failed-request events.
### Work
- add frontend unit tests for selection, coverage, loading and temporal guards;
@@ -449,6 +459,22 @@ offline migration SQL generation.
- no workflow depends only on source-text assertion tests;
- console and failed-request audits are clean.
### Reproduction
```bash
npm --prefix frontend run test:unit
bash scripts/run_rc8_release_journeys.sh \
http://192.168.10.150:1202 \
artifacts/rc8-release-journeys \
artifacts/rc8-golden-areas.json
```
The second command is intentionally live and mutating. It creates only missing
golden Areas, copies the official Mol/Kempen geometry into the national
workbench with source identifiers, uses the existing explicit demo-fixture flow for AI validation
and writes screenshots plus `manifest.json` under the ignored `artifacts/`
directory.
## RC-9 - UX, accessibility and performance
### Work
+1 -1
View File
@@ -26,7 +26,7 @@ maritieme zones.
bewijzen.
- [x] RC-6: volledige CI, dependency-audit, containerscan en SBOM toevoegen.
- [x] RC-7: kritieke API-routes concrete responsemodellen geven.
- [ ] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen.
- [x] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen.
- [ ] RC-9: loading, toegankelijkheid, widescreen/mobile en performance afronden.
- [ ] RC-10: dataretentie, diskdruk en veilige cleanup operationaliseren.
- [ ] RC-11: fresh install, upgrade, rollback, releasepakket, tag en live
+25
View File
@@ -726,3 +726,28 @@ the active theme as `Beschikbaar`, `Gedeeltelijk`, `Niet gekoppeld` or `Niet
ondersteund`. A coastal or cross-region selection remains visibly split. The
browser never calls NGI, SPW, UrbIS, RBINS or MDK directly and never promotes
an audited catalog entry to operational data without a matching ready Dataset.
## Frontend release tests
Run the deterministic component/hook regression suite locally:
```bash
npm run test:unit
```
Install the browser runtime once and execute the live Belgium/North Sea
journeys from the repository root:
```bash
npx playwright install chromium
bash scripts/run_rc8_release_journeys.sh \
http://192.168.10.150:1202 \
artifacts/rc8-release-journeys \
artifacts/rc8-golden-areas.json
```
The live runner expects a healthy GeoIntel deployment with PostGIS, the
configured local Ollama integration and the configured local YOLO model. It
uses only the explicit technical demo-fixture flow for AI validation. Runtime
screenshots and the machine-readable manifest are ignored build evidence and
must not be committed.
+498
View File
@@ -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)
})
+1192 -4
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -7,7 +7,9 @@
"start": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:unit": "vitest run",
"test:e2e": "node e2e/releaseJourneys.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -18,10 +20,14 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^26.1.0",
"playwright": "^1.61.1",
"typescript": "^5.5.4",
"vite": "^7.3.6"
"vite": "^7.3.6",
"vitest": "^3.2.6"
}
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
bboxesEqual,
normalizeBboxFromCorners,
parseBboxInput,
resultMetricLabel,
selectionAreaSquareMetres,
} from './mapWorkspaceUtils'
import type { VectorSelectionResponse } from '../../types'
describe('map workspace selection guards', () => {
it('normalizes drag corners into an EPSG:4326 bbox', () => {
expect(normalizeBboxFromCorners([5.2, 51.3], [4.8, 50.9])).toEqual({
min_x: 4.8,
min_y: 50.9,
max_x: 5.2,
max_y: 51.3,
crs: 'EPSG:4326',
})
})
it('rejects empty, inverted and degenerate manual selections', () => {
expect(parseBboxInput({ min_x: '', min_y: '', max_x: '', max_y: '' })).toBeNull()
expect(parseBboxInput({ min_x: '5', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
expect(parseBboxInput({ min_x: '4', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
})
it('treats sub-nanodegree bbox drift as the same persisted selection', () => {
const bbox = normalizeBboxFromCorners([4.98, 51.15], [5.02, 51.19])
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-10 })).toBe(true)
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-5 })).toBe(false)
})
it('computes a positive bounded selection area and renders governed metrics', () => {
const bbox = normalizeBboxFromCorners([5.0, 51.0], [5.01, 51.01])
expect(selectionAreaSquareMetres(bbox)).toBeGreaterThan(700_000)
expect(selectionAreaSquareMetres(bbox)).toBeLessThan(900_000)
const result = {
feature_count: 12,
truncated: false,
summary: {
metric_key: 'forest_area',
metric_value: 14.236,
metric_unit: 'ha',
},
} as unknown as VectorSelectionResponse
expect(resultMetricLabel(result)).toBe('14,24 ha')
})
})
@@ -0,0 +1,98 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
const mocks = vi.hoisted(() => ({
resolveCoverage: vi.fn(),
}))
vi.mock('../services/api', () => ({
externalApi: {
resolveCoverage: mocks.resolveCoverage,
},
}))
import { useCoverageResolver } from './useCoverageResolver'
const bbox: VectorSelectionBBox = {
min_x: 4.98,
min_y: 51.15,
max_x: 5.02,
max_y: 51.19,
crs: 'EPSG:4326',
}
const coverageResult = {
intersected_zones: ['flanders'],
outside_supported_scope: false,
items: [],
warnings: [],
} as unknown as CoverageResolveResponse
describe('useCoverageResolver', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.resolveCoverage.mockResolvedValue(coverageResult)
})
afterEach(() => {
vi.useRealTimers()
})
it('does not query until both project and bbox exist', async () => {
const { result, rerender } = renderHook(
({ projectId, selection }) => useCoverageResolver({ projectId, bbox: selection }),
{ initialProps: { projectId: 'project-1' as string | null, selection: null as VectorSelectionBBox | null } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(mocks.resolveCoverage).not.toHaveBeenCalled()
expect(result.current.loadingCoverage).toBe(false)
rerender({ projectId: 'project-1', selection: bbox })
await act(async () => {
await vi.advanceTimersByTimeAsync(249)
})
expect(mocks.resolveCoverage).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(result.current.coverage).toEqual(coverageResult)
expect(mocks.resolveCoverage).toHaveBeenCalledWith({
projectId: 'project-1',
bbox: { minx: 4.98, miny: 51.15, maxx: 5.02, maxy: 51.19 },
})
})
it('clears stale coverage when the selection is removed', async () => {
const { result, rerender } = renderHook(
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
{ initialProps: { selection: bbox as VectorSelectionBBox | null } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverage).toEqual(coverageResult)
rerender({ selection: null })
expect(result.current.coverage).toBeNull()
expect(result.current.coverageError).toBeNull()
expect(result.current.loadingCoverage).toBe(false)
})
it('exposes provider failures without retaining stale results', async () => {
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverageError).toBe('provider unavailable')
expect(result.current.coverage).toBeNull()
expect(result.current.loadingCoverage).toBe(false)
})
})
@@ -0,0 +1,72 @@
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
const mocks = vi.hoisted(() => ({
compare: vi.fn(),
}))
vi.mock('../services/api/temporal', () => ({
temporalApi: {
compare: mocks.compare,
},
}))
import { useTemporalComparison } from './useTemporalComparison'
const bbox: VectorSelectionBBox = {
min_x: 4.98,
min_y: 51.15,
max_x: 5.02,
max_y: 51.19,
crs: 'EPSG:4326',
}
describe('useTemporalComparison', () => {
it('fails locally when no project is active', async () => {
const { result } = renderHook(() => useTemporalComparison(null))
let response: TemporalComparisonResponse | null = null
await act(async () => {
response = await result.current.compareTemporalSnapshots('earlier', 'later', bbox)
})
expect(response).toBeNull()
expect(result.current.temporalComparisonError).toContain('project')
expect(mocks.compare).not.toHaveBeenCalled()
})
it('requires two explicit snapshots before calling the API', async () => {
const { result } = renderHook(() => useTemporalComparison('project-1'))
await act(async () => {
await result.current.compareTemporalSnapshots('', 'later', bbox)
})
expect(result.current.temporalComparisonError).toContain('twee meetmomenten')
expect(mocks.compare).not.toHaveBeenCalled()
})
it('persists the compatible comparison result in hook state', async () => {
const comparison = {
project_id: 'project-1',
earlier_dataset_id: 'earlier',
later_dataset_id: 'later',
warnings: [],
} as unknown as TemporalComparisonResponse
mocks.compare.mockResolvedValueOnce(comparison)
const { result } = renderHook(() => useTemporalComparison('project-1'))
let response: TemporalComparisonResponse | null = null
await act(async () => {
response = await result.current.compareTemporalSnapshots('earlier', 'later', bbox, 'area-1')
})
expect(response).toEqual(comparison)
expect(result.current.temporalComparison).toEqual(comparison)
expect(result.current.temporalComparisonError).toBeNull()
expect(mocks.compare).toHaveBeenCalledWith('project-1', {
earlier_dataset_id: 'earlier',
later_dataset_id: 'later',
bbox,
area_id: 'area-1',
preview_limit: 500,
})
})
})
@@ -0,0 +1,66 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useWorkbenchBootstrap } from './useWorkbenchBootstrap'
function action() {
return vi.fn().mockResolvedValue(undefined)
}
function options(selectedProjectId: string | null) {
return {
selectedProjectId,
selectedDetectionRunId: '',
detectionClassFilter: '',
detectionMinConfidenceFilter: 0,
selectedSegmentationRunId: '',
segmentationClassFilter: '',
segmentationMinConfidenceFilter: 0,
loadProjects: action(),
loadCapabilities: action(),
loadDetectionModels: action(),
loadSegmentationModels: action(),
loadProjectData: action(),
loadDetectionRuns: action(),
loadSegmentationRuns: action(),
loadQualityChecks: action(),
loadExports: action(),
loadDetectionResults: action(),
loadSegmentationResults: action(),
resetProjectData: vi.fn(),
resetDatasetForProject: vi.fn(),
resetDetectionForProject: vi.fn(),
resetSegmentationForProject: vi.fn(),
resetExportsForProject: vi.fn(),
}
}
describe('useWorkbenchBootstrap', () => {
it('loads global capabilities and clears project-owned state without a project', async () => {
const state = options(null)
renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjects).toHaveBeenCalledOnce())
expect(state.loadCapabilities).toHaveBeenCalledOnce()
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
expect(state.resetProjectData).toHaveBeenCalledOnce()
expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
expect(state.loadProjectData).not.toHaveBeenCalled()
})
it('resets stale state before loading every project-owned collection', async () => {
const state = options('project-1')
renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
expect(state.resetProjectData).toHaveBeenCalledOnce()
expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
expect(state.resetDetectionForProject).toHaveBeenCalledOnce()
expect(state.resetSegmentationForProject).toHaveBeenCalledOnce()
expect(state.resetExportsForProject).toHaveBeenCalledOnce()
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
expect(state.loadExports).toHaveBeenCalledWith('project-1')
})
})
+7 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
declare const process: { env: Record<string, string | undefined> }
@@ -7,6 +7,12 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:80
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
clearMocks: true,
restoreMocks: true,
},
build: {
chunkSizeWarningLimit: 900,
rollupOptions: {
+33
View File
@@ -2036,3 +2036,36 @@ bash scripts/rotate_postgres_password.sh \
The command atomically updates the operator-owned `.env`, changes the matching
PostgreSQL role and recreates the container. A failed role change restores the
previous environment file. The generated secret is never printed.
## RC-8 Belgium/North Sea release journeys
Preview the seven release areas without mutating GeoIntel:
```bash
python scripts/provision_release_golden_areas.py \
--base-url http://192.168.10.150:1202 \
--output artifacts/rc8-golden-areas.json
```
Provision only missing Areas and execute the complete live browser/API release
journey:
```bash
bash scripts/run_rc8_release_journeys.sh \
http://192.168.10.150:1202 \
artifacts/rc8-release-journeys \
artifacts/rc8-golden-areas.json
```
The wrapper runs the same operator with `--apply`, then starts the Playwright
runner. Mol and Kempen retain their official source Area identifiers while
bounded copies are made selectable in the national workbench. It validates all
land, cross-region, coast and maritime golden areas;
governed metrics and provenance; compatible history; no-data, partial,
unsupported and provider-failure states; persistent map export; real local
Ollama context; and the explicit configured-YOLO fixture journey.
The command is intentionally live and creates missing Area records, an export,
an assistant conversation and explicit technical demo AI records. It never
fetches fake production provider data. Evidence is written below the requested
ignored `artifacts/` directory as screenshots plus `manifest.json`.
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""Provision and fingerprint the bounded Belgium/North Sea RC journey areas.
The command is dry-run-first. With --apply it creates only missing Area rows
through the public API. Existing Mol and Kempen authoritative geometries are
copied into the national release workspace and retained as source evidence;
no dataset, feature or source artifact is created by this command.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_BASE_URL = os.environ.get("GE_INTEL_BASE_URL", "http://127.0.0.1:1202")
NATIONAL_PROJECT = "Belgium and North Sea Workbench"
MOL_PROJECT = "Mol Municipality Workbench"
KEMPEN_PROJECT = "Kempen Regional Workbench"
def rectangle(minx: float, miny: float, maxx: float, maxy: float) -> dict[str, Any]:
return {
"type": "MultiPolygon",
"coordinates": [
[
[
[minx, miny],
[maxx, miny],
[maxx, maxy],
[minx, maxy],
[minx, miny],
]
]
],
}
GOLDEN_AREAS = (
{
"key": "wallonia_urban_rural",
"project": NATIONAL_PROJECT,
"name": "RC Golden - Wallonia urban-rural",
"geometry": rectangle(4.80, 50.42, 4.95, 50.53),
"expected_zones": ["wallonia"],
},
{
"key": "brussels_urban",
"project": NATIONAL_PROJECT,
"name": "RC Golden - Brussels urban",
"geometry": rectangle(4.32, 50.82, 4.39, 50.88),
"expected_zones": ["brussels"],
},
{
"key": "language_boundary",
"project": NATIONAL_PROJECT,
"name": "RC Golden - language boundary",
"geometry": rectangle(4.05, 50.70, 4.20, 50.80),
"expected_zones": ["flanders", "wallonia"],
},
{
"key": "coast_land_sea",
"project": NATIONAL_PROJECT,
"name": "RC Golden - coast land-sea",
"geometry": rectangle(2.88, 51.20, 2.98, 51.28),
"expected_zones": ["flanders", "territorial_sea"],
},
{
"key": "north_sea_multi_zone",
"project": NATIONAL_PROJECT,
"name": "RC Golden - North Sea multi-zone",
"geometry": rectangle(2.55, 51.35, 2.75, 51.55),
"expected_zones": [
"territorial_sea",
"exclusive_economic_zone",
"continental_shelf",
],
},
)
SOURCE_AREAS: tuple[dict[str, Any], ...] = (
{
"key": "mol_municipality",
"source_project": MOL_PROJECT,
"target_name": "RC Golden - Mol municipality",
"predicate": lambda name: name.startswith("Gemeente Mol"),
"expected_zones": ["flanders"],
},
{
"key": "kempen_region",
"source_project": KEMPEN_PROJECT,
"target_name": "RC Golden - Kempen region",
"predicate": lambda name: "Vervoerregio Kempen" in name,
"expected_zones": ["flanders"],
},
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--output", type=Path)
parser.add_argument(
"--apply",
action="store_true",
help="Create missing bounded RC Areas through the canonical API.",
)
return parser.parse_args()
class ApiClient:
def __init__(self, base_url: str) -> None:
self.base_url = base_url.rstrip("/")
def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=60) as response:
body = json.load(response)
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise RuntimeError(f"{method} {path} failed: {exc.reason}") from exc
if not isinstance(body, dict) or "data" not in body:
raise RuntimeError(f"{method} {path} did not return the canonical data envelope")
return body["data"]
def canonical_hash(value: Any) -> str:
serialized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float]:
points: list[tuple[float, float]] = []
def walk(value: Any) -> None:
if not isinstance(value, list):
return
if len(value) >= 2 and isinstance(value[0], (int, float)) and isinstance(value[1], (int, float)):
points.append((float(value[0]), float(value[1])))
return
for child in value:
walk(child)
walk(geometry.get("coordinates"))
if not points:
raise RuntimeError("Golden Area geometry has no coordinates")
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return {"minx": min(xs), "miny": min(ys), "maxx": max(xs), "maxy": max(ys)}
def find_one(items: list[dict[str, Any]], predicate: Callable[[str], bool], label: str) -> dict[str, Any]:
matches = [item for item in items if predicate(str(item.get("name", "")))]
if len(matches) != 1:
raise RuntimeError(f"Expected exactly one {label}; found {len(matches)}")
return matches[0]
def main() -> int:
args = parse_args()
client = ApiClient(args.base_url)
projects_payload = client.request("GET", "/api/v1/projects?limit=200")
projects = projects_payload.get("items", [])
projects_by_name = {str(project["name"]): project for project in projects}
required_projects = {NATIONAL_PROJECT, MOL_PROJECT, KEMPEN_PROJECT}
missing_projects = sorted(required_projects - set(projects_by_name))
if missing_projects:
raise RuntimeError(f"Required release projects are missing: {', '.join(missing_projects)}")
areas_by_project: dict[str, list[dict[str, Any]]] = {}
for project_name in required_projects:
project_id = projects_by_name[project_name]["id"]
payload = client.request("GET", f"/api/v1/projects/{project_id}/areas?limit=200")
areas_by_project[project_name] = list(payload.get("items", []))
evidence: list[dict[str, Any]] = []
missing_area_names: list[str] = []
for definition in GOLDEN_AREAS:
project_name = str(definition["project"])
project = projects_by_name[project_name]
existing = [
area
for area in areas_by_project[project_name]
if area.get("name") == definition["name"]
]
if len(existing) > 1:
raise RuntimeError(f"Duplicate release Area name: {definition['name']}")
created = False
if not existing:
if not args.apply:
missing_area_names.append(str(definition["name"]))
continue
area = client.request(
"POST",
f"/api/v1/projects/{project['id']}/areas",
{
"name": definition["name"],
"geometry": definition["geometry"],
"crs": "EPSG:4326",
},
)
areas_by_project[project_name].append(area)
created = True
else:
area = existing[0]
geometry = area.get("geometry")
if not isinstance(geometry, dict):
raise RuntimeError(f"Area {definition['name']} has no serialized geometry")
expected_hash = canonical_hash(definition["geometry"])
actual_hash = canonical_hash(geometry)
if actual_hash != expected_hash:
raise RuntimeError(
f"Area {definition['name']} geometry drifted: expected {expected_hash}, got {actual_hash}"
)
evidence.append(
{
"key": definition["key"],
"project_name": project_name,
"project_id": str(project["id"]),
"area_id": str(area["id"]),
"area_name": str(area["name"]),
"bbox": geometry_bbox(geometry),
"geometry_sha256": actual_hash,
"expected_zones": definition["expected_zones"],
"created": created,
}
)
if missing_area_names:
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
for name in missing_area_names:
print(f"- {name}", file=sys.stderr)
return 3
national_project = projects_by_name[NATIONAL_PROJECT]
for definition in SOURCE_AREAS:
source_project_name = str(definition["source_project"])
source_area = find_one(
areas_by_project[source_project_name],
definition["predicate"],
f"{definition['key']} Area in {source_project_name}",
)
geometry = source_area.get("geometry")
if not isinstance(geometry, dict):
raise RuntimeError(f"Area {source_area['name']} has no serialized geometry")
existing = [
area
for area in areas_by_project[NATIONAL_PROJECT]
if area.get("name") == definition["target_name"]
]
if len(existing) > 1:
raise RuntimeError(f"Duplicate release Area name: {definition['target_name']}")
created = False
if not existing:
if not args.apply:
missing_area_names.append(str(definition["target_name"]))
continue
area = client.request(
"POST",
f"/api/v1/projects/{national_project['id']}/areas",
{
"name": definition["target_name"],
"geometry": geometry,
"crs": "EPSG:4326",
},
)
areas_by_project[NATIONAL_PROJECT].append(area)
created = True
else:
area = existing[0]
target_geometry = area.get("geometry")
if not isinstance(target_geometry, dict):
raise RuntimeError(f"Area {area['name']} has no serialized geometry")
source_hash = canonical_hash(geometry)
target_hash = canonical_hash(target_geometry)
if target_hash != source_hash:
raise RuntimeError(
f"Area {definition['target_name']} geometry drifted from "
f"{source_project_name}: expected {source_hash}, got {target_hash}"
)
evidence.append(
{
"key": definition["key"],
"project_name": NATIONAL_PROJECT,
"project_id": str(national_project["id"]),
"area_id": str(area["id"]),
"area_name": str(area["name"]),
"bbox": geometry_bbox(target_geometry),
"geometry_sha256": target_hash,
"expected_zones": definition["expected_zones"],
"created": created,
"source_project_name": source_project_name,
"source_project_id": str(projects_by_name[source_project_name]["id"]),
"source_area_id": str(source_area["id"]),
"source_area_name": str(source_area["name"]),
}
)
if missing_area_names:
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
for name in missing_area_names:
print(f"- {name}", file=sys.stderr)
return 3
evidence.sort(key=lambda item: item["key"])
manifest = {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"base_url": args.base_url.rstrip("/"),
"area_count": len(evidence),
"areas": evidence,
}
output = json.dumps(manifest, indent=2, sort_keys=True)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(f"{output}\n", encoding="utf-8")
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://127.0.0.1:1202}}"
OUTPUT_DIR="${2:-artifacts/rc8-release-journeys}"
GOLDEN_MANIFEST="${3:-artifacts/rc8-golden-areas.json}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
PYTHON_BIN="python"
fi
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "Python is required to provision release Areas." >&2
exit 2
fi
if ! command -v npm >/dev/null 2>&1; then
echo "Node.js/npm is required to execute release journeys." >&2
exit 2
fi
"$PYTHON_BIN" scripts/provision_release_golden_areas.py \
--base-url "$BASE_URL" \
--output "$GOLDEN_MANIFEST" \
--apply
npm --prefix frontend run test:e2e -- \
--base-url "$BASE_URL" \
--golden-manifest "../$GOLDEN_MANIFEST" \
--output "../$OUTPUT_DIR"
+15
View File
@@ -25,6 +25,17 @@ if [ -z "${PYTHON_BIN}" ]; then
echo "No usable python interpreter found" >&2
exit 1
fi
NODE_BIN=""
for candidate in node node.exe; do
if command -v "${candidate}" >/dev/null 2>&1; then
NODE_BIN="${candidate}"
break
fi
done
if [ -z "${NODE_BIN}" ]; then
echo "No usable Node.js interpreter found" >&2
exit 1
fi
echo "== GeoIntel run readiness check =="
@@ -87,6 +98,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_regional_timeseries.py
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py
${PYTHON_BIN} -m py_compile scripts/provision_belgium_north_sea_scope.py
${PYTHON_BIN} -m py_compile scripts/provision_release_golden_areas.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_context.py
${PYTHON_BIN} -m py_compile scripts/audit_source_freshness.py
@@ -111,8 +123,10 @@ ${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m compileall backend/app
(cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning)
(cd backend && ${PYTHON_BIN} -m alembic heads)
(cd frontend && npm run test:unit)
(cd frontend && npm run typecheck)
(cd frontend && npm run build)
"${NODE_BIN}" --check frontend/e2e/releaseJourneys.mjs
bash -n scripts/live_migration_smoke.sh
bash -n scripts/deploy_tower.sh
bash -n scripts/verify_release_fresh_install.sh
@@ -145,4 +159,5 @@ bash -n scripts/verify_gis_runtime.sh
bash -n scripts/verify_golden_qa_benchmark.sh
bash -n scripts/verify_demo_cleanup_dry_run.sh
bash -n scripts/capture_workbench_screenshots.sh
bash -n scripts/run_rc8_release_journeys.sh
echo "== Run readiness check passed =="