diff --git a/CHANGELOG.md b/CHANGELOG.md index a544f9f2..eb63e41a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 75a6f904..a8f7c3ca 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/app/services/demo_workflow_service.py b/backend/app/services/demo_workflow_service.py index 5820881b..efa2dae4 100644 --- a/backend/app/services/demo_workflow_service.py +++ b/backend/app/services/demo_workflow_service.py @@ -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: diff --git a/backend/tests/test_rc8_release_journey_contract.py b/backend/tests/test_rc8_release_journey_contract.py new file mode 100644 index 00000000..d65cbc8f --- /dev/null +++ b/backend/tests/test_rc8_release_journey_contract.py @@ -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 diff --git a/backend/tests/test_sprint15_demo_workflow.py b/backend/tests/test_sprint15_demo_workflow.py index 3a5b7f49..f4bf7ce5 100644 --- a/backend/tests/test_sprint15_demo_workflow.py +++ b/backend/tests/test_sprint15_demo_workflow.py @@ -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] diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index d9190604..77b828be 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index a71c7fb0..2eadb47e 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md b/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md index 38d725b5..69449421 100644 --- a/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md +++ b/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md @@ -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 diff --git a/docs/TODO.md b/docs/TODO.md index 1d995501..4d6d6a30 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/frontend/README.md b/frontend/README.md index 7c23f64a..3b8556a3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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. diff --git a/frontend/e2e/releaseJourneys.mjs b/frontend/e2e/releaseJourneys.mjs new file mode 100644 index 00000000..c7101720 --- /dev/null +++ b/frontend/e2e/releaseJourneys.mjs @@ -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) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2fe0e6a9..808ea6fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,22 +7,47 @@ "": { "name": "geointel-frontend", "version": "0.1.0", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, "dependencies": { "maplibre-gl": "^4.7.1", "react": "^18.2.0", "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" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -257,6 +282,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -305,6 +340,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1237,6 +1387,63 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1282,6 +1489,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1385,6 +1610,203 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.36", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.36.tgz", @@ -1432,6 +1854,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1453,6 +1885,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1460,6 +1919,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1467,6 +1940,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1485,6 +1972,42 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/earcut": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", @@ -1498,6 +2021,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1550,6 +2093,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1631,6 +2194,60 @@ "node": ">=16" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1660,6 +2277,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -1675,6 +2299,46 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1734,6 +2398,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1744,6 +2415,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/maplibre-gl": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", @@ -1836,6 +2528,43 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -1869,6 +2598,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1904,12 +2680,38 @@ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -1941,6 +2743,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -2005,12 +2815,39 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2030,6 +2867,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2040,6 +2884,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -2049,6 +2927,27 @@ "kdbush": "^4.0.2" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2066,12 +2965,88 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2192,6 +3167,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", @@ -2203,6 +3274,67 @@ "pbf": "^3.2.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -2218,6 +3350,62 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1ed69a1e..1ee3fe0f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/components/map/mapWorkspaceUtils.test.ts b/frontend/src/components/map/mapWorkspaceUtils.test.ts new file mode 100644 index 00000000..6d565141 --- /dev/null +++ b/frontend/src/components/map/mapWorkspaceUtils.test.ts @@ -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') + }) +}) diff --git a/frontend/src/hooks/useCoverageResolver.test.tsx b/frontend/src/hooks/useCoverageResolver.test.tsx new file mode 100644 index 00000000..f35e1f26 --- /dev/null +++ b/frontend/src/hooks/useCoverageResolver.test.tsx @@ -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) + }) +}) diff --git a/frontend/src/hooks/useTemporalComparison.test.tsx b/frontend/src/hooks/useTemporalComparison.test.tsx new file mode 100644 index 00000000..b34be14f --- /dev/null +++ b/frontend/src/hooks/useTemporalComparison.test.tsx @@ -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, + }) + }) +}) diff --git a/frontend/src/hooks/useWorkbenchBootstrap.test.tsx b/frontend/src/hooks/useWorkbenchBootstrap.test.tsx new file mode 100644 index 00000000..9722937f --- /dev/null +++ b/frontend/src/hooks/useWorkbenchBootstrap.test.tsx @@ -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') + }) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e457564e..8a68e789 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' declare const process: { env: Record } @@ -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: { diff --git a/scripts/README.md b/scripts/README.md index c9f114f0..aaae8381 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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`. diff --git a/scripts/provision_release_golden_areas.py b/scripts/provision_release_golden_areas.py new file mode 100644 index 00000000..eaf2df10 --- /dev/null +++ b/scripts/provision_release_golden_areas.py @@ -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()) diff --git a/scripts/run_rc8_release_journeys.sh b/scripts/run_rc8_release_journeys.sh new file mode 100644 index 00000000..953db617 --- /dev/null +++ b/scripts/run_rc8_release_journeys.sh @@ -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" diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 2f5d0ccd..78e6ccbb 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -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 =="