From 7886b41f00a42cdf6a1570dc093f77c42c78687d Mon Sep 17 00:00:00 2001 From: xiduzo Date: Tue, 14 Jul 2026 13:41:26 +0200 Subject: [PATCH 01/62] build: configure playwright-bdd test runner and dependencies Switch the e2e project to playwright-bdd (Gherkin features generated into .features-gen), add Stagehand/OpenRouter + zod + otpauth deps, and point the `@/*` path alias at the repo root. Co-Authored-By: Claude Opus 4.8 --- .env.example | 10 + .gitignore | 3 + package-lock.json | 3175 +++++++++++++++++++++++++++++++++++++++++- package.json | 12 +- playwright.config.ts | 66 +- tsconfig.json | 5 +- 6 files changed, 3169 insertions(+), 102 deletions(-) diff --git a/.env.example b/.env.example index 0b07de2..46296e0 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,13 @@ ADMIN_PASSWORD= ADMIN_OTP_SECRET= GPP_APP_BASE_URL= GPP_PUBLICATIEBANK_BASE_URL= +GPP_BURGERPORTAAL_BASE_URL= + +# OpenRouter API key for the Stagehand-driven @beheer scenarios (Testscript 1). +# Get one at https://openrouter.ai/keys. Without it the @beheer scenarios skip. +OPENROUTER_API_KEY= + +# GPP-publicatiebank (ODRC) REST API — used to own publicaties/documenten/organisaties +# test data (TS6-9). Defaults to GPP_PUBLICATIEBANK_BASE_URL when ODRC_BASE_URL is unset. +ODRC_BASE_URL= +ODRC_API_KEY= diff --git a/.gitignore b/.gitignore index 2ea7adc..a76e903 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,9 @@ dist .yarn/install-state.gz .pnp.* +# Playwright BDD generated specs +.features-gen/ + # Playwright /test-results/ /playwright-report/ diff --git a/package-lock.json b/package-lock.json index ccddcf8..ad324a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@browserbasehq/stagehand": "^3.6.0", + "openai": "^4.104.0", "otpauth": "^9.4.0", "zod": "^3.24.3" }, @@ -19,9 +21,290 @@ "dotenv": "^16.5.0", "eslint": "^9.24.0", "eslint-plugin-format": "^1.0.1", + "playwright-bdd": "^9.2.0", "typescript": "^5.8.3" } }, + "node_modules/@ai-sdk/amazon-bedrock": { + "version": "3.0.105", + "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-3.0.105.tgz", + "integrity": "sha512-zMO80RQhm5r9jm8u01CydTAQFo956LO92NeOZYPuzeNdeS1ZXRz9vWA5X2GeSJBGZBSSRc7o1D8tFQK6n1R91w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/anthropic": "2.0.85", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28", + "@smithy/eventstream-codec": "^4.0.1", + "@smithy/util-utf8": "^4.0.0", + "aws4fetch": "^1.0.20" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "2.0.85", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.85.tgz", + "integrity": "sha512-ozfPUXb5cnXyFWXyY0hRFi8fudgNDr+FAuP2J1HTRhPjkTHTSFLbPSVRXEXCVoJhfl1IboF+xNDZs35nOHDU7w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/azure": { + "version": "2.0.114", + "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-2.0.114.tgz", + "integrity": "sha512-gS4rv2Kd/Q8ek3ztQ8Kc5e0J6n853QcbZ9ISZtTxw9UHn/4Wg9rwUYyyLYJc0CBXGZG2grJBQJk1uqEc91jU8Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/deepseek": "1.0.45", + "@ai-sdk/openai": "2.0.110", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/cerebras": { + "version": "1.0.47", + "resolved": "https://registry.npmjs.org/@ai-sdk/cerebras/-/cerebras-1.0.47.tgz", + "integrity": "sha512-K8Q8TOPuyIbXOi1R9O+5JM2v+NbQRv1uLfgg8QBUI4/tKPC0yu6MUcg5qk/lk4FLacI7T+45lhgcHUCg/FzsYA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/openai-compatible": "1.0.42", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/deepseek": { + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-1.0.45.tgz", + "integrity": "sha512-DrV5KFfii+LS38Ky6dY3fmfCBybxAQ0/HyImf8fhm2AfNtfZvANSYre5zBVLH5YkZRdESZGUhISGgzjFs6vtVA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "2.0.78", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.78.tgz", + "integrity": "sha512-21AeBlmeXcFVzujaW6I6bWSENYAVpaEYZ767Pv4LJeuSt7F6TR3/SutbLhi6n9XBiT+F5kWOtJnt/VPLHHb7XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google-vertex": { + "version": "3.0.146", + "resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-3.0.146.tgz", + "integrity": "sha512-/rel0/S71P/iGOMyTlj6flSnRK29DT/7oCVcVSpFfkrfZvhRh14qxJ5E3TDPJH4dD95X5mBj1NYo+SVoddN34A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/anthropic": "2.0.85", + "@ai-sdk/google": "2.0.78", + "@ai-sdk/openai-compatible": "1.0.42", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28", + "google-auth-library": "^10.5.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "2.0.43", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-2.0.43.tgz", + "integrity": "sha512-D2BVVeID0nX4USXApbN2QPJ8yXudDDJuRPfvHubzw0zHP5fjyS8QnDLlBgK9xB2BVaKryg6nxlA7itDnmA7uxA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-2.0.37.tgz", + "integrity": "sha512-KiL/bVRDJcn5U1a3XOTG4Xd8olFSDWAZeOTYGXDI0JPaRCm+hioeHiO/fVBRrPcbY2Xtbe4NJ8QgEnBIvtf7Vw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "2.0.110", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.110.tgz", + "integrity": "sha512-PVbq0vXo6LVBCKeIOiDsOfrUIL2IOkMUipWP53SJZppDo594kbu5BFs/NdwnJMAX4q6EM6sGPVv751RN9PrZ9A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-1.0.42.tgz", + "integrity": "sha512-zEvULhRonXDacqpjJGgOhADHsQnPwrx3dWj0wOev5ASUlzVlOO8PhUhXE+tr8v70xc4xmXHEkVSAV/MGVqu69w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/perplexity": { + "version": "2.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/perplexity/-/perplexity-2.0.33.tgz", + "integrity": "sha512-gJzzD/Jg4e9tqq/NLI/L+nNDssHOWLZPqSmbbNTIGyEkN70DZvFbeNcBySqH13IbqtUAH0vD8Duf8Yi4jZigyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", + "integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.28.tgz", + "integrity": "sha512-bXlX1WX7E50a2N+AJW+1a/x63m52aPhm+6xYe5THxWrx9vW9NR7E2Ay+1G1ndlCdMdYKo2Fnsd7kBhuyQPaphw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/togetherai": { + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@ai-sdk/togetherai/-/togetherai-1.0.45.tgz", + "integrity": "sha512-dedlp1iFG37BZlxAvhfvTmGdP5JML7X3w0pxp19Hysi1BaJlS5b2aLB8RKNUThLDKxwoULW20ndNvLMRSANeEQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/openai-compatible": "1.0.42", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/xai": { + "version": "2.0.76", + "resolved": "https://registry.npmjs.org/@ai-sdk/xai/-/xai-2.0.76.tgz", + "integrity": "sha512-OfmSestD/bKeFsv4P1PPsLY8apzLVZfpygLXhHdRqT4p/o4WCfu05m6ZiuALp46tfPwOCdoxcjuQdvfv/9J8Tg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/openai-compatible": "1.0.42", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@antfu/eslint-config": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/@antfu/eslint-config/-/eslint-config-4.12.0.tgz", @@ -148,6 +431,36 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -216,6 +529,157 @@ "node": ">=6.9.0" } }, + "node_modules/@browserbasehq/sdk": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.15.0.tgz", + "integrity": "sha512-yWT7URYwpON1MvYRAA18Vmhg3iIhvj1VMqHC9igdh1CdS740xhYerxlvrm5GxMX5ExnjNbf7tf0k1VOUIvZbbg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@browserbasehq/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@browserbasehq/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@browserbasehq/stagehand": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@browserbasehq/stagehand/-/stagehand-3.6.0.tgz", + "integrity": "sha512-wQ3Mv5b2xy64SntWNUbvgd7liWPbQRvGa8KEb1LorwheTkpUZFkt3BpZE8ovMY/lIleV9DfYjbvqghbeOOla4w==", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "^2.0.0", + "@anthropic-ai/sdk": "0.39.0", + "@browserbasehq/sdk": "^2.10.0", + "@google/genai": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "ai": "^5.0.133", + "devtools-protocol": "^0.0.1642743", + "fetch-cookie": "^3.1.0", + "openai": "^4.104.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "uuid": "^11.1.1", + "ws": "^8.21.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@ai-sdk/amazon-bedrock": "^3.0.73", + "@ai-sdk/anthropic": "^2.0.81", + "@ai-sdk/azure": "^2.0.109", + "@ai-sdk/cerebras": "^1.0.25", + "@ai-sdk/deepseek": "^1.0.23", + "@ai-sdk/google": "^2.0.53", + "@ai-sdk/google-vertex": "^3.0.70", + "@ai-sdk/groq": "^2.0.24", + "@ai-sdk/mistral": "^2.0.19", + "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/perplexity": "^2.0.13", + "@ai-sdk/togetherai": "^1.0.23", + "@ai-sdk/xai": "^2.0.26", + "bufferutil": "^4.0.9", + "chrome-launcher": "^1.2.0", + "ollama-ai-provider-v2": "^1.5.0" + }, + "peerDependencies": { + "patchright-core": "^1.55.2", + "playwright-core": "^1.55.1", + "puppeteer-core": "^24.43.0", + "zod": "^3.25.76 || ^4.2.0" + }, + "peerDependenciesMeta": { + "patchright-core": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/@browserbasehq/stagehand/node_modules/@ai-sdk/gateway": { + "version": "2.0.109", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.109.tgz", + "integrity": "sha512-jvTQnfmcKD/bc8fbp2bbaO8QvqSPaj7lv5rj9kj/GLHUfTGpjQV3pH7GdO4fDi1SX2PdHpPQY0y6rxJ05Xo5cg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@browserbasehq/stagehand/node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@browserbasehq/stagehand/node_modules/ai": { + "version": "5.0.210", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.210.tgz", + "integrity": "sha512-HAYfzP0vMU3/1GzjQkpJT7/x7BqacIYimAMgm6wXZKjZfOHGv1458hLDqYcC7o9BuAr23sFijvL41MSx9KEnEQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "2.0.109", + "@ai-sdk/provider": "2.0.3", + "@ai-sdk/provider-utils": "3.0.28", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@browserbasehq/stagehand/node_modules/ollama-ai-provider-v2": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-1.5.5.tgz", + "integrity": "sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.17" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^4.0.16" + } + }, "node_modules/@clack/core": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz", @@ -239,6 +703,139 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cucumber/ci-environment": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-13.0.0.tgz", + "integrity": "sha512-cs+3NzfNkGbcmHPddjEv4TKFiBpZRQ6WJEEufB9mw+ExS22V/4R/zpDSEG+fsJ/iSNCd6A2sATdY8PFOyY3YnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-19.0.0.tgz", + "integrity": "sha512-4FKoOQh2Uf6F6/Ln+1OxuK8LkTg6PyAqekhf2Ix8zqV2M54sH+m7XNJNLhOFOAW/t9nxzRbw2CcvXbCLjcvHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "39.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz", + "integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=31.0.0 <33" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-11.0.0.tgz", + "integrity": "sha512-LJ+s4+TepHTgdKWDR4zbPyT7rQjmYIcukTwNbwNwgqr6i8Gjcmzf6NmtbYDA19m1ZFg6kWbFsmHnj37ZuX+kZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^38.0.0", + "@cucumber/messages": "^32.0.0", + "@teppeis/multimaps": "3.0.0", + "commander": "14.0.2", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "38.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-38.0.0.tgz", + "integrity": "sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=31.0.0 <33" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "23.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-23.1.0.tgz", + "integrity": "sha512-DcCSFoGs6jbwzXPgX1CwgJKEE+ZMcIEzq/0Memg0o24maNn9NJizBFHmoFWG4iv/OxHza+mvc+56cTHetfHndw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/junit-xml-formatter": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.13.3.tgz", + "integrity": "sha512-w9ujOxiuKDtU6fLzJz+wp4Sgp5Xu6ba7ls00LHJccVmQU0Ba7zs+AHnv3iIgPjKZAQe1w8x93dr8Gaubh7Vqkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/query": "^15.0.1", + "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", + "xmlbuilder": "^15.1.1" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/messages": { + "version": "32.3.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz", + "integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2" + } + }, + "node_modules/@cucumber/query": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-15.0.1.tgz", + "integrity": "sha512-FMfT3orJblRsOxvU2doECBvQmauizYlj+5JsM8atAKKPbnQTj7v2/OrnuykvQpfZNBf19DYbRq1e832vllRP/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-9.1.0.tgz", + "integrity": "sha512-bvHjcRFZ+J1TqIa9eFNO1wGHqwx4V9ZKV3hYgkuK/VahHx73uiP4rKV3JVrvWSMrwrFvJG6C8aEwnCWSvbyFdQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@dprint/formatter": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@dprint/formatter/-/formatter-0.3.0.tgz", @@ -581,6 +1178,42 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -655,6 +1288,68 @@ "license": "MIT", "peer": true }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz", @@ -718,6 +1413,21 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgr/core": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.4.tgz", @@ -731,22 +1441,140 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@playwright/test": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", - "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", - "dev": true, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", + "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.4.7.tgz", + "integrity": "sha512-YNodWVjMFOMAyjQgpHBBCz62DbYu4xwhpt+z5HRf7OZPwrfHgNDCyUZxaC0fy9/2TWcO+niOMHSl8aMgfbWZTQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/core": "^3.29.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", + "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.4.7.tgz", + "integrity": "sha512-NskAyOBZcHO+fa1HwkwWuPbMUQE7NZ4IWBnAc71E4f9IdoQ65WLkdJ2ed/n4Q8vePSgZUN/CbyGZLBiI7dMI+w==", "license": "Apache-2.0", + "optional": true, "dependencies": { - "playwright": "1.52.0" - }, - "bin": { - "playwright": "cli.js" + "@smithy/core": "^3.29.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" + "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.2.0.tgz", @@ -767,6 +1595,16 @@ "eslint": ">=9.0.0" } }, + "node_modules/@teppeis/multimaps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", + "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -841,12 +1679,21 @@ "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -854,6 +1701,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1355,6 +2208,31 @@ "license": "MIT", "peer": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -1378,6 +2256,27 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1395,6 +2294,55 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "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", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1438,6 +2386,28 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT", + "optional": true + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1445,6 +2415,72 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1508,6 +2544,33 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/builtin-modules": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", @@ -1521,6 +2584,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1531,6 +2603,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1601,6 +2702,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chrome-launcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", + "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/ci-info": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", @@ -1617,6 +2737,13 @@ "node": ">=8" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "dev": true, + "license": "MIT" + }, "node_modules/clean-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", @@ -1640,6 +2767,22 @@ "node": ">=0.8.0" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1660,6 +2803,34 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/comment-parser": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", @@ -1684,6 +2855,46 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-js-compat": { "version": "3.41.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", @@ -1698,11 +2909,27 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1723,14 +2950,31 @@ "cssesc": "bin/cssesc" }, "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1765,6 +3009,24 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1789,6 +3051,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1642743", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1642743.tgz", + "integrity": "sha512-vTCGze95hGFayPNUXv2H/3cNt/Kqv3J7XqS519j7U8HYhmtD6+eVEPHp6nRHD64dDXJ022TU4reAYhjYyuxm8Q==", + "license": "BSD-3-Clause" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1815,6 +3083,35 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.138", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.138.tgz", @@ -1822,6 +3119,31 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -1850,6 +3172,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", @@ -1857,6 +3197,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1867,11 +3234,17 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -2658,6 +4031,106 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/exsolve": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", @@ -2665,11 +4138,22 @@ "dev": true, "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-copy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.4.tgz", + "integrity": "sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -2723,6 +4207,28 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2748,11 +4254,14 @@ } }, "node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -2762,6 +4271,48 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fetch-blob/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/fetch-cookie": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.2.0.tgz", + "integrity": "sha512-n61pQIxP25C6DRhcJxn7BDzgHP/+S56Urowb5WFxtcRMpU6drqXD90xjyAsVQYsNSNNVbaCcYY1DuHsdkZLuiA==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^6.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2788,6 +4339,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2839,40 +4411,208 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", "dev": true, "engines": { - "node": ">=0.4.x" + "node": ">=0.4.x" + } + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz", + "integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "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" - ], + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", @@ -2912,6 +4652,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2936,11 +4714,37 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2949,6 +4753,21 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.29", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", + "integrity": "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -2962,6 +4781,64 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3025,6 +4902,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-builtin-module": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", @@ -3057,6 +4958,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "optional": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3067,6 +4984,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3090,13 +5017,49 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3140,6 +5103,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3147,6 +5119,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3154,6 +5132,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3211,6 +5195,27 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3235,6 +5240,17 @@ "node": ">= 0.8.0" } }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, "node_modules/local-pkg": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", @@ -3283,6 +5299,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -3301,6 +5330,16 @@ "dev": true, "license": "ISC" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -3323,6 +5362,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -3568,6 +5623,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4213,6 +6289,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4236,7 +6337,16 @@ "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/mlly": { @@ -4275,7 +6385,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4315,6 +6424,67 @@ "node": ">=18" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -4350,6 +6520,102 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4412,6 +6678,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/package-manager-detector": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", @@ -4477,6 +6756,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4491,7 +6779,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4504,6 +6791,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4519,9 +6816,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -4531,6 +6828,97 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", @@ -4562,7 +6950,55 @@ "fsevents": "2.3.2" } }, + "node_modules/playwright-bdd": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-9.2.0.tgz", + "integrity": "sha512-1tBTmo4DpOhLsc+A6PB4isWO3DHKb4BQ3Tzw5+ze/PmgBW9W2m9c+nc0TPy2nByWJtw0gKSfMoexyBR+y82+pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/ci-environment": "^13.0.0", + "@cucumber/cucumber-expressions": "19.0.0", + "@cucumber/gherkin": "^39.1.0", + "@cucumber/gherkin-utils": "^11.0.0", + "@cucumber/html-formatter": "^23.1.0", + "@cucumber/junit-xml-formatter": "^0.13.3", + "@cucumber/messages": "^32.3.1", + "@cucumber/query": "^15.0.1", + "@cucumber/tag-expressions": "^9.1.0", + "cli-table3": "0.6.5", + "commander": "^13.1.0", + "mime-types": "^3.0.2", + "tinyglobby": "0.2.17" + }, + "bin": { + "bddgen": "dist/cli/index.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/vitalets" + }, + "peerDependencies": { + "@playwright/test": ">=1.44" + } + }, "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/playwright-core": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", @@ -4688,6 +7124,68 @@ "node": ">=6.0.0" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4698,6 +7196,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", @@ -4736,6 +7250,40 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -4774,6 +7322,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/refa": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", @@ -4787,6 +7344,13 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/regexp-ast-analysis": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", @@ -4801,6 +7365,16 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -4837,6 +7411,15 @@ "node": ">=6" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -4878,6 +7461,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4889,6 +7481,22 @@ "node": ">=0.10.0" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4913,6 +7521,41 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scslre": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", @@ -4928,6 +7571,22 @@ "node": "^14.0.0 || >=16.0.0" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -4941,11 +7600,67 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4958,12 +7673,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -4978,6 +7764,25 @@ "dev": true, "license": "ISC" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4989,6 +7794,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -5036,6 +7852,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5043,6 +7868,43 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", @@ -5138,6 +8000,15 @@ "node": ">=6" } }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -5146,14 +8017,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5162,6 +8033,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5175,6 +8064,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/toml-eslint-parser": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.10.0.tgz", @@ -5204,6 +8102,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5221,7 +8137,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, + "devOptional": true, "license": "0BSD" }, "node_modules/type-check": { @@ -5250,6 +8166,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -5275,7 +8222,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -5350,6 +8296,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.5.0.tgz", @@ -5426,6 +8381,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -5448,6 +8416,15 @@ "spdx-license-ids": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vue-eslint-parser": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.1.3.tgz", @@ -5473,11 +8450,35 @@ "eslint": "^8.57.0 || ^9.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5499,6 +8500,33 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -5509,6 +8537,16 @@ "node": ">=12" } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/yaml": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", @@ -5566,14 +8604,23 @@ } }, "node_modules/zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 5b70018..b543d40 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,18 @@ "scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "npx playwright test -c auth.playwright.config.ts && npx playwright test" + "bddgen": "bddgen", + "test": "bddgen && playwright test", + "test:ui": "bddgen && playwright test --ui", + "test:admin": "bddgen && playwright test --grep @admin", + "test:gpp-app": "bddgen && playwright test --grep @gpp-app", + "test:publicatiebank": "bddgen && playwright test --grep @publicatiebank", + "test:burgerportaal": "bddgen && playwright test --grep @burgerportaal", + "test:beheer": "bddgen && playwright test --grep @beheer" }, "dependencies": { + "@browserbasehq/stagehand": "^3.6.0", + "openai": "^4.104.0", "otpauth": "^9.4.0", "zod": "^3.24.3" }, @@ -30,6 +39,7 @@ "dotenv": "^16.5.0", "eslint": "^9.24.0", "eslint-plugin-format": "^1.0.1", + "playwright-bdd": "^9.2.0", "typescript": "^5.8.3" } } diff --git a/playwright.config.ts b/playwright.config.ts index a60ca17..aeeea6a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,17 +2,33 @@ import path from 'node:path' import process from 'node:process' import { defineConfig, devices } from '@playwright/test' import dotenv from 'dotenv' +import { defineBddConfig } from 'playwright-bdd' + /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ dotenv.config({ path: path.resolve(__dirname, '.env') }) +/** + * playwright-bdd: features + steps live under ./bdd. Directory names prefixed + * with `@` become tags (tags-from-path), e.g. bdd/@publicatiebank/@admin/*. + * `bddgen` generates runnable specs into the returned testDir before each run. + * https://vitalets.github.io/playwright-bdd/ + */ +const testDir = defineBddConfig({ + featuresRoot: './bdd', +}) + /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './tests', + testDir, + /* Sweep any test data left behind by a hard-killed run (via the admin UI, so + it works against any environment — no container access). The reference-data + prerequisite is verified in setup/auth.setup.ts. See setup/global-teardown.ts. */ + globalTeardown: './setup/global-teardown.ts', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -25,55 +41,39 @@ export default defineConfig({ reporter: process.env.CI ? [['html'], ['github'], ['dot']] : [['html'], ['line']], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on', + + /* Resolve the local Keycloak hostname (used as OIDC issuer) to localhost so the + browser reaches the published Keycloak port without an /etc/hosts entry. */ + launchOptions: { + args: ['--host-resolver-rules=MAP keycloak.woo-search.local 127.0.0.1'], + }, }, /* Configure projects for major browsers */ projects: [ + /* Signs in once per role and writes .auth/*.json; runs before the BDD + projects. Kept out of ./bdd so bddgen does not treat it as a step file. */ + { + name: 'setup', + testDir: './setup', + testMatch: /auth\.setup\.ts/, + }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + dependencies: ['setup'], }, - { name: 'firefox', use: { ...devices['Desktop Firefox'] }, + dependencies: ['setup'], }, - { name: 'webkit', use: { ...devices['Desktop Safari'] }, + dependencies: ['setup'], }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, }) diff --git a/tsconfig.json b/tsconfig.json index e89177c..9eecf74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,11 +8,8 @@ "skipLibCheck": true }, "include": [ - "tests", + "bdd", "setup", - "login-helpers", - "environment.ts", - "auth.playwright.config.ts", "playwright.config.ts" ] } From c4dbc5ebb742f2df703e77c00b5a10887ed53689 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Tue, 14 Jul 2026 13:41:39 +0200 Subject: [PATCH 02/62] chore: remove legacy pre-BDD spec tests and login helpers Delete the old spec-based tests and Azure/Maykin login helpers superseded by the playwright-bdd suite. Co-Authored-By: Claude Opus 4.8 --- auth.playwright.config.ts | 35 ---- environment.ts | 15 -- login-helpers/azure.ts | 59 ------- .../login-to-gpp-publicatiebank.spec.ts | 13 -- login-helpers/maykin.ts | 48 ------ login-helpers/types.ts | 3 - setup/auth.setup.spec.ts | 19 -- tests/gpp-publicatiebank.spec.ts | 162 ------------------ tests/login-to-gpp-app.spec.ts | 12 -- 9 files changed, 366 deletions(-) delete mode 100644 auth.playwright.config.ts delete mode 100644 environment.ts delete mode 100644 login-helpers/azure.ts delete mode 100644 login-helpers/login-to-gpp-publicatiebank.spec.ts delete mode 100644 login-helpers/maykin.ts delete mode 100644 login-helpers/types.ts delete mode 100644 setup/auth.setup.spec.ts delete mode 100644 tests/gpp-publicatiebank.spec.ts delete mode 100644 tests/login-to-gpp-app.spec.ts diff --git a/auth.playwright.config.ts b/auth.playwright.config.ts deleted file mode 100644 index 35fee58..0000000 --- a/auth.playwright.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from 'node:path' -import process from 'node:process' -import { defineConfig } from '@playwright/test' -import dotenv from 'dotenv' -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -dotenv.config({ path: path.resolve(__dirname, '.env') }) - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './setup', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - use: { - trace: 'off', - }, - reporter: process.env.CI ? [['dot'], ['github']] : undefined, - /* Configure projects for major browsers */ - projects: [ - { - name: 'auth', - testMatch: /.*setup\.spec\.ts/, - }, - ], -}) diff --git a/environment.ts b/environment.ts deleted file mode 100644 index 9496ee0..0000000 --- a/environment.ts +++ /dev/null @@ -1,15 +0,0 @@ -import process from 'node:process' -import { z } from 'zod' - -const schema = z.object({ - GPP_APP_BASE_URL: z.string().min(1), - GPP_PUBLICATIEBANK_BASE_URL: z.string().min(1), - DEFAULT_EMAIL: z.string().email(), - DEFAULT_PASSWORD: z.string().min(1), - DEFAULT_OTP_SECRET: z.string().min(1), - ADMIN_EMAIL: z.string().email(), - ADMIN_PASSWORD: z.string().min(1), - ADMIN_OTP_SECRET: z.string().min(1), -}) - -export default schema.parse(process.env) diff --git a/login-helpers/azure.ts b/login-helpers/azure.ts deleted file mode 100644 index b26b712..0000000 --- a/login-helpers/azure.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Page } from '@playwright/test' -import type { LoginParams } from './types' -import { Secret, TOTP } from 'otpauth' -import environment from '../environment' - -function createUser({ - email, - password, - otpSecret, -}: { - email: string - password: string - otpSecret: string -}) { - const totp = new TOTP({ - secret: Secret.fromBase32(otpSecret), - }) - - return Object.freeze({ - email, - password, - getOtp: () => totp.generate(), - }) -} - -const users = Object.freeze({ - default: createUser({ - email: environment.DEFAULT_EMAIL, - password: environment.DEFAULT_PASSWORD, - otpSecret: environment.DEFAULT_OTP_SECRET, - }), - admin: createUser({ - email: environment.ADMIN_EMAIL, - password: environment.ADMIN_PASSWORD, - otpSecret: environment.ADMIN_OTP_SECRET, - }), -}) - -export async function loginToAzureEntraId( - page: Page, - loginParams: LoginParams | undefined, -) { - const { email, password, getOtp } = loginParams?.asAdmin - ? users.admin - : users.default - await page.getByRole('textbox', { name: 'email' }).fill(email) - await page.getByRole('button', { name: 'next' }).click() - await page.getByRole('textbox', { name: 'password' }).fill(password) - await page.getByRole('button', { name: 'sign in' }).click() - const code = page.getByRole('textbox', { name: 'code' }) - const dontShow = page.getByRole('checkbox', { name: `don't show this again` }) - await code.or(dontShow).waitFor() - if (await code.isVisible()) { - await code.fill(getOtp()) - await page.getByRole('button', { name: 'verify' }).click() - } - await dontShow.check() - await page.getByRole('button', { name: 'no' }).click() -} diff --git a/login-helpers/login-to-gpp-publicatiebank.spec.ts b/login-helpers/login-to-gpp-publicatiebank.spec.ts deleted file mode 100644 index 5cc99f2..0000000 --- a/login-helpers/login-to-gpp-publicatiebank.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import environment from '@/environment' -import { adminState } from '@/login-helpers' -import { expect, test } from '@playwright/test' - -test.use({ - storageState: adminState, -}) - -export async function login(page) { - await page.goto(environment.GPP_PUBLICATIEBANK_BASE_URL) - await page.getByRole('link', { name: 'Beheer' }).click() - await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible() -} diff --git a/login-helpers/maykin.ts b/login-helpers/maykin.ts deleted file mode 100644 index 143e984..0000000 --- a/login-helpers/maykin.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Page } from '@playwright/test' -import type { LoginParams } from './types' -import { loginToAzureEntraId } from './azure' - -export async function login(page: Page, loginParams: LoginParams | undefined) { - const beheerLocator = page.getByRole('link', { name: 'Beheer' }) - const oidcLocator = page.getByRole('link', { name: 'Inloggen met organisatieaccount' }) - const dashboardLocator = page.getByRole('link', { name: 'Dashboard' }) - const icattLink = page.getByRole('link', { name: 'Azure ICATT' }) - const email = page.getByRole('textbox', { name: 'email' }) - - await oidcLocator - .or(dashboardLocator) - .or(beheerLocator) - .or(icattLink) - .or(email) - .first() - .waitFor() - - if (await beheerLocator.isVisible()) { - await beheerLocator.click() - await oidcLocator - .or(dashboardLocator) - .or(icattLink) - .or(email) - .first() - .waitFor() - } - - if (await oidcLocator.isVisible()) { - await oidcLocator.click() - await dashboardLocator - .or(icattLink) - .or(email) - .first() - .waitFor() - } - - if (await icattLink.isVisible()) { - await icattLink.click() - await email.or(dashboardLocator).isVisible() - } - - if (await email.isVisible()) { - await loginToAzureEntraId(page, loginParams) - await dashboardLocator.waitFor() - } -} diff --git a/login-helpers/types.ts b/login-helpers/types.ts deleted file mode 100644 index 0874731..0000000 --- a/login-helpers/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface LoginParams { - asAdmin?: boolean -} diff --git a/setup/auth.setup.spec.ts b/setup/auth.setup.spec.ts deleted file mode 100644 index 7cb9cfb..0000000 --- a/setup/auth.setup.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import environment from '@/environment' -import { adminState, logIn, regularUserState } from '@/login-helpers' -import { test as setup } from '@playwright/test' - -setup('authenticate as admin', async ({ page }) => { - await page.goto(environment.GPP_APP_BASE_URL) - await logIn(page, { asAdmin: true }) - await page.goto(environment.GPP_PUBLICATIEBANK_BASE_URL) - await logIn(page, { asAdmin: true }) - await page.context().storageState({ path: adminState }) -}) - -setup('authenticate as regular user', async ({ page }) => { - await page.goto(environment.GPP_APP_BASE_URL) - await logIn(page, { asAdmin: false }) - await page.goto(environment.GPP_PUBLICATIEBANK_BASE_URL) - await logIn(page, { asAdmin: false }) - await page.context().storageState({ path: regularUserState }) -}) diff --git a/tests/gpp-publicatiebank.spec.ts b/tests/gpp-publicatiebank.spec.ts deleted file mode 100644 index dab4cac..0000000 --- a/tests/gpp-publicatiebank.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { Page } from '@playwright/test' -import { expect, test } from '@playwright/test' -import { login } from '../login-helpers/login-to-gpp-publicatiebank.spec' - -const removeWhitespace = (s: string) => s.replace(/\s+/g, '') -const compareIgnoringWhitespace = (a: string, b: string) => removeWhitespace(a).localeCompare(removeWhitespace(b)) - -async function goToInformatiecategorieen(page: Page) { - await login(page) - await page.locator('#header').getByText('Metadata').click() - await page.locator('#header').getByRole('link', { name: 'Informatiecategorieën' }).click() -} - -async function assertOorsprongFilter(page: Page, filterName: string) { - await page.getByRole('link', { name: filterName }).click() - await page.waitForSelector('td.field-oorsprong') - - const oorsprongCells = page.locator('td.field-oorsprong') - const count = await oorsprongCells.count() - - for (let i = 0; i < count; i++) { - const text = await oorsprongCells.nth(i).textContent() - expect(text?.trim()).toBe(filterName) - } -} - -test('navigate and validate table headers', async ({ page }) => { - await test.step('Login and navigate to Informatiecategorieën page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Verify table headers are visible and clickable', async () => { - const headers = ['Naam', 'Identificatie', 'Oorsprong'] - for (const header of headers) { - const headerElement = page.getByRole('link', { name: header }) - await expect(headerElement).toBeVisible() - await expect(headerElement).toHaveText(header) - await headerElement.click() - } - }) - - await test.step('Click on additional table actions', async () => { - await page.getByText('Acties').click() - await page.getByText('Move').click() - }) -}) - -test('search for "organisatie en werkwijze" and verify result and navigation', async ({ page }) => { - await test.step('Navigate to the information category page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Search for "organisatie en werkwijze"', async () => { - await page.getByRole('textbox', { name: 'Search' }).fill('organisatie en werkwijze') - await page.getByRole('button', { name: 'Zoeken' }).click() - }) - - await test.step('Verify search result appears', async () => { - const resultLink = page.getByRole('link', { name: 'organisatie en werkwijze' }) - await expect(resultLink).toHaveCount(1) - }) -}) - -test('filter based on Waardelijst', async ({ page }) => { - await test.step('Navigate to the information category page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Verify all results have "Waardelijst" as oorsprong', async () => { - await assertOorsprongFilter(page, 'Waardelijst') - }) -}) - -test('filter based on Zelf-toegevoegd item', async ({ page }) => { - await test.step('Navigate to the information category page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Verify all results have "Zelf-toegevoegd item" as oorsprong', async () => { - await assertOorsprongFilter(page, 'Zelf-toegevoegd item') - }) -}) - -test('filter "Alle" shows all items', async ({ page }) => { - await test.step('Navigate to informatiecategorieën page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Click the "Alle" filter', async () => { - await page.getByRole('link', { name: 'Alle', exact: true }).click() - }) - - await test.step('Wait for table to update', async () => { - await expect(page.locator('td.field-oorsprong').first()).toBeVisible() - }) - - const values: string[] = [] - - await test.step('Extract all "oorsprong" values from the table', async () => { - const oorsprongCells = page.locator('td.field-oorsprong') - const count = await oorsprongCells.count() - - for (let i = 0; i < count; i++) { - const text = await oorsprongCells.nth(i).textContent() - if (text) { - values.push(text.trim()) - } - } - }) - - await test.step('Verify both expected types are present', async () => { - expect(values).toContain('Waardelijst') - expect(values).toContain('Zelf-toegevoegd item') - }) -}) - -test('search information category and navigate to its detail view', async ({ page }) => { - await test.step('Go to the information category page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Search for "organisatie en werkwijze"', async () => { - await page.getByRole('textbox', { name: 'Search' }).fill('organisatie en werkwijze') - await page.getByRole('button', { name: 'Zoeken' }).click() - }) - - await test.step('Verify search result appears and navigate', async () => { - const searchResults = page.getByRole('link', { name: 'organisatie en werkwijze' }) - await expect(searchResults).toHaveCount(1) - - await searchResults.click() - await expect(page).toHaveURL(/\/admin\/metadata\/informationcategory\/\d+\/change\/(?:\?.*)?$/) - await expect(page.getByRole('heading', { name: 'informatiecategorie wijzigen' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'organisatie en werkwijze' })).toBeVisible() - }) -}) - -test('sort information category based on Naam column', async ({ page }) => { - await test.step('Navigate to information category page', async () => { - await goToInformatiecategorieen(page) - }) - - await test.step('Click on "Naam" column to enable sorting', async () => { - await page.getByRole('link', { name: 'Naam' }).click() - }) - - await test.step('Click on sort icon twice for ascending order', async () => { - const sortLink = page.getByRole('link', { name: 'Sortering aan/uit' }) - await sortLink.click() - await sortLink.click() - }) - - await test.step('Verify that information categories are sorted alphabetically', async () => { - const naamLinks = await page.locator('th.field-naam a').all() - const names = await Promise.all( - naamLinks.map(async el => (await el.textContent())?.trim() || ''), - ) - - const sortedNames = [...names].sort(compareIgnoringWhitespace) - expect(names).toEqual(sortedNames) - }) -}) diff --git a/tests/login-to-gpp-app.spec.ts b/tests/login-to-gpp-app.spec.ts deleted file mode 100644 index 49abd3b..0000000 --- a/tests/login-to-gpp-app.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import environment from '@/environment' -import { adminState } from '@/login-helpers' -import { expect, test } from '@playwright/test' - -test.use({ - storageState: adminState, -}) - -test('login works', async ({ page }) => { - await page.goto(environment.GPP_APP_BASE_URL) - await expect(page.getByRole('link', { name: 'Nieuwe publicatie' })).toBeVisible() -}) From 95527288d81b76f127c9f60534da7eacde56aa4a Mon Sep 17 00:00:00 2001 From: xiduzo Date: Tue, 14 Jul 2026 13:41:51 +0200 Subject: [PATCH 03/62] test(e2e): add Plateau-4 playwright-bdd suite with vertical structure Gherkin features + steps for testscripts 1,3,4,5,9,10,11 (plus @blocked stubs for 6/7/8), driven by Stagehand for admin/beheer UI mutations and verified deterministically through the session-authenticated page or public API. Organised vertically by GPP-Woo bounded context: each bdd/@/ holds its features, steps and support/ helpers; bdd/_core/ holds the shared harness (fixture graph, ENV, resource-manager, Keycloak, Stagehand plumbing, signIn). Deterministic own-and-cleanup via a makeResourceManager() factory + an adminResource()/adminDriver() seam over the Django admin; run-wide E2E-data sweep in global-teardown. Co-Authored-By: Claude Opus 4.8 --- .../@beheer/configuratie.feature | 74 ++++ .../@beheer/fixtures/favicon.png | Bin 0 -> 79 bytes bdd/@burgerportaal/@beheer/fixtures/logo.svg | 1 + .../@beheer/fixtures/sfeerfoto.png | Bin 0 -> 118 bytes bdd/@burgerportaal/@beheer/steps.ts | 174 +++++++++ bdd/@burgerportaal/sitemap.feature | 34 ++ bdd/@burgerportaal/steps.ts | 63 ++++ bdd/@burgerportaal/support/beheer-config.ts | 168 +++++++++ bdd/@burgerportaal/support/login.ts | 27 ++ bdd/@burgerportaal/support/sitemap.ts | 56 +++ bdd/@burgerportaal/zoeken-steps.ts | 96 +++++ bdd/@burgerportaal/zoeken.feature | 42 +++ bdd/@gpp-app/authentication.feature | 24 ++ bdd/@gpp-app/gebruikersgroepen-steps.ts | 104 ++++++ bdd/@gpp-app/gebruikersgroepen.feature | 33 ++ bdd/@gpp-app/publicaties-steps.ts | 40 ++ bdd/@gpp-app/publicaties.feature | 31 ++ bdd/@gpp-app/steps.ts | 35 ++ bdd/@gpp-app/support/hydrate.ts | 27 ++ bdd/@gpp-app/support/login.ts | 25 ++ bdd/@gpp-app/support/usergroup.ts | 51 +++ .../@admin/documenten-steps.ts | 28 ++ bdd/@publicatiebank/@admin/documenten.feature | 19 + .../@admin/fixtures/onderwerp.png | Bin 0 -> 74 bytes .../@admin/informatiecategorieen.feature | 47 +++ .../@admin/onderwerpen-steps.ts | 114 ++++++ .../@admin/onderwerpen.feature | 57 +++ .../@admin/organisaties.feature | 60 +++ .../@admin/publicaties-steps.ts | 123 ++++++ .../@admin/publicaties.feature | 45 +++ bdd/@publicatiebank/@admin/steps.ts | 127 +++++++ bdd/@publicatiebank/steps.ts | 113 ++++++ bdd/@publicatiebank/support/admin-driver.ts | 88 +++++ bdd/@publicatiebank/support/admin-resource.ts | 127 +++++++ .../support/information-category.ts | 66 ++++ bdd/@publicatiebank/support/login.ts | 51 +++ bdd/@publicatiebank/support/odrc.ts | 165 +++++++++ bdd/@publicatiebank/support/organisation.ts | 59 +++ bdd/@publicatiebank/support/poll.ts | 33 ++ bdd/@publicatiebank/support/publication.ts | 121 ++++++ bdd/@publicatiebank/support/topic.ts | 75 ++++ bdd/_core/fixture.ts | 350 ++++++++++++++++++ bdd/_core/keycloak.ts | 71 ++++ bdd/_core/resource-manager.ts | 87 +++++ bdd/_core/signIn.ts | 18 + bdd/_core/stagehand.ts | 169 +++++++++ bdd/_core/types.ts | 59 +++ login-helpers/gpp-app.ts | 14 - login-helpers/index.ts | 18 - setup/auth.setup.ts | 33 ++ setup/global-teardown.ts | 79 ++++ setup/paths.ts | 19 + 52 files changed, 3508 insertions(+), 32 deletions(-) create mode 100644 bdd/@burgerportaal/@beheer/configuratie.feature create mode 100644 bdd/@burgerportaal/@beheer/fixtures/favicon.png create mode 100644 bdd/@burgerportaal/@beheer/fixtures/logo.svg create mode 100644 bdd/@burgerportaal/@beheer/fixtures/sfeerfoto.png create mode 100644 bdd/@burgerportaal/@beheer/steps.ts create mode 100644 bdd/@burgerportaal/sitemap.feature create mode 100644 bdd/@burgerportaal/steps.ts create mode 100644 bdd/@burgerportaal/support/beheer-config.ts create mode 100644 bdd/@burgerportaal/support/login.ts create mode 100644 bdd/@burgerportaal/support/sitemap.ts create mode 100644 bdd/@burgerportaal/zoeken-steps.ts create mode 100644 bdd/@burgerportaal/zoeken.feature create mode 100644 bdd/@gpp-app/authentication.feature create mode 100644 bdd/@gpp-app/gebruikersgroepen-steps.ts create mode 100644 bdd/@gpp-app/gebruikersgroepen.feature create mode 100644 bdd/@gpp-app/publicaties-steps.ts create mode 100644 bdd/@gpp-app/publicaties.feature create mode 100644 bdd/@gpp-app/steps.ts create mode 100644 bdd/@gpp-app/support/hydrate.ts create mode 100644 bdd/@gpp-app/support/login.ts create mode 100644 bdd/@gpp-app/support/usergroup.ts create mode 100644 bdd/@publicatiebank/@admin/documenten-steps.ts create mode 100644 bdd/@publicatiebank/@admin/documenten.feature create mode 100644 bdd/@publicatiebank/@admin/fixtures/onderwerp.png create mode 100644 bdd/@publicatiebank/@admin/informatiecategorieen.feature create mode 100644 bdd/@publicatiebank/@admin/onderwerpen-steps.ts create mode 100644 bdd/@publicatiebank/@admin/onderwerpen.feature create mode 100644 bdd/@publicatiebank/@admin/organisaties.feature create mode 100644 bdd/@publicatiebank/@admin/publicaties-steps.ts create mode 100644 bdd/@publicatiebank/@admin/publicaties.feature create mode 100644 bdd/@publicatiebank/@admin/steps.ts create mode 100644 bdd/@publicatiebank/steps.ts create mode 100644 bdd/@publicatiebank/support/admin-driver.ts create mode 100644 bdd/@publicatiebank/support/admin-resource.ts create mode 100644 bdd/@publicatiebank/support/information-category.ts create mode 100644 bdd/@publicatiebank/support/login.ts create mode 100644 bdd/@publicatiebank/support/odrc.ts create mode 100644 bdd/@publicatiebank/support/organisation.ts create mode 100644 bdd/@publicatiebank/support/poll.ts create mode 100644 bdd/@publicatiebank/support/publication.ts create mode 100644 bdd/@publicatiebank/support/topic.ts create mode 100644 bdd/_core/fixture.ts create mode 100644 bdd/_core/keycloak.ts create mode 100644 bdd/_core/resource-manager.ts create mode 100644 bdd/_core/signIn.ts create mode 100644 bdd/_core/stagehand.ts create mode 100644 bdd/_core/types.ts delete mode 100644 login-helpers/gpp-app.ts delete mode 100644 login-helpers/index.ts create mode 100644 setup/auth.setup.ts create mode 100644 setup/global-teardown.ts create mode 100644 setup/paths.ts diff --git a/bdd/@burgerportaal/@beheer/configuratie.feature b/bdd/@burgerportaal/@beheer/configuratie.feature new file mode 100644 index 0000000..5e281e5 --- /dev/null +++ b/bdd/@burgerportaal/@beheer/configuratie.feature @@ -0,0 +1,74 @@ +# Testscript 1 — Configureren van het burgerportaal. +# +# A functioneel-beheerder configures the public GPP-burgerportaal through its +# beheer interface (welkomsttekst, video, afbeeldingen, externe links) and a +# burger sees the result on the public site. Here an AI agent (Stagehand driven +# by an OpenRouter model) operates the beheer UI, while assertions are made +# deterministically against the public site: the config API +# (/api/environment/resources), the rendered DOM, and raw image bytes. +# +# Test-owned data: the beheer config is snapshotted before each scenario and +# restored afterwards (bdd/_core/fixture.ts `beheer` fixture), so runs — including +# against shared/production environments — leave the portal exactly as found. +# +# Model-by-tag: this feature carries @anthropic (Claude Sonnet, reliable for +# DOM acts); a scenario can override with @expensive-ai / @cheap-ai / @openai, +# and anything untagged falls back to the default model. See +# _core/stagehand.ts. +# +# Requires OPENROUTER_API_KEY (else every scenario skips) — see README. +# +# @mode:serial — the burgerportaal configuration is a single global resource, so +# these scenarios must not run concurrently (a parallel snapshot/restore would +# race). They run sequentially in one worker; the rest of the suite still runs +# in parallel. +# +# @timeout:120000 — each scenario drives the beheer UI through Stagehand (LLM +# round-trips per act) and then publishes, which comfortably exceeds Playwright's +# 30s default; the other Stagehand features carry the same override. + +@anthropic @mode:serial @timeout:120000 +Feature: Configureren van het burgerportaal + + Background: + Given the burgerportaal beheer interface is open + + Scenario: Welkomsttekst wijzigen + When I set the welcome text to "E2E welkom" + And I publish the homepage settings + Then the public homepage shows the new welcome text + + # The beheer form only accepts a YouTube/Vimeo *embed* URL (see the ?-help in + # the UI); a plain watch?v= link is rejected. + Scenario: Promotievideo instellen en weer wissen + When I set the promotion video URL to "https://www.youtube.com/embed/aqz-KE-bpKQ" + And I publish the homepage settings + Then the public homepage has a promotion video + When I clear the promotion video URL + And I publish the homepage settings + Then the public homepage has no promotion video + + Scenario: Logo vervangen + When I replace the "logo" image + Then the public "logo" image has changed + + Scenario: Favicon vervangen + When I replace the "favicon" image + Then the public "favicon" image has changed + + Scenario: Sfeerfoto vervangen + When I replace the "image" image + Then the public "image" image has changed + + Scenario: URL van de hoofd-website van de organisatie wijzigen + When I set the organisation website URL to "https://e2e.example.org/gemeente" + And I publish the external links + Then the public organisation website URL matches + + Scenario: Voettekst-links wijzigen en verwijderen + When I set the "privacy" footer link to "https://e2e.example.org/privacy" + And I publish the external links + Then the public "privacy" footer link matches + When I remove the "privacy" footer link + And I publish the external links + Then the public "privacy" footer link is empty diff --git a/bdd/@burgerportaal/@beheer/fixtures/favicon.png b/bdd/@burgerportaal/@beheer/fixtures/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a1d8c622dcc9b476f0c12ebfe34479d3d5c4928b GIT binary patch literal 79 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|VxBIJAr-fhC#>G#ER-a1)nHZP ck1Pg;Z`wryiv>?z0;*u}boFyt=akR{0G<;R%>V!Z literal 0 HcmV?d00001 diff --git a/bdd/@burgerportaal/@beheer/fixtures/logo.svg b/bdd/@burgerportaal/@beheer/fixtures/logo.svg new file mode 100644 index 0000000..ebfc97a --- /dev/null +++ b/bdd/@burgerportaal/@beheer/fixtures/logo.svg @@ -0,0 +1 @@ +E2E logo diff --git a/bdd/@burgerportaal/@beheer/fixtures/sfeerfoto.png b/bdd/@burgerportaal/@beheer/fixtures/sfeerfoto.png new file mode 100644 index 0000000000000000000000000000000000000000..d08b9ea72cc16d44d60e6f9f6fb9dae5f20d0d91 GIT binary patch literal 118 zcmeAS@N?(olHy`uVBq!ia0vp^4nVBI!2~1^uF2j9q-;H1978H@y*=;9$-uzDvY|U! q?Xu)m9=q0qTso$Iqge0(J+VETLY4`{=hgrXV(@hJb6Mw<&;$UqHW=yv literal 0 HcmV?d00001 diff --git a/bdd/@burgerportaal/@beheer/steps.ts b/bdd/@burgerportaal/@beheer/steps.ts new file mode 100644 index 0000000..1868a13 --- /dev/null +++ b/bdd/@burgerportaal/@beheer/steps.ts @@ -0,0 +1,174 @@ +import type { ImageKind } from '@/bdd/@burgerportaal/support/beheer-config' +import type { Buffer } from 'node:buffer' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { hasOpenRouterKey, settle, stagehandPage } from '@/bdd/_core/stagehand' +import { expect } from '@playwright/test' +import { Before, Given, test, Then, When } from '../../_core/fixture' +import { ENV } from '../../_core/types' + +/** + * Testscript 1 steps. Mutations run through the beheer UI via Stagehand + * (natural-language `act()` on Dutch field/menu labels); assertions run against + * the public site deterministically — the config API + * (`/api/environment/resources`), raw image bytes, and one AI `extract()` to + * prove a burger actually sees the rendered welcome text. + * + * Image replacement uses the authenticated upload API rather than the UI: the + * LOCAL Stagehand browser is CDP-based and cannot drive an OS file dialog, so + * we POST to the very endpoint the beheer UI calls and assert the public effect. + */ + +const base = ENV.apps.burgerportaal.replace(/\/$/, '') + +/** Short unique marker so assertions can't collide with pre-existing config. */ +function uniqueToken(): string { + return `e2e-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}` +} + +function sha(bytes: Buffer): string { + return crypto.createHash('sha256').update(bytes).digest('hex') +} + +async function resource(beheer: { getResources: () => Promise> }, key: string): Promise { + const value = (await beheer.getResources())[key] + return value == null ? '' : String(value) +} + +const FIXTURES_DIR = path.join(__dirname, 'fixtures') +const FIXTURE_IMAGES: Record = { + logo: { name: 'logo.svg', mimeType: 'image/svg+xml', file: 'logo.svg' }, + favicon: { name: 'favicon.png', mimeType: 'image/png', file: 'favicon.png' }, + image: { name: 'sfeerfoto.png', mimeType: 'image/png', file: 'sfeerfoto.png' }, +} + +/** Public config field behind each footer link + its beheer form label. */ +const FOOTER_FIELD: Record = { privacy: 'privacyUrl', contact: 'contactUrl', toegankelijkheid: 'a11yUrl' } +const FOOTER_LABEL: Record = { privacy: 'Privacy', contact: 'Contact', toegankelijkheid: 'Toegankelijkheid' } + +const POLL = { timeout: 20_000, intervals: [500, 1000, 2000] } + +// Skip the whole feature when no model key is configured, so the rest of the +// suite stays green without OpenRouter. +Before({ tags: '@beheer' }, async () => { + test.skip(!hasOpenRouterKey(), 'Set OPENROUTER_API_KEY to run the Stagehand @beheer scenarios') +}) + +Given('the burgerportaal beheer interface is open', async ({ stagehand }) => { + const page = stagehandPage(stagehand) + await page.goto(`${base}/beheer`) + await settle(page) +}) + +// --- Welkomsttekst --------------------------------------------------------- + +When('I set the welcome text to {string}', async ({ stagehand, beheerState }, label: string) => { + const token = uniqueToken() + beheerState.expected.set('welcomeToken', token) + await stagehandPage(stagehand).goto(`${base}/beheer`) + await stagehand.act('Open the "Homepage" item in the beheer navigation menu') + await stagehand.act(`Replace the entire contents of the "Welkomsttekst" rich text editor with: ${label} ${token}`) +}) + +When('I publish the homepage settings', async ({ stagehand }) => { + await stagehand.act('Click the "Publiceren" button to save the homepage settings') + await settle(stagehandPage(stagehand)) +}) + +Then('the public homepage shows the new welcome text', async ({ stagehand, beheer, beheerState }) => { + const token = beheerState.expected.get('welcomeToken')! + // Authoritative: the public config reflects the change. + await expect.poll(() => resource(beheer, 'welcomeText'), POLL).toContain(token) + // And a burger sees it: extract the rendered homepage text via the AI browser. + const page = stagehandPage(stagehand) + await page.goto(`${base}/`) + await settle(page) + const extracted = (await stagehand.extract('Extract the welcome text shown on the homepage')) as { extraction?: string } + expect(extracted.extraction ?? '').toContain(token) +}) + +// --- Promotievideo --------------------------------------------------------- + +When('I set the promotion video URL to {string}', async ({ stagehand }, url: string) => { + await stagehandPage(stagehand).goto(`${base}/beheer`) + await stagehand.act('Open the "Homepage" item in the beheer navigation menu') + await stagehand.act(`Set the "Promotie- of instructievideo" URL field to: ${url}`) +}) + +Then('the public homepage has a promotion video', async ({ beheer }) => { + await expect.poll(() => resource(beheer, 'videoUrl'), POLL).not.toBe('') +}) + +When('I clear the promotion video URL', async ({ stagehand }) => { + await stagehandPage(stagehand).goto(`${base}/beheer`) + await stagehand.act('Open the "Homepage" item in the beheer navigation menu') + await stagehand.act('Clear the "Promotie- of instructievideo" URL field so it is empty') +}) + +Then('the public homepage has no promotion video', async ({ beheer }) => { + await expect.poll(() => resource(beheer, 'videoUrl'), POLL).toBe('') +}) + +// --- Afbeeldingen (logo / favicon / sfeerfoto) ----------------------------- + +When('I replace the {string} image', async ({ beheer, beheerState }, kind: string) => { + const k = kind as ImageKind + const before = await beheer.getPublicImage(k) + beheerState.imageBefore.set(k, sha(before.bytes)) + const fixture = FIXTURE_IMAGES[k] + await beheer.uploadImage(k, { + name: fixture.name, + mimeType: fixture.mimeType, + buffer: fs.readFileSync(path.join(FIXTURES_DIR, fixture.file)), + }) +}) + +Then('the public {string} image has changed', async ({ beheer, beheerState }, kind: string) => { + const k = kind as ImageKind + const before = beheerState.imageBefore.get(k)! + await expect.poll(async () => sha((await beheer.getPublicImage(k)).bytes), POLL).not.toBe(before) +}) + +// --- Externe links --------------------------------------------------------- + +When('I set the organisation website URL to {string}', async ({ stagehand, beheerState }, label: string) => { + const url = `${label}-${uniqueToken()}` + beheerState.expected.set('websiteUrl', url) + await stagehandPage(stagehand).goto(`${base}/beheer`) + await stagehand.act('Open the "Externe links" item in the beheer navigation menu') + await stagehand.act(`Set the "URL Website organisatie" field to: ${url}`) +}) + +When('I publish the external links', async ({ stagehand }) => { + await stagehand.act('Click the "Publiceren" button to save the external links') + await settle(stagehandPage(stagehand)) +}) + +Then('the public organisation website URL matches', async ({ beheer, beheerState }) => { + const url = beheerState.expected.get('websiteUrl')! + await expect.poll(() => resource(beheer, 'websiteUrl'), POLL).toBe(url) +}) + +When('I set the {string} footer link to {string}', async ({ stagehand, beheerState }, which: string, label: string) => { + const url = `${label}-${uniqueToken()}` + beheerState.expected.set(`footer:${which}`, url) + await stagehandPage(stagehand).goto(`${base}/beheer`) + await stagehand.act('Open the "Externe links" item in the beheer navigation menu') + await stagehand.act(`Set the "${FOOTER_LABEL[which]}" URL field to: ${url}`) +}) + +Then('the public {string} footer link matches', async ({ beheer, beheerState }, which: string) => { + const url = beheerState.expected.get(`footer:${which}`)! + await expect.poll(() => resource(beheer, FOOTER_FIELD[which]), POLL).toBe(url) +}) + +When('I remove the {string} footer link', async ({ stagehand }, which: string) => { + await stagehandPage(stagehand).goto(`${base}/beheer`) + await stagehand.act('Open the "Externe links" item in the beheer navigation menu') + await stagehand.act(`Clear the "${FOOTER_LABEL[which]}" URL field so it is empty`) +}) + +Then('the public {string} footer link is empty', async ({ beheer }, which: string) => { + await expect.poll(() => resource(beheer, FOOTER_FIELD[which]), POLL).toBe('') +}) diff --git a/bdd/@burgerportaal/sitemap.feature b/bdd/@burgerportaal/sitemap.feature new file mode 100644 index 0000000..9776a81 --- /dev/null +++ b/bdd/@burgerportaal/sitemap.feature @@ -0,0 +1,34 @@ +Feature: DiWoo sitemap + As the landelijke Woo-index harvester + I want to read the GPP-burgerportaal sitemap anonymously + So that published documents end up findable on https://open.overheid.nl/ + + # The sitemap is public XML: robots.txt -> sitemap index -> one sitemap per + # year/month -> `` entries with DiWoo metadata. These scenarios read it + # signed-out (no @admin/@regular tag), exactly like the harvester does, so + # they run against any environment. They validate the sitemap *structure* + # everywhere and validate document *metadata* whenever documents are present. + # Populating documents, change propagation and cache tuning are documented + # prerequisites (see README "Requirements to run" -> sitemap) — not asserted + # here because they need the ODRC->index pipeline and the 1-minute cache + # override that the testscript calls out as a testomgeving pre-set. + + Scenario: robots.txt advertises the sitemap index + When I fetch the burgerportaal robots.txt + Then the response status is 200 + And it advertises the DiWoo sitemap index location + + Scenario: The sitemap index is served as valid DiWoo XML + When I fetch the sitemap index + Then the response status is 200 + And the response is XML with a sitemaps.org "urlset" root + And every location it lists is a monthly sitemap URL + + Scenario: The current month's sitemap is served as valid XML + When I fetch the current month's sitemap + Then the response status is 200 + And the response is XML with a sitemaps.org "urlset" root + + Scenario: Every document in the current month's sitemap carries DiWoo metadata + When I fetch the current month's sitemap + Then every document entry carries the required DiWoo metadata diff --git a/bdd/@burgerportaal/steps.ts b/bdd/@burgerportaal/steps.ts new file mode 100644 index 0000000..a185f5f --- /dev/null +++ b/bdd/@burgerportaal/steps.ts @@ -0,0 +1,63 @@ +import { + missingDiwooFields, + sitemapLocations, + urlEntries, +} from '@/bdd/@burgerportaal/support/sitemap' +import { Then, When } from '@/bdd/_core/fixture' +import { expect } from '@playwright/test' + +const SITEMAP_INDEX_PATH = '/api/sitemapindex-diwoo.xml' +const SITEMAPS_ORG_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9' +/** A monthly sitemap location, e.g. /api/sitemap/2025/5.xml (month not padded). */ +const MONTHLY_SITEMAP_URL = /\/api\/sitemap\/\d{4}\/\d{1,2}\.xml$/ + +When('I fetch the burgerportaal robots.txt', async ({ sitemap }) => { + await sitemap.get('/robots.txt') +}) + +When('I fetch the sitemap index', async ({ sitemap }) => { + await sitemap.get(SITEMAP_INDEX_PATH) +}) + +When('I fetch the current month\'s sitemap', async ({ sitemap }) => { + const now = new Date() + // Months are not zero-padded in the sitemap index locations. + await sitemap.get(`/api/sitemap/${now.getFullYear()}/${now.getMonth() + 1}.xml`) +}) + +Then('the response status is {int}', async ({ sitemap }, status: number) => { + expect(sitemap.last().status).toBe(status) +}) + +Then('it advertises the DiWoo sitemap index location', async ({ sitemap }) => { + const { body } = sitemap.last() + const line = body.split(/\r?\n/).find(l => /^\s*Sitemap:/i.test(l)) + expect(line, 'robots.txt has a Sitemap: directive').toBeTruthy() + expect(line).toContain(SITEMAP_INDEX_PATH) +}) + +Then( + 'the response is XML with a sitemaps.org {string} root', + async ({ sitemap }, root: string) => { + const { contentType, body } = sitemap.last() + expect(contentType).toContain('xml') + // Root element present (possibly self-closing when empty) in the sitemaps.org namespace. + expect(body).toMatch(new RegExp(`<${root}[\\s>]`)) + expect(body).toContain(SITEMAPS_ORG_NS) + }, +) + +Then('every location it lists is a monthly sitemap URL', async ({ sitemap }) => { + const locations = sitemapLocations(sitemap.last().body) + // Empty on a fresh environment with no published documents — still valid. + for (const loc of locations) + expect(loc, `${loc} is a monthly sitemap URL`).toMatch(MONTHLY_SITEMAP_URL) +}) + +Then('every document entry carries the required DiWoo metadata', async ({ sitemap }) => { + const entries = urlEntries(sitemap.last().body) + for (const entry of entries) + expect(missingDiwooFields(entry), ` entry missing DiWoo fields`).toEqual([]) + // eslint-disable-next-line no-console + console.log(`[sitemap] validated DiWoo metadata on ${entries.length} document(s)`) +}) diff --git a/bdd/@burgerportaal/support/beheer-config.ts b/bdd/@burgerportaal/support/beheer-config.ts new file mode 100644 index 0000000..b9d7736 --- /dev/null +++ b/bdd/@burgerportaal/support/beheer-config.ts @@ -0,0 +1,168 @@ +import type { APIRequestContext } from '@playwright/test' +import type { Buffer } from 'node:buffer' +import { ENV } from '@/bdd/_core/types' + +/** + * The burgerportaal beheer configuration API — read/write the same objects the + * beheer SPA does, plus the public reflections a burger actually sees. Split out + * from the beheer *login* flow (`burgerportaal.ts`): this is a data seam, that is + * an auth seam. + */ + +/** Editable homepage config (`/api/beheer/homepage`). Round-tripped verbatim. */ +export interface HomepageConfig { + welcomeText?: string | null + videoUrl?: string | null + [key: string]: unknown +} + +/** Editable external links config (`/api/beheer/links`). Round-tripped verbatim. */ +export interface LinksConfig { + websiteUrl?: string | null + privacyUrl?: string | null + contactUrl?: string | null + a11yUrl?: string | null + [key: string]: unknown +} + +/** Raw bytes + content type of a public image, for byte-diff asserts and restore. */ +export interface ImageSnapshot { + bytes: Buffer + contentType: string +} + +export type ImageKind = 'logo' | 'favicon' | 'image' +export const IMAGE_KINDS: ImageKind[] = ['logo', 'favicon', 'image'] + +/** `resources` field holding each image's current public URL. */ +const IMAGE_URL_FIELD: Record = { + logo: 'logoUrl', + favicon: 'faviconUrl', + image: 'imageUrl', +} + +/** + * Everything the burgerportaal configuration can hold, captured before a + * scenario mutates it so the scenario can restore it afterwards (test-owned + * data: nothing is left changed). + */ +export interface ConfigSnapshot { + homepage: HomepageConfig + links: LinksConfig + images: Record +} + +/** + * Authenticated client for the burgerportaal beheer config API. Wraps an + * `APIRequestContext` created with the beheer-admin storage state, so every + * request carries the OIDC session cookie. Reads/writes the same objects the + * beheer SPA does, plus the public reflections of them. + */ +export class BeheerConfigClient { + private readonly base: string + + constructor(private readonly request: APIRequestContext) { + this.base = ENV.apps.burgerportaal.replace(/\/$/, '') + } + + /** Public config the burger site renders (`/api/environment/resources`). */ + async getResources(): Promise> { + const res = await this.request.get(`${this.base}/api/environment/resources`) + if (!res.ok()) + throw new Error(`GET /api/environment/resources -> ${res.status()}`) + return res.json() + } + + async getHomepage(): Promise { + const res = await this.request.get(`${this.base}/api/beheer/homepage`) + if (!res.ok()) + throw new Error(`GET /api/beheer/homepage -> ${res.status()} (is the beheer-admin session valid?)`) + return res.json() + } + + async putHomepage(config: HomepageConfig): Promise { + const res = await this.request.put(`${this.base}/api/beheer/homepage`, { data: config }) + if (!res.ok()) + throw new Error(`PUT /api/beheer/homepage -> ${res.status()}: ${await res.text()}`) + } + + async getLinks(): Promise { + const res = await this.request.get(`${this.base}/api/beheer/links`) + if (!res.ok()) + throw new Error(`GET /api/beheer/links -> ${res.status()}`) + return res.json() + } + + async putLinks(config: LinksConfig): Promise { + const res = await this.request.put(`${this.base}/api/beheer/links`, { data: config }) + if (!res.ok()) + throw new Error(`PUT /api/beheer/links -> ${res.status()}: ${await res.text()}`) + } + + /** + * Bytes + content type of a public image, fetched from the URL the burger + * site actually uses. That URL is filename-based and changes on every upload + * (`/api/afbeeldingen/logo_.svg`), so we resolve it from `resources` + * rather than the static `/api/afbeeldingen/{kind}` alias (which serves a + * stale/seeded copy and does not reflect uploads). + */ + async getPublicImage(kind: ImageKind): Promise { + const resources = await this.getResources() + const publicUrl = resources[IMAGE_URL_FIELD[kind]] + if (typeof publicUrl !== 'string' || !publicUrl) + throw new Error(`resources.${IMAGE_URL_FIELD[kind]} is not a URL: ${String(publicUrl)}`) + const res = await this.request.get(this.base + publicUrl) + if (!res.ok()) + throw new Error(`GET ${publicUrl} -> ${res.status()}`) + return { + bytes: await res.body(), + contentType: res.headers()['content-type'] ?? 'application/octet-stream', + } + } + + /** Upload a replacement image (`POST /api/beheer/afbeeldingen/{kind}`, multipart). */ + async uploadImage(kind: ImageKind, file: { name: string, mimeType: string, buffer: Buffer }): Promise { + const res = await this.request.post(`${this.base}/api/beheer/afbeeldingen/${kind}`, { + multipart: { file }, + }) + if (!res.ok()) + throw new Error(`POST /api/beheer/afbeeldingen/${kind} -> ${res.status()}: ${await res.text()}`) + } + + /** Capture the full current config so a scenario can restore it in teardown. */ + async snapshot(): Promise { + const [homepage, links] = await Promise.all([this.getHomepage(), this.getLinks()]) + const images = {} as Record + for (const kind of IMAGE_KINDS) + images[kind] = await this.getPublicImage(kind) + return { homepage, links, images } + } + + /** Put a previously captured snapshot back, undoing everything a scenario changed. */ + async restore(snap: ConfigSnapshot): Promise { + await this.putHomepage(snap.homepage) + await this.putLinks(snap.links) + for (const kind of IMAGE_KINDS) { + const img = snap.images[kind] + await this.uploadImage(kind, { + name: `${kind}${extensionFor(img.contentType)}`, + mimeType: img.contentType, + buffer: img.bytes, + }) + } + } +} + +function extensionFor(contentType: string): string { + if (contentType.includes('svg')) + return '.svg' + if (contentType.includes('png')) + return '.png' + if (contentType.includes('x-icon') || contentType.includes('vnd.microsoft.icon')) + return '.ico' + if (contentType.includes('jpeg') || contentType.includes('jpg')) + return '.jpg' + if (contentType.includes('webp')) + return '.webp' + return '.bin' +} diff --git a/bdd/@burgerportaal/support/login.ts b/bdd/@burgerportaal/support/login.ts new file mode 100644 index 0000000..11e52a6 --- /dev/null +++ b/bdd/@burgerportaal/support/login.ts @@ -0,0 +1,27 @@ +import type { User } from '@/bdd/_core/types' +import type { Page } from '@playwright/test' +import { awaitKeycloakFormOrSso, fillKeycloakLogin } from '@/bdd/_core/keycloak' +import { ENV } from '@/bdd/_core/types' + +/** + * Sign in to the GPP-burgerportaal beheer (admin config) UI via Keycloak OIDC. + * + * The beheer SPA is protected by the ASP.NET OIDC client `odbp`. A full-page + * navigation to `/api/challenge?returnUrl=/beheer` triggers the OIDC challenge: + * Keycloak either shows its login form (+ TOTP) or — when a realm-wide SSO + * session already exists — redirects straight back to `/beheer` authenticated. + * The session lives in a cookie, so the resulting storage state is all a later + * browser context or APIRequestContext needs. + * + * The beheer config API lives in `beheer-config.ts` (a separate data seam). + */ +export async function signInToBurgerportaalBeheer(page: Page, user: User) { + const base = ENV.apps.burgerportaal.replace(/\/$/, '') + const onBeheer = /\/beheer/ + await page.goto(`${base}/api/challenge?returnUrl=/beheer`) + + if (await awaitKeycloakFormOrSso(page, onBeheer)) + await fillKeycloakLogin(page, user) + + await page.waitForURL(onBeheer) +} diff --git a/bdd/@burgerportaal/support/sitemap.ts b/bdd/@burgerportaal/support/sitemap.ts new file mode 100644 index 0000000..67ed4fa --- /dev/null +++ b/bdd/@burgerportaal/support/sitemap.ts @@ -0,0 +1,56 @@ +/** + * Minimal, dependency-free helpers for the GPP-burgerportaal DiWoo sitemap. + * + * The sitemap is public XML the Woo-index harvester reads anonymously + * (robots.txt -> sitemap index -> one sitemap per year/month -> `` entries + * with DiWoo metadata). These helpers extract the bits the tests assert on. + * + * Matching is namespace-prefix tolerant: the burgerportaal serialises DiWoo + * elements with a `diwoo:` prefix, so every tag matcher accepts an optional + * `prefix:` (e.g. both `` and ``). + */ + +/** `` with an optional namespace prefix, e.g. `loc` or `diwoo:Document`. */ +function tag(name: string) { + return String.raw`(?:\w+:)?${name}` +} + +/** All text contents of `...` occurrences (namespace-tolerant). */ +export function elementTexts(xml: string, name: string): string[] { + const re = new RegExp(`<${tag(name)}[^>]*>([\\s\\S]*?)`, 'g') + return Array.from(xml.matchAll(re), m => m[1].trim()) +} + +/** True when at least one `` element (self-closing or not) is present. */ +export function hasElement(xml: string, name: string): boolean { + return new RegExp(`<${tag(name)}(?:[\\s/>])`).test(xml) +} + +/** The `` values listed in a sitemap index. */ +export function sitemapLocations(indexXml: string): string[] { + return elementTexts(indexXml, 'loc') +} + +/** Each `...` block of a sitemap, as raw XML fragments. */ +export function urlEntries(sitemapXml: string): string[] { + const re = new RegExp(`<${tag('url')}[^>]*>[\\s\\S]*?`, 'g') + return sitemapXml.match(re) ?? [] +} + +/** DiWoo metadata every `` entry must carry (testscript step 3c). */ +export const REQUIRED_DIWOO_FIELDS = [ + 'loc', + 'lastmod', + 'Document', + 'creatiedatum', + 'officieleTitel', + 'publisher', + 'verantwoordelijke', + 'informatiecategorie', + 'soortHandeling', +] as const + +/** Field names from {@link REQUIRED_DIWOO_FIELDS} missing from a `` entry. */ +export function missingDiwooFields(entry: string): string[] { + return REQUIRED_DIWOO_FIELDS.filter(f => !hasElement(entry, f)) +} diff --git a/bdd/@burgerportaal/zoeken-steps.ts b/bdd/@burgerportaal/zoeken-steps.ts new file mode 100644 index 0000000..9795b34 --- /dev/null +++ b/bdd/@burgerportaal/zoeken-steps.ts @@ -0,0 +1,96 @@ +import { waitForWithReload } from '@/bdd/@gpp-app/support/hydrate' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' +import { Before, Given, test, Then, When } from '../_core/fixture' + +// The burgerportaal SPA never finishes booting in WebKit on this stack (stuck on +// its "wordt geladen…" splash), so skip there instead of timing out. Chromium +// and Firefox boot it fine. +Before({ tags: '@no-webkit' }, async ({ browserName }) => { + test.skip( + browserName === 'webkit', + '@no-webkit: the burgerportaal SPA does not boot in WebKit on this stack (stuck on the loading splash).', + ) +}) + +/** + * Testscript 11 (zoeken en raadplegen, burger) steps. The public burgerportaal is + * driven deterministically through the ordinary Playwright `page` (the portal is + * public — the @admin session on `page` is only used to seed the onderwerp). No + * mutations happen here, so there is nothing for Stagehand to do. + * + * Onderwerpen are seeded through the publicatiebank admin (the `topics` fixture) + * and are served live on the burgerportaal, so a freshly published onderwerp is + * browsable within seconds. See the feature file for why search-hit indexing and + * the homepage counters are not asserted. + */ + +const burg = ENV.apps.burgerportaal.replace(/\/$/, '') +const LIVE = { timeout: 30_000, intervals: [1000, 2000, 3000] } + +Given('the burgerportaal homepage is open', async ({ page }) => { + await page.goto(burg) + await page.waitForLoadState('networkidle').catch(() => {}) +}) + +// Prerequisite (deterministic, through the admin): a promoted, gepubliceerd +// onderwerp — the only kind visible to the public. +Given('a promoted, published onderwerp', async ({ topics, scratch }) => { + const omschrijving = `E2E burger omschrijving ${Date.now()}` + scratch.set('onderwerp:omschrijving', omschrijving) + await topics.add(undefined, { status: 'gepubliceerd', promoot: true, omschrijving }) +}) + +// --- Full-text search (experience, not seeded-content indexing) ------------- + +When('I search the burgerportaal for {string}', async ({ page }, term: string) => { + await page.goto(burg) + await page.waitForLoadState('networkidle').catch(() => {}) + // The search field can be late to hydrate on WebKit; reload-retry until it is. + const field = page.locator('#search-field') + await waitForWithReload(page, field) + await field.fill(term) + await field.press('Enter') + await page.waitForURL(/\/zoeken/, { timeout: 15_000 }).catch(() => {}) +}) + +Then('I land on the search results page', async ({ page }) => { + expect(page.url()).toContain('/zoeken') +}) + +// --- Browse onderwerpen (served live from the publicatiebank) --------------- + +When('I open the Onderwerpen page on the burgerportaal', async ({ page }) => { + await page.goto(`${burg}/onderwerpen`) + await page.waitForLoadState('networkidle').catch(() => {}) +}) + +Then('the onderwerp is listed with its omschrijving', async ({ page, topics, scratch }) => { + const titel = topics.last() + const omschrijving = scratch.get('onderwerp:omschrijving')! + // The list is served live from the publicatiebank; allow a few seconds and a + // reload for propagation, then read the rendered page text. + await expect.poll(async () => { + await page.goto(`${burg}/onderwerpen`) + await page.waitForLoadState('networkidle').catch(() => {}) + return page.locator('body').textContent() + }, LIVE).toContain(titel) + expect(await page.locator('body').textContent()).toContain(omschrijving) +}) + +When('I open that onderwerp on the burgerportaal', async ({ page, topics }) => { + const titel = topics.last() + await expect.poll(async () => { + await page.goto(`${burg}/onderwerpen`) + await page.waitForLoadState('networkidle').catch(() => {}) + return page.locator('body').textContent() + }, LIVE).toContain(titel) + await page.getByText(titel, { exact: false }).first().click() + await page.waitForLoadState('networkidle').catch(() => {}) +}) + +Then('its omschrijving is shown', async ({ page, scratch }) => { + const omschrijving = scratch.get('onderwerp:omschrijving')! + await expect.poll(() => page.locator('body').textContent(), { timeout: 10_000, intervals: [400, 800, 1500] }) + .toContain(omschrijving) +}) diff --git a/bdd/@burgerportaal/zoeken.feature b/bdd/@burgerportaal/zoeken.feature new file mode 100644 index 0000000..cae2a73 --- /dev/null +++ b/bdd/@burgerportaal/zoeken.feature @@ -0,0 +1,42 @@ +# Testscript 11 — Zoeken en raadplegen (burger). +# +# A burger browses the public GPP-burgerportaal. The portal is public and its +# pages are stable, so these scenarios drive it deterministically (no Stagehand / +# no OpenRouter needed) — reading is exactly the kind of verification the suite +# does deterministically. The onderwerp under test is seeded through the +# publicatiebank admin (hence @admin, which authenticates the seed) and cleaned +# up again. +# +# Scope note: the burgerportaal's onderwerpen are served live from the +# publicatiebank, so a freshly published onderwerp is browsable within seconds. +# Full-text SEARCH results and the homepage counters come from woo-search's +# Elasticsearch, whose harvest/index pipeline is a documented environment +# prerequisite that is not active on the test stack (see sitemap.feature) — so we +# assert the search *experience* (the results page opens) but not that freshly +# seeded content is indexed. Documents are out of scope (no Documents API — see +# PLAN-plateau4-remaining.md). +# +# @no-webkit — the burgerportaal SPA never leaves its "wordt geladen…" splash in +# WebKit on this stack (a WebKit-specific boot failure; Chromium and Firefox boot +# it fine), so its steps can never see the search field. Skip on WebKit (see +# bdd/@burgerportaal/zoeken-steps.ts) rather than fail on an app that won't boot. +@burgerportaal @admin @mode:serial @timeout:120000 @no-webkit +Feature: Zoeken en raadplegen op het GPP-burgerportaal + As a burger + I want to search and browse the public portal + So that I can find and read openbare onderwerpen and publicaties + + Scenario: The homepage offers full-text search + Given the burgerportaal homepage is open + When I search the burgerportaal for "woo" + Then I land on the search results page + + Scenario: A published onderwerp is browsable on the Onderwerpen page + Given a promoted, published onderwerp + When I open the Onderwerpen page on the burgerportaal + Then the onderwerp is listed with its omschrijving + + Scenario: A published onderwerp can be opened and read + Given a promoted, published onderwerp + When I open that onderwerp on the burgerportaal + Then its omschrijving is shown diff --git a/bdd/@gpp-app/authentication.feature b/bdd/@gpp-app/authentication.feature new file mode 100644 index 0000000..a90b176 --- /dev/null +++ b/bdd/@gpp-app/authentication.feature @@ -0,0 +1,24 @@ +# Live Keycloak logins can hit a used TOTP code when another login for the same +# account (e.g. the auth setup) shares the 30s window; the helper waits a window +# and retries. On WebKit/Firefox the gpp-app nav can also need a reload to +# hydrate (@gpp-app/support/login.ts). Allow extra time for both. +# +# @chromium-only — the live Keycloak redirect targets keycloak.woo-search.local, +# which only resolves in Chromium via the --host-resolver-rules launch arg +# (playwright.config.ts). Firefox/WebKit ignore that flag and cannot reach the +# host, so this feature runs on Chromium only (skip in bdd/@gpp-app/steps.ts). +@timeout:120000 @chromium-only +Feature: GPP-app authentication + As a GPP-Woo user + I want to sign in to the GPP-app via my organisation account + So that I can manage my publications + + Scenario: Sign in as admin via Keycloak + Given I am on the GPP-app + When I sign in to the GPP-app as "admin" + Then I should see my publications + + Scenario: Sign in as a regular user via Keycloak + Given I am on the GPP-app + When I sign in to the GPP-app as "regular" + Then I should see my publications diff --git a/bdd/@gpp-app/gebruikersgroepen-steps.ts b/bdd/@gpp-app/gebruikersgroepen-steps.ts new file mode 100644 index 0000000..15f963a --- /dev/null +++ b/bdd/@gpp-app/gebruikersgroepen-steps.ts @@ -0,0 +1,104 @@ +import type { Stagehand } from '@browserbasehq/stagehand' +import { settle, stagehandPage } from '@/bdd/_core/stagehand' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' +import { Given, Then, When } from '../_core/fixture' + +/** + * Testscript 5 (gebruikersgroepen) steps. Mutations run through the gpp-app UI + * via Stagehand `act()` on the `adminStagehand` browser (its adminState cookies + * authenticate the gpp-app — the admin account is the AD-beheerder). Verification + * and cleanup use the odpc JSON API through the `usergroups` fixture — the token + * API on the publicatiebank is not involved here. + * + * The `@anthropic` skip guard (no OpenRouter key → skip) is shared with the other + * Stagehand features (see @publicatiebank/@admin/steps.ts). + */ + +const gppApp = ENV.apps.gppApp.replace(/\/$/, '') +const READ = { timeout: 15_000, intervals: [500, 1000, 2000] } + +// The gpp-app is a SPA that only hydrates its routes when navigated via clicks +// from the app root — deep-linking a route (e.g. /gebruikersgroepen/nieuw) leaves +// the form unrendered. So every flow starts at the root and clicks through. +async function openGebruikersgroepen(stagehand: Stagehand) { + const page = stagehandPage(stagehand) + await page.goto(gppApp) + await settle(page) + await stagehand.act('Click "Gebruikersgroepen" in the top navigation') + await settle(page) +} + +/** Create a gebruikersgroep with just a naam through the gpp-app UI (Stagehand). */ +async function createGroupViaUi(stagehand: Stagehand, naam: string) { + await openGebruikersgroepen(stagehand) + await stagehand.act('Click the "Nieuwe gebruikersgroep" button') + await stagehand.act(`Fill the "Naam" field with: ${naam}`) + await stagehand.act('Click the "Opslaan" button to save the gebruikersgroep') + await settle(stagehandPage(stagehand)) +} + +/** Open a gebruikersgroep by naam from the overview (Stagehand). */ +async function openGroup(stagehand: Stagehand, naam: string) { + await openGebruikersgroepen(stagehand) + await stagehand.act(`Open the gebruikersgroep named "${naam}" by clicking it`) + await settle(stagehandPage(stagehand)) +} + +// --- Create ----------------------------------------------------------------- + +When('I create a gebruikersgroep through the gpp-app', async ({ adminStagehand, usergroups }) => { + const naam = usergroups.freshName() + await createGroupViaUi(adminStagehand, naam) + usergroups.track(naam) +}) + +Then('the gebruikersgroep exists', async ({ usergroups }) => { + const naam = usergroups.last() + await expect.poll(() => usergroups.exists(naam), READ).toBe(true) +}) + +// --- Prerequisite ----------------------------------------------------------- + +Given('a gebruikersgroep', async ({ adminStagehand, usergroups }) => { + const naam = usergroups.freshName() + await createGroupViaUi(adminStagehand, naam) + usergroups.track(naam) + // Make sure it actually landed before the scenario mutates it. + await expect.poll(() => usergroups.exists(naam), READ).toBe(true) +}) + +// --- Rename ----------------------------------------------------------------- + +When('I rename the gebruikersgroep through the gpp-app', async ({ adminStagehand, usergroups, scratch }) => { + const oldName = usergroups.last() + const newName = `${usergroups.freshName()} hernoemd` + scratch.set('groep:oldName', oldName) + await openGroup(adminStagehand, oldName) + await adminStagehand.act(`Replace the contents of the "Naam" field with: ${newName}`) + await adminStagehand.act('Click the "Opslaan" button to save the gebruikersgroep') + await settle(stagehandPage(adminStagehand)) + usergroups.track(newName) +}) + +Then('the gebruikersgroep is known under its new name and not the old one', async ({ usergroups, scratch }) => { + const newName = usergroups.last() + const oldName = scratch.get('groep:oldName')! + await expect.poll(() => usergroups.exists(newName), READ).toBe(true) + expect(await usergroups.exists(oldName)).toBe(false) +}) + +// --- Delete ----------------------------------------------------------------- + +When('I delete the gebruikersgroep through the gpp-app', async ({ adminStagehand, usergroups }) => { + const naam = usergroups.last() + await openGroup(adminStagehand, naam) + await adminStagehand.act('Click the "Verwijderen" button to delete this gebruikersgroep') + await adminStagehand.act('Confirm the deletion by clicking the "Ja, verwijderen" button') + await settle(stagehandPage(adminStagehand)) +}) + +Then('the gebruikersgroep no longer exists', async ({ usergroups }) => { + const naam = usergroups.last() + await expect.poll(() => usergroups.exists(naam), READ).toBe(false) +}) diff --git a/bdd/@gpp-app/gebruikersgroepen.feature b/bdd/@gpp-app/gebruikersgroepen.feature new file mode 100644 index 0000000..e10b00c --- /dev/null +++ b/bdd/@gpp-app/gebruikersgroepen.feature @@ -0,0 +1,33 @@ +# Testscript 5 — Inrichten van gebruikersgroepen en autorisaties. +# +# A functioneel-beheerder manages gebruikersgroepen in the GPP-app. An AI agent +# (Stagehand) performs the UI mutations (create / rename / delete) exactly as a +# beheerder would; the result is verified and cleaned up deterministically +# through the odpc JSON API (/api/gebruikersgroepen). The admin account carries +# the AD-beheerder role, so the "Gebruikersgroepen" section is available. +# +# Scope note: this covers the gebruikersgroep lifecycle (naam). The per-group +# autorisaties (koppeling aan organisaties/informatiecategorieën/onderwerpen) +# depend on the gpp-app loading the woo-publications "waardelijsten", which it +# fetches through the same service token that intermittently 500s under load +# (see README "Known server flake") and are not exposed on the group list API, so +# they are not asserted here. +@gpp-app @anthropic @mode:serial @timeout:120000 +Feature: Gebruikersgroepen beheren in de GPP-app + As functioneel beheer + I want to create, rename and delete gebruikersgroepen + So that I can control who may publish on behalf of which organisations + + Scenario: Create a gebruikersgroep + When I create a gebruikersgroep through the gpp-app + Then the gebruikersgroep exists + + Scenario: Rename a gebruikersgroep + Given a gebruikersgroep + When I rename the gebruikersgroep through the gpp-app + Then the gebruikersgroep is known under its new name and not the old one + + Scenario: Delete a gebruikersgroep + Given a gebruikersgroep + When I delete the gebruikersgroep through the gpp-app + Then the gebruikersgroep no longer exists diff --git a/bdd/@gpp-app/publicaties-steps.ts b/bdd/@gpp-app/publicaties-steps.ts new file mode 100644 index 0000000..107744a --- /dev/null +++ b/bdd/@gpp-app/publicaties-steps.ts @@ -0,0 +1,40 @@ +import { Before, Given, test, Then, When } from '../_core/fixture' + +/** + * Testscripts 6 & 7 (eindgebruiker publicatie flows) are parked: no available + * account is a member of an authorised gebruikersgroep, so the gpp-app "Nieuwe + * publicatie" form errors and renders nothing to drive (see publicaties.feature + * and PLAN-plateau4-remaining.md). The @blocked scenario is skipped-with-reason + * so it stays visible; these step bodies exist only so bddgen can resolve it and + * never run. Implement them once a test account has authorised-group membership. + */ + +const BLOCKED = 'TS6/TS7 blocked: signed-in user is not a member of an authorised gebruikersgroep (see publicaties.feature)' + +Before({ tags: '@blocked' }, async () => { + test.skip(true, BLOCKED) +}) + +Given('the signed-in user belongs to an authorised gebruikersgroep', async () => { + throw new Error(BLOCKED) +}) + +Given('a published publicatie owned by the signed-in user', async () => { + throw new Error(BLOCKED) +}) + +When('I create and publish a publicatie through the gpp-app', async () => { + throw new Error(BLOCKED) +}) + +When('I withdraw the publicatie through the gpp-app', async () => { + throw new Error(BLOCKED) +}) + +Then('the publicatie is public on the burgerportaal', async () => { + throw new Error(BLOCKED) +}) + +Then('the publicatie is no longer public', async () => { + throw new Error(BLOCKED) +}) diff --git a/bdd/@gpp-app/publicaties.feature b/bdd/@gpp-app/publicaties.feature new file mode 100644 index 0000000..6ff5a95 --- /dev/null +++ b/bdd/@gpp-app/publicaties.feature @@ -0,0 +1,31 @@ +# Testscripts 6 & 7 — Creëren / wijzigen / intrekken van een publicatie (eindgebruiker). +# +# PARKED / BLOCKED — not runnable as the test stack is provisioned. Creating or +# editing a publicatie in the GPP-app requires the signed-in user to be a member +# of a gebruikersgroep that is authorised for at least one organisatie and +# informatiecategorie (the "profiel" a publicatie is made under). On this stack +# `/api/mijn-gebruikersgroepen` returns `[]` for every available account (incl. +# the admin), so "Nieuwe publicatie" shows "Er is iets misgegaan bij het ophalen +# van de gegevens" and renders no form — there is nothing to drive. (All backing +# API calls return 200; the blocker is missing authorised-group membership, not a +# server error.) Documents would be blocked regardless (no Documents API — see +# PLAN-plateau4-remaining.md). +# +# To unblock: give a test account membership of a gebruikersgroep authorised for +# an organisatie + informatiecategorie (extend TS5: create group → "Gebruiker +# toevoegen" → authorise org/categorie). Then implement create/edit/withdraw via +# Stagehand (the SPA hydrates only via click-navigation from the app root — see +# gebruikersgroepen-steps.ts) with "Publicatie zonder documenten" for publishing, +# verifying + cleaning up through the publicatiebank admin (publication.ts). +@blocked @gpp-app +Feature: Publicaties creëren en wijzigen in de GPP-app (geblokkeerd) + + Scenario: Create a publicatie (blocked: no authorised gebruikersgroep membership) + Given the signed-in user belongs to an authorised gebruikersgroep + When I create and publish a publicatie through the gpp-app + Then the publicatie is public on the burgerportaal + + Scenario: Withdraw a publicatie (blocked: no authorised gebruikersgroep membership) + Given a published publicatie owned by the signed-in user + When I withdraw the publicatie through the gpp-app + Then the publicatie is no longer public diff --git a/bdd/@gpp-app/steps.ts b/bdd/@gpp-app/steps.ts new file mode 100644 index 0000000..cf204ea --- /dev/null +++ b/bdd/@gpp-app/steps.ts @@ -0,0 +1,35 @@ +import { Before, Given, test, Then, When } from '@/bdd/_core/fixture' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' + +// The live Keycloak login redirects to keycloak.woo-search.local, resolvable +// only in Chromium (the --host-resolver-rules launch arg is Chrome-only). Skip +// this feature on Firefox/WebKit rather than fail on an unreachable host. +Before({ tags: '@chromium-only' }, async ({ browserName }) => { + test.skip( + browserName !== 'chromium', + `@chromium-only: keycloak.woo-search.local resolves only in Chromium (host-resolver launch arg); ${browserName} cannot reach it.`, + ) +}) + +Given('I am on the GPP-app', async ({ page }) => { + await page.goto(ENV.apps.gppApp) +}) + +Given('I am logged in to the GPP-app', async ({ page }) => { + // Session is restored from storage state (@admin / @regular tag). + await page.goto(ENV.apps.gppApp) + await expect(page.getByRole('link', { name: 'Mijn publicaties' })).toBeVisible() +}) + +When('I sign in to the GPP-app as {string}', async ({ signIn, currentUser }, userKey: string) => { + const user = ENV.users[userKey] + if (!user) + throw new Error(`Unknown user "${userKey}" (known: ${Object.keys(ENV.users).join(', ')})`) + currentUser.value = user + await signIn('gppApp') +}) + +Then('I should see my publications', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Mijn publicaties' })).toBeVisible() +}) diff --git a/bdd/@gpp-app/support/hydrate.ts b/bdd/@gpp-app/support/hydrate.ts new file mode 100644 index 0000000..1333506 --- /dev/null +++ b/bdd/@gpp-app/support/hydrate.ts @@ -0,0 +1,27 @@ +import type { Locator, Page } from '@playwright/test' + +/** + * Wait for a locator to become visible, reloading the page a few times if it + * doesn't show up. The GPP SPAs (odpc gpp-app, the burgerportaal) occasionally + * fail to hydrate their first render on non-Chromium engines (WebKit/Firefox); + * a plain reload reliably nudges them. Chromium hits the element on the first + * try, so this is a no-op there. + */ +export async function waitForWithReload( + page: Page, + locator: Locator, + { tries = 4, timeout = 15_000 }: { tries?: number, timeout?: number } = {}, +) { + for (let attempt = 0; attempt < tries; attempt++) { + try { + await locator.first().waitFor({ state: 'visible', timeout }) + return + } + catch (error) { + if (attempt === tries - 1) + throw error + await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {}) + await page.waitForLoadState('networkidle').catch(() => {}) + } + } +} diff --git a/bdd/@gpp-app/support/login.ts b/bdd/@gpp-app/support/login.ts new file mode 100644 index 0000000..a787fd8 --- /dev/null +++ b/bdd/@gpp-app/support/login.ts @@ -0,0 +1,25 @@ +import type { User } from '@/bdd/_core/types' +import type { Page } from '@playwright/test' +import { fillKeycloakLogin } from '@/bdd/_core/keycloak' +import { waitForWithReload } from './hydrate' + +/** + * Sign in to the GPP-app (odpc) front-end via Keycloak OIDC. + * + * Flow: landing page -> "Inloggen" -> Keycloak form -> "Mijn publicaties". + * When a realm-wide SSO session already exists, the app lands straight on the + * authenticated view and the login link is never shown. + */ +export async function signInToGppApp(page: Page, user: User) { + const loginLink = page.getByRole('link', { name: 'Inloggen' }) + const publicatiesLink = page.getByRole('link', { name: 'Mijn publicaties' }) + + // The gpp-app nav can fail to hydrate on the first render (WebKit/Firefox); + // reload-retry until one of the links shows. + await waitForWithReload(page, loginLink.or(publicatiesLink)) + if (await loginLink.isVisible()) { + await loginLink.click() + await fillKeycloakLogin(page, user) + } + await publicatiesLink.waitFor() +} diff --git a/bdd/@gpp-app/support/usergroup.ts b/bdd/@gpp-app/support/usergroup.ts new file mode 100644 index 0000000..e2932dc --- /dev/null +++ b/bdd/@gpp-app/support/usergroup.ts @@ -0,0 +1,51 @@ +import type { APIRequestContext } from '@playwright/test' +import { ENV } from '@/bdd/_core/types' + +/** + * Read/cleanup helpers for gpp-app gebruikersgroepen via the odpc JSON API + * (`/api/gebruikersgroepen`, session-authenticated). The @beheer scenarios mutate + * gebruikersgroepen through the gpp-app UI with Stagehand; these helpers verify + * the result and clean up deterministically — the same split as the publicatie + * beheer scenarios (Stagehand mutate, deterministic read). + * + * The list endpoint returns `[{ naam, uuid }]`; that is enough to assert a group + * exists / was renamed / was deleted, and to resolve a uuid for cleanup. + */ + +interface Usergroup { + naam: string + uuid: string +} + +function endpoint() { + return new URL('/api/gebruikersgroepen', ENV.apps.gppApp).href +} + +/** All gebruikersgroepen visible to the signed-in user. */ +export async function listUsergroups(ctx: APIRequestContext): Promise { + const res = await ctx.get(endpoint()) + if (!res.ok()) + throw new Error(`GET gebruikersgroepen -> ${res.status()}: ${await res.text()}`) + const body = await res.json() + return Array.isArray(body) ? body : (body.results ?? []) +} + +/** Whether a gebruikersgroep with exactly this naam exists. */ +export async function usergroupExists(ctx: APIRequestContext, naam: string): Promise { + return (await listUsergroups(ctx)).some(g => g.naam === naam) +} + +/** Delete a gebruikersgroep by exact naam via the API. No-op if already gone. */ +export async function deleteUsergroupByName(ctx: APIRequestContext, naam: string): Promise { + const group = (await listUsergroups(ctx)).find(g => g.naam === naam) + if (!group) + return + const res = await ctx.delete(`${endpoint()}/${group.uuid}`) + if (!res.ok() && res.status() !== 404) + throw new Error(`DELETE gebruikersgroep ${group.uuid} -> ${res.status()}`) +} + +/** Naam of leftover `E2E `-prefixed gebruikersgroepen, for the run-wide sweep. */ +export async function listE2EUsergroupNames(ctx: APIRequestContext, prefix = 'E2E '): Promise { + return (await listUsergroups(ctx)).map(g => g.naam).filter(n => n.startsWith(prefix)) +} diff --git a/bdd/@publicatiebank/@admin/documenten-steps.ts b/bdd/@publicatiebank/@admin/documenten-steps.ts new file mode 100644 index 0000000..6c7a3d6 --- /dev/null +++ b/bdd/@publicatiebank/@admin/documenten-steps.ts @@ -0,0 +1,28 @@ +import { Before, Given, test, Then, When } from '../../_core/fixture' + +/** + * Testscript 8 (document beheer) is parked: this stack has no Documents API + * configured, so no document can exist to manage (see documenten.feature and + * PLAN-plateau4-remaining.md). The @blocked scenario is skipped-with-reason so it + * stays visible in the report; these step bodies exist only so bddgen can resolve + * it and never run. If a Documents API is configured later, replace them with + * real admin mutations mirroring publicaties-steps.ts. + */ + +const BLOCKED = 'TS8 is blocked: no Documents API configured on this stack (see documenten.feature)' + +Before({ tags: '@blocked' }, async () => { + test.skip(true, BLOCKED) +}) + +Given('the Documents API is configured', async () => { + throw new Error(BLOCKED) +}) + +When('I withdraw the document through the admin', async () => { + throw new Error(BLOCKED) +}) + +Then('the document is no longer public', async () => { + throw new Error(BLOCKED) +}) diff --git a/bdd/@publicatiebank/@admin/documenten.feature b/bdd/@publicatiebank/@admin/documenten.feature new file mode 100644 index 0000000..e864b86 --- /dev/null +++ b/bdd/@publicatiebank/@admin/documenten.feature @@ -0,0 +1,19 @@ +# Testscript 8 — Wijzigen of verwijderen van een document (beheer). +# +# PARKED / BLOCKED — not runnable on this stack. Documents cannot be created +# anywhere: woo-publications has no Documents API configured, so `/documenten` +# POST (and the gpp-app upload, which shares that backend) return +# 500 "No documents API configured yet! Set up the global configuration." +# (See PLAN-plateau4-remaining.md.) With no documents to edit/withdraw/delete, +# every TS8 step is a no-op, so the scenario is @skip-ped rather than faked green. +# +# To unblock: configure a Documents API (Django admin → global configuration / +# services) so `/documenten` accepts creates, then implement seeding + admin +# mutations mirroring publicaties.feature (TS9). +@blocked @publicatiebank @admin +Feature: Document beheer in de GPP-publicatiebank (geblokkeerd) + + Scenario: Withdraw a document (blocked: no Documents API configured) + Given the Documents API is configured + When I withdraw the document through the admin + Then the document is no longer public diff --git a/bdd/@publicatiebank/@admin/fixtures/onderwerp.png b/bdd/@publicatiebank/@admin/fixtures/onderwerp.png new file mode 100644 index 0000000000000000000000000000000000000000..61a6540bd85d5660961c87c6a5b91d4a8d98fe18 GIT binary patch literal 74 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYi^K~ERQkcwN$0balRMGbj$IT?O! WPCTf2>&SDUB!j1`pUXO@geCwY8WOkw literal 0 HcmV?d00001 diff --git a/bdd/@publicatiebank/@admin/informatiecategorieen.feature b/bdd/@publicatiebank/@admin/informatiecategorieen.feature new file mode 100644 index 0000000..b3340db --- /dev/null +++ b/bdd/@publicatiebank/@admin/informatiecategorieen.feature @@ -0,0 +1,47 @@ +Feature: Informatiecategorieën management + As an administrator of the GPP-publicatiebank + I want to browse, search, filter and sort information categories + So that I can manage the metadata catalogue + + # Every scenario creates the self-added information category it needs and + # deletes it again afterwards (see the `categories` fixture). The `waardelijst` + # reference categories are a documented prerequisite (they cannot be created + # via the UI/API); their presence is verified during auth setup. + Background: + Given I am logged in to the publicatiebank admin + And I have added a self-added information category + And I open the "Informatiecategorieën" metadata page + + Scenario: The table headers are visible and clickable + Then the "Naam" column header is visible and clickable + And the "Identificatie" column header is visible and clickable + And the "Oorsprong" column header is visible and clickable + + Scenario: Move information categories through the actions menu + When I choose "Move" from the "Acties" menu + + Scenario: Search finds the self-added category + When I search for the self-added information category + Then I see the self-added information category in the results + + Scenario: Filter by self-added origin lists the category + When I filter on "Zelf-toegevoegd item" + Then every result has "Zelf-toegevoegd item" as its origin + And the self-added information category is listed + + Scenario: Filter by Waardelijst excludes the self-added category + When I filter on "Waardelijst" + Then every result has "Waardelijst" as its origin + + Scenario: The "Alle" filter shows both origins + When I filter on "Alle" + Then the results contain both "Waardelijst" and "Zelf-toegevoegd item" origins + + Scenario: Open the self-added category detail view + When I search for the self-added information category + And I open the self-added information category + Then I am on its information category change page + + Scenario: Sort information categories by name + When I sort ascending by the "Naam" column + Then the information categories are listed alphabetically by name diff --git a/bdd/@publicatiebank/@admin/onderwerpen-steps.ts b/bdd/@publicatiebank/@admin/onderwerpen-steps.ts new file mode 100644 index 0000000..bdd0128 --- /dev/null +++ b/bdd/@publicatiebank/@admin/onderwerpen-steps.ts @@ -0,0 +1,114 @@ +import type { Stagehand } from '@browserbasehq/stagehand' +import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' +import { TOPIC_IMAGE, topicExists, topicIsPromoted, topicOmschrijving } from '@/bdd/@publicatiebank/support/topic' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' +import { Given, Then, When } from '../../_core/fixture' + +/** + * Testscript 3 (onderwerpen) steps. UI *mutations* run through the Django admin + * via Stagehand `act()` behind the shared {@link adminDriver} seam — except the + * mandatory afbeelding on the add form, whose bytes are set straight on the + * (a CDP browser cannot drive an OS file-picker). Assertions + * are made *deterministically* by reading the admin back through the ordinary + * Playwright `page` (session-authenticated, stable), because the token API is + * unreliable while Stagehand drives the same server (see README "Known server + * flake"). + * + * The `@anthropic`-tagged skip guard and the `adminStagehand` fixture are shared + * with the organisatie steps (see steps.ts). + */ + +const pub = ENV.apps.publicatiebank.replace(/\/$/, '') +const TOPIC_CHANGELIST = `${pub}/admin/publications/topic/` +const TOPIC_ADD = `${pub}/admin/publications/topic/add/` +const READ = { timeout: 10_000, intervals: [400, 800, 1500] } + +function onderwerp(stagehand: Stagehand) { + return adminDriver(stagehand, { noun: 'onderwerp', changelist: TOPIC_CHANGELIST, add: TOPIC_ADD }) +} + +Given('the publicatiebank onderwerp admin is open', async ({ adminStagehand }) => { + await onderwerp(adminStagehand).openList() +}) + +// Prerequisite (deterministic, not the action under test). +Given('an onderwerp', async ({ topics }) => { + await topics.add() +}) + +// --- Add (Stagehand for fields; image via setInputFiles carve-out) ---------- + +When('I add an onderwerp through the admin', async ({ adminStagehand, topics }) => { + const titel = topics.freshName() + const d = onderwerp(adminStagehand) + await d.openAdd() + // File carve-out: set the required afbeelding bytes directly on the input. + await d.page.locator('#id_afbeelding').setInputFiles(TOPIC_IMAGE) + await d.act(`Fill the "Officiële titel" field with: ${titel}`) + await d.act('Select "Concept" as the publicatiestatus') + await d.save() + topics.track(titel) +}) + +Then('the onderwerp exists in the API', async ({ page, topics }) => { + const titel = topics.last() + await expect.poll(() => topicExists(page, titel), READ).toBe(true) +}) + +// --- Promote --------------------------------------------------------------- + +When('I tick the {string} checkbox and save the onderwerp', async ({ adminStagehand, topics }, label: string) => { + const d = onderwerp(adminStagehand) + await d.open(topics.last()) + await d.act(`Tick the "${label}" checkbox`) + await d.save() +}) + +Then('the onderwerp is promoted in the API', async ({ page, topics }) => { + const titel = topics.last() + await expect.poll(() => topicIsPromoted(page, titel), READ).toBe(true) +}) + +// --- Edit omschrijving ----------------------------------------------------- + +When('I change the onderwerp omschrijving and save it', async ({ adminStagehand, topics, scratch }) => { + const omschrijving = `E2E gewijzigde omschrijving ${Date.now()}` + scratch.set('topic:omschrijving', omschrijving) + const d = onderwerp(adminStagehand) + await d.open(topics.last()) + await d.act(`Replace the contents of the "Omschrijving" field with: ${omschrijving}`) + await d.save() +}) + +Then('the onderwerp has the new omschrijving in the API', async ({ page, topics, scratch }) => { + const titel = topics.last() + const expected = scratch.get('topic:omschrijving')! + await expect.poll(() => topicOmschrijving(page, titel), READ).toContain(expected) +}) + +// --- Delete ---------------------------------------------------------------- + +When('I delete the onderwerp through the admin', async ({ adminStagehand, topics }) => { + const d = onderwerp(adminStagehand) + await d.open(topics.last()) + await d.removeCurrent() +}) + +Then('the onderwerp no longer exists in the API', async ({ page, topics }) => { + const titel = topics.last() + await expect.poll(() => topicExists(page, titel), READ).toBe(false) +}) + +// --- Search (UI read under test) ------------------------------------------- + +When('I search the admin for the onderwerp', async ({ adminStagehand, topics }) => { + await onderwerp(adminStagehand).search(topics.last()) +}) + +Then('the onderwerp is shown in the admin results', async ({ page, topics }) => { + const titel = topics.last() + // The Stagehand search ran above; confirm the onderwerp is findable through the + // admin deterministically (topicExists searches the changelist too). + expect(await topicExists(page, titel)).toBe(true) +}) diff --git a/bdd/@publicatiebank/@admin/onderwerpen.feature b/bdd/@publicatiebank/@admin/onderwerpen.feature new file mode 100644 index 0000000..537605a --- /dev/null +++ b/bdd/@publicatiebank/@admin/onderwerpen.feature @@ -0,0 +1,57 @@ +# Testscript 3 — Configureren van onderwerpen. +# +# A functioneel-beheerder manages onderwerpen (topics) in the GPP-publicatiebank +# admin: adding one (with its mandatory afbeelding), promoting it, editing it and +# deleting it. UI *mutations* are driven by an AI agent (Stagehand + an OpenRouter +# model) operating the Dutch Django-admin from natural-language intent — no +# hand-written selectors. The one exception is the mandatory image on the add +# form: a CDP browser cannot drive the OS file-picker, so the file bytes are set +# directly on the (the same carve-out the @beheer feature makes +# for image uploads); every other field is still driven by Stagehand. +# +# Assertions are made deterministically by reading the admin back through a +# separate, session-authenticated Playwright browser (the token API is +# unreliable while Stagehand drives the same server — see README "Known server +# flake"). +# +# Test-owned data: each scenario's onderwerp is deleted again by the `topics` +# fixture (bdd/fixture.ts) in teardown, deterministically via the admin (the +# onderwerpen API is read-only). Test onderwerpen are created as `concept`, so +# they never surface publicly on the burgerportaal. Names are unique per worker. +# +# Requires OPENROUTER_API_KEY (else every scenario skips) — see README. + +# @timeout — Stagehand act() calls (each an LLM round-trip) plus API polling +# comfortably exceed the 30s default. +@anthropic @mode:serial @timeout:120000 +Feature: Configureren van onderwerpen + As a functioneel-beheerder of the GPP-publicatiebank + I want to add, promote, edit and delete onderwerpen + So that publications can be bundled under locally relevant topics + + Background: + Given the publicatiebank onderwerp admin is open + + Scenario: Add an onderwerp + When I add an onderwerp through the admin + Then the onderwerp exists in the API + + Scenario: Promote an onderwerp + Given an onderwerp + When I tick the "Promoot" checkbox and save the onderwerp + Then the onderwerp is promoted in the API + + Scenario: Edit the omschrijving of an onderwerp + Given an onderwerp + When I change the onderwerp omschrijving and save it + Then the onderwerp has the new omschrijving in the API + + Scenario: Delete an onderwerp + Given an onderwerp + When I delete the onderwerp through the admin + Then the onderwerp no longer exists in the API + + Scenario: Search finds the onderwerp + Given an onderwerp + When I search the admin for the onderwerp + Then the onderwerp is shown in the admin results diff --git a/bdd/@publicatiebank/@admin/organisaties.feature b/bdd/@publicatiebank/@admin/organisaties.feature new file mode 100644 index 0000000..c12d47e --- /dev/null +++ b/bdd/@publicatiebank/@admin/organisaties.feature @@ -0,0 +1,60 @@ +# Testscript 4 — Configureren van organisaties. +# +# A functioneel-beheerder manages the organisaties waardelijst in the +# GPP-publicatiebank admin: activating organisations, adding a self-added one, +# renaming it, and deleting it. Every UI *mutation* is driven by an AI agent +# (Stagehand + an OpenRouter model) operating the Dutch Django-admin from +# natural-language intent — no hand-written selectors. Assertions are made +# deterministically by reading the admin back through a separate, +# session-authenticated Playwright browser (the token API is unreliable while +# Stagehand drives the same server — see README "Known server flake"). +# +# Test-owned data: each scenario creates the self-added organisatie it needs and +# the `organisations` fixture (bdd/fixture.ts) deletes it again in teardown +# (deterministically, via the admin — the organisatie API has no DELETE). Names +# are unique per worker, so parallel scenarios never collide, and the +# waardelijst reference organisaties are left untouched. +# +# Requires OPENROUTER_API_KEY (else every scenario skips) — see README. +# +# @anthropic — Claude Sonnet drives the admin DOM reliably; override per scenario +# with @cheap-ai / @openai / @expensive-ai, or drop the tag for the default model. +# +# @mode:serial — like the @beheer feature, the AI DOM operations are more +# reliable one-at-a-time, and it keeps concurrent load off the publicatiebank +# (its user-less-token API path 500s intermittently under parallel load). + +# @timeout — each scenario drives several Stagehand act() calls (each an LLM +# round-trip) plus API polling, which comfortably exceeds the 30s default. +@anthropic @mode:serial @timeout:120000 +Feature: Configureren van organisaties + As a functioneel-beheerder of the GPP-publicatiebank + I want to activate, add, edit and delete organisaties + So that end users can publish on behalf of the right organisations + + Background: + Given the publicatiebank organisatie admin is open + + Scenario: Add a self-added organisatie + When I add a self-added organisatie through the admin + Then the organisatie exists in the API and is active + + Scenario: Activate a deactivated organisatie + Given a self-added organisatie that is not active + When I tick the "Actief" checkbox and save the organisatie + Then the organisatie is active in the API + + Scenario: Rename a self-added organisatie + Given a self-added organisatie + When I rename the organisatie and save it + Then the API knows the organisatie under its new name and not the old one + + Scenario: Delete a self-added organisatie + Given a self-added organisatie + When I delete the organisatie through the admin + Then the organisatie no longer exists in the API + + Scenario: Search finds the self-added organisatie + Given a self-added organisatie + When I search the admin for the self-added organisatie + Then the self-added organisatie is shown in the admin results diff --git a/bdd/@publicatiebank/@admin/publicaties-steps.ts b/bdd/@publicatiebank/@admin/publicaties-steps.ts new file mode 100644 index 0000000..64d470b --- /dev/null +++ b/bdd/@publicatiebank/@admin/publicaties-steps.ts @@ -0,0 +1,123 @@ +import type { Stagehand } from '@browserbasehq/stagehand' +import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' +import { + addPublishedPublication, + publicationExistsAdmin, + publicationOmschrijvingAdmin, + publicationStatusAdmin, +} from '@/bdd/@publicatiebank/support/publication' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' +import { Given, Then, When } from '../../_core/fixture' + +/** + * Testscript 9 (publicatie beheer) steps. Publicaties are seeded and cleaned up + * over the admin by the `publications` fixture; the beheer *mutations* run + * through the Django admin via Stagehand `act()` behind the shared + * {@link adminDriver} seam, and assertions read the admin back through the + * ordinary session-authenticated `page` (stable, unlike the token API while + * Stagehand drives the same server — see README "Known server flake"). + * + * The `@anthropic` skip guard (no OpenRouter key → skip) and the `adminStagehand` + * fixture are shared with the organisatie/onderwerp steps (see steps.ts). + */ + +const pub = ENV.apps.publicatiebank.replace(/\/$/, '') +const PUB_CHANGELIST = `${pub}/admin/publications/publication/` +const READ = { timeout: 10_000, intervals: [400, 800, 1500] } + +function publicatie(stagehand: Stagehand) { + return adminDriver(stagehand, { noun: 'publicatie', changelist: PUB_CHANGELIST, rowLink: 'titel link' }) +} + +// Prerequisites (deterministic, not the action under test), seeded through the +// admin add form — the token API is unusable while Stagehand drives the admin. +Given('a concept publicatie', async ({ publications }) => { + await publications.seed() +}) + +// A gepubliceerd publicatie, seeded deterministically through the admin (needs a +// publisher organisatie + an informatiecategorie); the withdraw is the mutation +// under test. +Given('a published publicatie', async ({ page, publications, organisations }) => { + const orgNaam = await organisations.add() + const titel = publications.freshName() + await addPublishedPublication(page, titel, orgNaam) + publications.track(titel) +}) + +// --- Edit omschrijving ------------------------------------------------------ + +When('I change the publicatie omschrijving through the admin', async ({ adminStagehand, publications, scratch }) => { + const omschrijving = `E2E gewijzigde omschrijving ${Date.now()}` + scratch.set('pub:omschrijving', omschrijving) + const d = publicatie(adminStagehand) + await d.open(publications.last()) + await d.act(`Replace the contents of the "Omschrijving" field with: ${omschrijving}`) + await d.save() +}) + +Then('the publicatie has the new omschrijving', async ({ page, publications, scratch }) => { + const titel = publications.last() + const expected = scratch.get('pub:omschrijving')! + await expect.poll(() => publicationOmschrijvingAdmin(page, titel), READ).toContain(expected) +}) + +// --- Rename ----------------------------------------------------------------- + +When('I rename the publicatie through the admin', async ({ adminStagehand, publications, scratch }) => { + const oldTitel = publications.last() + const newTitel = `${publications.freshName()} hernoemd` + scratch.set('pub:oldTitel', oldTitel) + const d = publicatie(adminStagehand) + await d.open(oldTitel) + await d.act(`Replace the contents of the "Officiële titel" field with: ${newTitel}`) + await d.save() + // Track the new titel so cleanup deletes the renamed publicatie too. + publications.track(newTitel) +}) + +Then('the publicatie is known under its new titel and not the old one', async ({ page, publications, scratch }) => { + const newTitel = publications.last() + const oldTitel = scratch.get('pub:oldTitel')! + await expect.poll(() => publicationExistsAdmin(page, newTitel), READ).toBe(true) + expect(await publicationExistsAdmin(page, oldTitel)).toBe(false) +}) + +// --- Withdraw (intrekken) --------------------------------------------------- + +When('I set the publicatiestatus to {string} and save the publicatie', async ({ adminStagehand, publications }, label: string) => { + const d = publicatie(adminStagehand) + await d.open(publications.last()) + await d.act(`Select "${label}" as the publicatiestatus`) + await d.save() +}) + +Then('the publicatie has status {string}', async ({ page, publications }, status: string) => { + const titel = publications.last() + await expect.poll(() => publicationStatusAdmin(page, titel), READ).toBe(status) +}) + +// --- Delete ----------------------------------------------------------------- + +When('I delete the publicatie through the admin', async ({ adminStagehand, publications }) => { + const d = publicatie(adminStagehand) + await d.open(publications.last()) + await d.removeCurrent() +}) + +Then('the publicatie no longer exists', async ({ page, publications }) => { + const titel = publications.last() + await expect.poll(() => publicationExistsAdmin(page, titel), READ).toBe(false) +}) + +// --- Search (UI read under test) -------------------------------------------- + +When('I search the admin for the publicatie', async ({ adminStagehand, publications }) => { + await publicatie(adminStagehand).search(publications.last()) +}) + +Then('the publicatie is shown in the admin results', async ({ page, publications }) => { + const titel = publications.last() + expect(await publicationExistsAdmin(page, titel)).toBe(true) +}) diff --git a/bdd/@publicatiebank/@admin/publicaties.feature b/bdd/@publicatiebank/@admin/publicaties.feature new file mode 100644 index 0000000..e33ac76 --- /dev/null +++ b/bdd/@publicatiebank/@admin/publicaties.feature @@ -0,0 +1,45 @@ +# Testscript 9 — Wijzigen of verwijderen van een publicatie (beheer). +# UI mutations run through the Django admin via Stagehand (natural language, no +# hand-written selectors). Publicaties are seeded as `concept` and cleaned up +# through the admin (session auth) — NOT the token API, which woo-publications +# 500s on for the whole time Stagehand drives the admin on the same server (the +# user-less-token bug; see README "Known server flake"). Assertions read the +# admin back through the ordinary session-authenticated page. +# +# Documents are out of scope: this stack has no Documents API configured (see +# PLAN-plateau4-remaining.md), so TS9's document sub-steps and all of TS8 are +# parked. +@anthropic @mode:serial @timeout:120000 +Feature: Publicatie beheer in de GPP-publicatiebank + As functioneel beheer + I want to correct, publish and withdraw publicaties in the publicatiebank + So that mistakes made by end users can be fixed + + # No Background opening the admin: each scenario seeds its publicatie first + # (deterministically, through the admin add form), and the `When` step drives + # the mutation under test through Stagehand. + + Scenario: Edit the omschrijving of a publicatie + Given a concept publicatie + When I change the publicatie omschrijving through the admin + Then the publicatie has the new omschrijving + + Scenario: Rename a publicatie + Given a concept publicatie + When I rename the publicatie through the admin + Then the publicatie is known under its new titel and not the old one + + Scenario: Withdraw (intrekken) a published publicatie + Given a published publicatie + When I set the publicatiestatus to "Ingetrokken" and save the publicatie + Then the publicatie has status "ingetrokken" + + Scenario: Delete a publicatie through the admin + Given a concept publicatie + When I delete the publicatie through the admin + Then the publicatie no longer exists + + Scenario: Search finds a publicatie + Given a concept publicatie + When I search the admin for the publicatie + Then the publicatie is shown in the admin results diff --git a/bdd/@publicatiebank/@admin/steps.ts b/bdd/@publicatiebank/@admin/steps.ts new file mode 100644 index 0000000..830d2e2 --- /dev/null +++ b/bdd/@publicatiebank/@admin/steps.ts @@ -0,0 +1,127 @@ +import type { Stagehand } from '@browserbasehq/stagehand' +import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' +import { organisationExists, organisationIsActive } from '@/bdd/@publicatiebank/support/organisation' +import { hasOpenRouterKey } from '@/bdd/_core/stagehand' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' +import { Before, Given, test, Then, When } from '../../_core/fixture' + +/** + * Testscript 4 (organisaties) steps. UI *mutations* run through the Django admin + * via Stagehand `act()` on Dutch labels — no hand-written selectors — behind the + * shared {@link adminDriver} seam. Assertions are made *deterministically* by + * reading the admin back through the ordinary Playwright `page` (a separate, + * session-authenticated browser): the token API is unreliable while Stagehand + * drives the same server (see README "Known server flake"), whereas admin reads + * authenticate as a real user and are stable. + * + * The organisatie API has no RSIN field and no DELETE, so "edit" is exercised by + * renaming (naam is visible in the changelist) and cleanup is admin-driven. + */ + +const pub = ENV.apps.publicatiebank.replace(/\/$/, '') +const ORG_CHANGELIST = `${pub}/admin/metadata/organisation/` +const ORG_ADD = `${pub}/admin/metadata/organisation/add/` +const READ = { timeout: 10_000, intervals: [400, 800, 1500] } + +function org(stagehand: Stagehand) { + return adminDriver(stagehand, { noun: 'organisatie', changelist: ORG_CHANGELIST, add: ORG_ADD }) +} + +// Skip the feature when no model key is configured (like @beheer), so the rest +// of the suite stays green without OpenRouter. +Before({ tags: '@anthropic' }, async () => { + test.skip(!hasOpenRouterKey(), 'Set OPENROUTER_API_KEY to run the Stagehand @admin scenarios') +}) + +Given('the publicatiebank organisatie admin is open', async ({ adminStagehand }) => { + await org(adminStagehand).openList() +}) + +// --- prerequisites (deterministic, not the action under test) -------------- + +Given('a self-added organisatie', async ({ organisations }) => { + await organisations.add() +}) + +Given('a self-added organisatie that is not active', async ({ organisations }) => { + await organisations.add(undefined, { actief: false }) +}) + +// --- Add (mutation via Stagehand) ------------------------------------------ + +When('I add a self-added organisatie through the admin', async ({ adminStagehand, organisations }) => { + const naam = organisations.freshName() + const d = org(adminStagehand) + await d.openAdd() + await d.act(`Fill the "Naam" field with: ${naam}`) + await d.act('Make sure the "Is actief" checkbox is ticked') + await d.save() + organisations.track(naam) +}) + +Then('the organisatie exists in the API and is active', async ({ page, organisations }) => { + const naam = organisations.last() + await expect.poll(() => organisationIsActive(page, naam), READ).toBe(true) +}) + +// --- Activate -------------------------------------------------------------- + +When('I tick the {string} checkbox and save the organisatie', async ({ adminStagehand, organisations }, label: string) => { + const d = org(adminStagehand) + await d.open(organisations.last()) + await d.act(`Tick the "${label}" checkbox`) + await d.save() +}) + +Then('the organisatie is active in the API', async ({ page, organisations }) => { + const naam = organisations.last() + await expect.poll(() => organisationIsActive(page, naam), READ).toBe(true) +}) + +// --- Rename (edit) --------------------------------------------------------- + +When('I rename the organisatie and save it', async ({ adminStagehand, organisations, scratch }) => { + const oldName = organisations.last() + const newName = `${organisations.freshName()} hernoemd` + scratch.set('org:oldName', oldName) + const d = org(adminStagehand) + await d.open(oldName) + await d.act(`Replace the contents of the "Naam" field with: ${newName}`) + await d.save() + // Track the new name so teardown deletes the renamed row too. + organisations.track(newName) +}) + +Then('the API knows the organisatie under its new name and not the old one', async ({ page, organisations, scratch }) => { + const newName = organisations.last() + const oldName = scratch.get('org:oldName')! + await expect.poll(() => organisationExists(page, newName), READ).toBe(true) + expect(await organisationExists(page, oldName)).toBe(false) +}) + +// --- Delete ---------------------------------------------------------------- + +When('I delete the organisatie through the admin', async ({ adminStagehand, organisations }) => { + const d = org(adminStagehand) + await d.open(organisations.last()) + await d.removeCurrent() +}) + +Then('the organisatie no longer exists in the API', async ({ page, organisations }) => { + const naam = organisations.last() + await expect.poll(() => organisationExists(page, naam), READ).toBe(false) +}) + +// --- Search (UI read under test) ------------------------------------------- + +When('I search the admin for the self-added organisatie', async ({ adminStagehand, organisations }) => { + await org(adminStagehand).search(organisations.last()) +}) + +Then('the self-added organisatie is shown in the admin results', async ({ page, organisations }) => { + const naam = organisations.last() + // The Stagehand search ran above; confirm the organisatie is findable through + // the admin deterministically (organisationExists searches the changelist too). + expect(await organisationExists(page, naam)).toBe(true) +}) diff --git a/bdd/@publicatiebank/steps.ts b/bdd/@publicatiebank/steps.ts new file mode 100644 index 0000000..8ac6896 --- /dev/null +++ b/bdd/@publicatiebank/steps.ts @@ -0,0 +1,113 @@ +import type { Page } from '@playwright/test' +import { openBeheer } from '@/bdd/@publicatiebank/support/login' +import { Given, Then, When } from '@/bdd/_core/fixture' +import { expect } from '@playwright/test' + +function stripWs(s: string) { + return s.replace(/\s+/g, '') +} +function compareIgnoringWhitespace(a: string, b: string) { + return stripWs(a).localeCompare(stripWs(b)) +} + +async function search(page: Page, query: string) { + await page.getByRole('textbox', { name: 'Search' }).fill(query) + await page.getByRole('button', { name: 'Zoeken' }).click() +} + +async function assertOnChangePage(page: Page, naam: string) { + await expect(page).toHaveURL(/\/admin\/metadata\/informationcategory\/\d+\/change\/(?:\?.*)?$/) + await expect(page.getByRole('heading', { name: 'informatiecategorie wijzigen' })).toBeVisible() + await expect(page.getByRole('heading', { name: naam })).toBeVisible() +} + +Given('I am logged in to the publicatiebank admin', async ({ page }) => { + // Session is restored from storage state (the @admin tag); just open the admin. + await openBeheer(page) +}) + +Given('I have added a self-added information category', async ({ categories }) => { + await categories.add() +}) + +Given('I open the {string} metadata page', async ({ page }, name: string) => { + await page.locator('#header').getByText('Metadata').click() + await page.locator('#header').getByRole('link', { name }).click() +}) + +Then('the {string} column header is visible and clickable', async ({ page }, header: string) => { + const headerElement = page.getByRole('link', { name: header }) + await expect(headerElement).toBeVisible() + await expect(headerElement).toHaveText(header) + await headerElement.click() +}) + +When('I choose {string} from the {string} menu', async ({ page }, action: string, menu: string) => { + await page.getByText(menu).click() + await page.getByText(action).click() +}) + +When('I search for the self-added information category', async ({ page, categories }) => { + await search(page, categories.last()) +}) + +Then('I see the self-added information category in the results', async ({ page, categories }) => { + await expect(page.getByRole('link', { name: categories.last(), exact: true })).toHaveCount(1) +}) + +Then('the self-added information category is listed', async ({ page, categories }) => { + await expect(page.getByRole('link', { name: categories.last(), exact: true })).toBeVisible() +}) + +When('I open the self-added information category', async ({ page, categories }) => { + await page.getByRole('link', { name: categories.last(), exact: true }).click() +}) + +Then('I am on its information category change page', async ({ page, categories }) => { + await assertOnChangePage(page, categories.last()) +}) + +When('I filter on {string}', async ({ page }, filterName: string) => { + await page.getByRole('link', { name: filterName, exact: true }).click() + await page.locator('td.field-oorsprong').first().waitFor() +}) + +Then('every result has {string} as its origin', async ({ page }, origin: string) => { + const cells = page.locator('td.field-oorsprong') + const count = await cells.count() + for (let i = 0; i < count; i++) { + expect((await cells.nth(i).textContent())?.trim()).toBe(origin) + } +}) + +Then( + 'the results contain both {string} and {string} origins', + async ({ page }, first: string, second: string) => { + const cells = page.locator('td.field-oorsprong') + const count = await cells.count() + const values: string[] = [] + for (let i = 0; i < count; i++) { + const text = (await cells.nth(i).textContent())?.trim() + if (text) + values.push(text) + } + expect(values).toContain(first) + expect(values).toContain(second) + }, +) + +When('I sort ascending by the {string} column', async ({ page }, column: string) => { + await page.getByRole('link', { name: column }).click() + const sortLink = page.getByRole('link', { name: 'Sortering aan/uit' }) + await sortLink.click() + await sortLink.click() +}) + +Then('the information categories are listed alphabetically by name', async ({ page }) => { + const naamLinks = await page.locator('th.field-naam a').all() + const names = await Promise.all( + naamLinks.map(async el => (await el.textContent())?.trim() || ''), + ) + const sorted = [...names].sort(compareIgnoringWhitespace) + expect(names).toEqual(sorted) +}) diff --git a/bdd/@publicatiebank/support/admin-driver.ts b/bdd/@publicatiebank/support/admin-driver.ts new file mode 100644 index 0000000..c292ce1 --- /dev/null +++ b/bdd/@publicatiebank/support/admin-driver.ts @@ -0,0 +1,88 @@ +import type { StagehandPage } from '@/bdd/_core/stagehand' +import type { Stagehand } from '@browserbasehq/stagehand' +import { settle, stagehandPage } from '@/bdd/_core/stagehand' + +/** + * A Django-admin resource driven from natural language via Stagehand `act()`. + * + * The organisatie/onderwerp/publicatie beheer scenarios all run the same + * changelist CRUD script — open the changelist, search a row and open it, save + * with "Opslaan", delete-with-confirm — differing only in the Dutch noun and the + * changelist path. This binds that script to one `{ noun, changelist }` so the + * step files express intent (`open`, `save`, `rename`, `remove`) instead of + * re-typing the goto→act→networkidle mechanics. Assertions still read the admin + * back through the ordinary session `page` (see {@link adminResource}); this + * seam only owns the *mutations*. + */ +export interface AdminDriverConfig { + /** Dutch singular used in the act() phrasing, e.g. `organisatie`, `onderwerp`, `publicatie`. */ + noun: string + /** Absolute changelist URL (no trailing `?q=`), e.g. `${pub}/admin/metadata/organisation/`. */ + changelist: string + /** Absolute add-form URL. Optional — only needed by flows that add through the admin. */ + add?: string + /** How the row link reads in the act() phrasing. Defaults to `link`; publicaties use `titel link`. */ + rowLink?: string +} + +export interface AdminDriver { + /** The understudy page Stagehand drives (for file carve-outs / navigation). */ + readonly page: StagehandPage + /** Pass an instruction straight to Stagehand `act()`. */ + act: (instruction: string) => Promise + /** Wait for the network to settle (best-effort). */ + settle: () => Promise + /** Open the changelist and settle. */ + openList: () => Promise + /** Open the add form and settle. */ + openAdd: () => Promise + /** Search for `name` and open its row in the changelist. */ + open: (name: string) => Promise + /** Click "Opslaan" and settle. */ + save: () => Promise + /** Delete-with-confirm the currently open change page, and settle. */ + removeCurrent: () => Promise + /** Search the changelist for `name` via the admin search box, and settle. */ + search: (name: string) => Promise +} + +export function adminDriver(stagehand: Stagehand, config: AdminDriverConfig): AdminDriver { + const { noun, changelist, add, rowLink = 'link' } = config + const page = stagehandPage(stagehand) + const act = (instruction: string) => stagehand.act(instruction) + const settleHere = () => settle(page) + + return { + page, + act, + settle: settleHere, + async openList() { + await page.goto(changelist) + await settleHere() + }, + async openAdd() { + if (!add) + throw new Error(`adminDriver for "${noun}" has no add URL configured`) + await page.goto(add) + await settleHere() + }, + async open(name) { + await page.goto(`${changelist}?q=${encodeURIComponent(name)}`) + await act(`Open the ${noun} by clicking its ${rowLink} in the results table`) + }, + async save() { + await act(`Click the "Opslaan" button to save the ${noun}`) + await settleHere() + }, + async removeCurrent() { + await act(`Click the "Verwijderen" button to delete this ${noun}`) + await act('Confirm the deletion by clicking the "Ja, ik weet het zeker" button') + await settleHere() + }, + async search(name) { + await page.goto(changelist) + await act(`Type "${name}" in the search box and submit the search`) + await settleHere() + }, + } +} diff --git a/bdd/@publicatiebank/support/admin-resource.ts b/bdd/@publicatiebank/support/admin-resource.ts new file mode 100644 index 0000000..7d9873f --- /dev/null +++ b/bdd/@publicatiebank/support/admin-resource.ts @@ -0,0 +1,127 @@ +import type { Page } from '@playwright/test' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' + +/** + * A Django-admin metadata resource (informatiecategorie, organisatie, onderwerp, + * publicatie) seen through the ordinary session-authenticated Playwright `page`. + * + * The four metadata entities the suite owns share one changelist script — search + * by name, open a row, delete-with-confirm, list `E2E `-prefixed leftovers — that + * differs only in a handful of selectors. This module captures that script once; + * each entity is a small {@link AdminResourceConfig} rather than a copy of the + * whole flow. Reads/deletes go through the session `page` (a real user) because + * the token API is unreliable while Stagehand drives the same admin (see README + * "Known server flake"). + * + * Bespoke add-form filling (a topic's mandatory afbeelding, a publicatie's + * publisher + select2) stays in the per-entity helper — only the shared + * changelist mechanics live here. + */ +export interface AdminResourceConfig { + /** Admin app/model path, e.g. `metadata/organisation` or `publications/topic`. */ + path: string + /** + * Selector for the name links in the changelist, e.g. `th.field-naam a`. + * Defaults to the generic result-list cells (used where no single name column + * class is reliable, as for publicaties). + */ + nameCell?: string + /** How a row is matched by name. `exact-link` (default) or `result-row` (substring). */ + rowMatch?: 'exact-link' | 'result-row' + /** Change-URL id shape: `\d+` (integer PK) or `[0-9a-f-]+` (uuid). */ + changeId?: string + /** Which delete link to click on the change page. `verwijderen` (default) or `deletelink`. */ + deleteVia?: 'verwijderen' | 'deletelink' +} + +export interface AdminResource { + changelistUrl: () => string + addFormUrl: () => string + /** Assert the page has landed back on the changelist (post-save/delete redirect). */ + assertOnChangelist: (page: Page) => Promise + /** Names of leftover `${prefix}`-prefixed rows, for scenario/run-wide cleanup. */ + listE2ENames: (page: Page, prefix?: string) => Promise + /** Whether a row with exactly this name is listed in the changelist. */ + exists: (page: Page, name: string) => Promise + /** Open the change page of the row with this name; false if none exists. */ + open: (page: Page, name: string) => Promise + /** Delete the row with this name via the admin. No-op if already gone. */ + remove: (page: Page, name: string) => Promise +} + +const DEFAULT_NAME_CELL = '#result_list tbody tr th a, #result_list tbody tr td a' + +export function adminResource(config: AdminResourceConfig): AdminResource { + const { + path, + nameCell = DEFAULT_NAME_CELL, + rowMatch = 'exact-link', + changeId = '\\d+', + deleteVia = 'verwijderen', + } = config + + const changelistUrl = () => new URL(`/admin/${path}/`, ENV.apps.publicatiebank).href + const addFormUrl = () => new URL(`/admin/${path}/add/`, ENV.apps.publicatiebank).href + const changelistRe = new RegExp(`/admin/${path}/(\\?.*)?$`) + const changeRe = new RegExp(`/admin/${path}/${changeId}/change/`) + + async function gotoSearch(page: Page, name: string) { + await page.goto(`${changelistUrl()}?q=${encodeURIComponent(name)}`) + } + + /** Whether the named row is present, and click into it if `click` is set. */ + async function findRow(page: Page, name: string, click: boolean): Promise { + if (rowMatch === 'result-row') { + const row = page.locator('#result_list tbody tr', { hasText: name }) + if ((await row.count()) === 0) + return false + if (click) + await row.first().locator('th a, td a').first().click() + return true + } + const row = page.getByRole('link', { name, exact: true }) + if ((await row.count()) === 0) + return false + if (click) + await row.first().click() + return true + } + + return { + changelistUrl, + addFormUrl, + async assertOnChangelist(page) { + await expect(page).toHaveURL(changelistRe) + }, + async listE2ENames(page, prefix = 'E2E ') { + await gotoSearch(page, prefix) + const names = await page.locator(nameCell).allTextContents() + return names.map(n => n.trim()).filter(n => n.startsWith(prefix)) + }, + async exists(page, name) { + await gotoSearch(page, name) + return findRow(page, name, false) + }, + async open(page, name) { + await gotoSearch(page, name) + if (!(await findRow(page, name, true))) + return false + await expect(page).toHaveURL(changeRe) + return true + }, + async remove(page, name) { + await gotoSearch(page, name) + if (!(await findRow(page, name, true))) + return + // The publicatie change page also carries an eigenaar-inline "verwijderen" + // link, so target the main delete link by class rather than accessible name. + if (deleteVia === 'deletelink') + await page.locator('a.deletelink').click() + else + await page.getByRole('link', { name: 'Verwijderen' }).click() + await page.getByRole('button', { name: /Ja, ik weet het zeker/i }).click() + await expect(page).toHaveURL(changelistRe) + }, + } +} diff --git a/bdd/@publicatiebank/support/information-category.ts b/bdd/@publicatiebank/support/information-category.ts new file mode 100644 index 0000000..d473fc4 --- /dev/null +++ b/bdd/@publicatiebank/support/information-category.ts @@ -0,0 +1,66 @@ +import type { Page } from '@playwright/test' +import { adminResource } from './admin-resource' + +const resource = adminResource({ + path: 'metadata/informationcategory', + nameCell: 'th.field-naam a', +}) + +/** + * Create a self-added (`zelf_toegevoegd`) information category through the Django + * admin. The API is read-only for categories and the admin forces + * `oorsprong = zelf_toegevoegd` for manually added entries, so this is the only + * way a test can own one. Requires an authenticated admin session on `page`. + */ +export async function addSelfAddedCategory(page: Page, naam: string) { + await page.goto(resource.addFormUrl()) + await page.locator('#id_naam').fill(naam) + await page.locator('#id_naam_meervoud').fill(naam) + await page.locator('#id_definitie').fill(`E2E test category ${naam}`) + // The admin requires an archiving source, unlike the fixture-loaded entries. + await page.locator('#id_bron_bewaartermijn').fill('E2E test') + await page.locator('input[name="_save"]').click() + // Django redirects to the changelist and shows a success message. + await resource.assertOnChangelist(page) +} + +/** + * Fail loud if the `waardelijst` reference categories are missing. They cannot + * be created via the UI or the API (the admin forces `oorsprong=zelf_toegevoegd` + * and the category API is read-only), so they are a documented prerequisite: + * the target environment must ship them (see README "Requirements to run"). + * This turns a missing prerequisite into a clear error at setup time instead of + * confusing scenario failures. Requires an authenticated admin session. + */ +export async function verifyWaardelijstPresent(page: Page) { + await page.goto(resource.changelistUrl()) + await page.getByRole('link', { name: 'Waardelijst', exact: true }).click() + await page.locator('td.field-oorsprong').first().waitFor({ timeout: 10_000 }).catch(() => {}) + if ((await page.locator('td.field-oorsprong').count()) > 0) + return + throw new Error( + '[setup] No `waardelijst` reference information categories found in the ' + + 'publicatiebank. They cannot be created via the UI/API and must be seeded ' + + 'in the target environment. See README "Requirements to run"; locally:\n' + + ' docker exec python src/manage.py ' + + 'load_information_categories ' + + 'src/woo_publications/fixtures/information_categories.json', + ) +} + +/** + * List the names of leftover `E2E `-prefixed categories via the admin, so a + * hard-killed run can be swept clean. All `E2E ` categories are self-added by + * the suite. Requires an authenticated admin session. + */ +export function listE2ECategoryNames(page: Page, prefix = 'E2E ') { + return resource.listE2ENames(page, prefix) +} + +/** + * Delete an information category by name via the admin, for scenario cleanup. + * No-op if it is already gone. + */ +export function deleteCategoryByName(page: Page, naam: string) { + return resource.remove(page, naam) +} diff --git a/bdd/@publicatiebank/support/login.ts b/bdd/@publicatiebank/support/login.ts new file mode 100644 index 0000000..b96a068 --- /dev/null +++ b/bdd/@publicatiebank/support/login.ts @@ -0,0 +1,51 @@ +import type { AppName, User } from '@/bdd/_core/types' +import type { Page } from '@playwright/test' +import { awaitKeycloakFormOrSso, fillKeycloakLogin } from '@/bdd/_core/keycloak' +import { ENV } from '@/bdd/_core/types' +import { expect } from '@playwright/test' + +const publicatiebank: AppName = 'publicatiebank' + +/** + * Open the publicatiebank Django admin assuming the browser context already + * holds an authenticated session (restored from storage state). Navigates to + * the admin via "Beheer" without going through Keycloak. + */ +export async function openBeheer(page: Page) { + await page.goto(ENV.apps[publicatiebank]) + await page.getByRole('link', { name: 'Beheer' }).click() + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible() +} + +/** + * Sign in to the GPP-publicatiebank (odrc) Django admin via Keycloak OIDC. + * + * Flow: landing page -> "Beheer" -> admin login -> "Inloggen met + * organisatieaccount" -> Keycloak form (+ TOTP) -> /admin/. + * + * When a realm-wide Keycloak SSO session already exists, Keycloak redirects + * straight back to the admin without showing its login form, so the username + * step is skipped. + */ +export async function signInToPublicatiebank(page: Page, user: User) { + const beheerLink = page.getByRole('link', { name: 'Beheer' }) + const oidcLink = page.getByRole('link', { + name: 'Inloggen met organisatieaccount', + }) + const adminIndex = /\/admin\/$/ + + await beheerLink.or(oidcLink).first().waitFor() + if (await beheerLink.isVisible()) { + await beheerLink.click() + } + + await oidcLink.waitFor() + await oidcLink.click() + + // Either Keycloak shows its login form, or an existing SSO session logs us + // straight back into the admin. + if (await awaitKeycloakFormOrSso(page, adminIndex)) + await fillKeycloakLogin(page, user) + + await page.waitForURL(adminIndex) +} diff --git a/bdd/@publicatiebank/support/odrc.ts b/bdd/@publicatiebank/support/odrc.ts new file mode 100644 index 0000000..8f324f7 --- /dev/null +++ b/bdd/@publicatiebank/support/odrc.ts @@ -0,0 +1,165 @@ +import type { APIRequestContext } from '@playwright/test' +import { ENV } from '@/bdd/_core/types' +import { request as apiRequest } from '@playwright/test' + +/** + * Token-authenticated client for the GPP-publicatiebank (ODRC / woo-publications) + * REST API at `/api/v2`. This is the create/read/delete backend the publicatie & + * document lifecycle scenarios (TS6–9) own their test data through, portably — + * no container/`docker exec` access required. + * + * Two hard-won constraints shape the design (see README "Known server flake"): + * + * - **User-less token → intermittent 500s.** The `insecure` service token + * authenticates as a token with no user; woo-publications' + * `SessionProfileMiddleware` dereferences `request.user` and 500s for as long + * as an admin session is active on the same server (which a Stagehand-driven + * run always causes). So every call can transiently 5xx; callers wrap reads + * in {@link waitForOdrc} (poll.ts) to ride the flake out. + * + * - **No shared cookies.** Each call runs in its own throwaway, cookieless + * `APIRequestContext` so a stray session cookie never turns a token request + * into a 401 (the API is token-only). + * + * woo-publications also enforces audit headers on every write; they are sent on + * all requests for simplicity. + */ + +/** Thrown when ODRC returns a 5xx — the signal {@link waitForOdrc} retries on. */ +export class Odrc5xxError extends Error { + readonly odrc5xx = true + constructor(public readonly status: number, public readonly body: string) { + super(`ODRC responded ${status}: ${body.slice(0, 500)}`) + this.name = 'Odrc5xxError' + } +} + +/** Whether an error is a persistent/transient ODRC 5xx (see {@link Odrc5xxError}). */ +export function isOdrc5xx(err: unknown): err is Odrc5xxError { + return typeof err === 'object' && err !== null && (err as { odrc5xx?: unknown }).odrc5xx === true +} + +/** Audit-trail identity woo-publications stamps on every mutation. */ +export interface OdrcAudit { + userId?: string + userRepresentation?: string + remarks?: string +} + +interface Page { + count: number + next: string | null + previous: string | null + results: T[] +} + +export class OdrcClient { + private readonly base: string + + constructor(private readonly audit: OdrcAudit = {}) { + // The v2 API lives under /api/v2/ on the ODRC host. + this.base = new URL('/api/v2/', ENV.odrc.baseUrl).href + } + + private headers(): Record { + return { + 'Authorization': `Token ${ENV.odrc.apiKey}`, + 'Content-Type': 'application/json', + 'Audit-User-ID': this.audit.userId ?? 'e2e', + 'Audit-User-Representation': this.audit.userRepresentation ?? 'E2E test suite', + 'Audit-Remarks': this.audit.remarks ?? 'automated e2e test data', + } + } + + private url(pathOrUrl: string): string { + // Absolute URLs (e.g. a pagination `next`) pass through untouched. + return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : new URL(pathOrUrl.replace(/^\//, ''), this.base).href + } + + /** Run `fn` against a fresh, cookieless request context and always dispose it. */ + private async withContext(fn: (ctx: APIRequestContext) => Promise): Promise { + const ctx = await apiRequest.newContext() + try { + return await fn(ctx) + } + finally { + await ctx.dispose() + } + } + + /** GET a single resource. Throws {@link Odrc5xxError} on a 5xx (retryable). */ + async get(pathOrUrl: string): Promise { + return this.withContext(async (ctx) => { + const res = await ctx.get(this.url(pathOrUrl), { headers: this.headers() }) + const status = res.status() + const body = await res.text() + if (status >= 500) + throw new Odrc5xxError(status, body) + if (!res.ok()) + throw new Error(`GET ${pathOrUrl} -> ${status}: ${body.slice(0, 500)}`) + return body ? JSON.parse(body) as T : (undefined as T) + }) + } + + /** GET all pages of a list endpoint, following `next` links. */ + async list(path: string, query: Record = {}): Promise { + const first = new URL(this.url(path)) + for (const [k, v] of Object.entries(query)) + first.searchParams.set(k, String(v)) + + const items: T[] = [] + let next: string | null = first.href + while (next) { + const page = await this.get>(next) + items.push(...(page?.results ?? [])) + next = page?.next ?? null + } + return items + } + + private async send(method: 'post' | 'patch' | 'put', path: string, data: unknown): Promise { + return this.withContext(async (ctx) => { + const res = await ctx[method](this.url(path), { headers: this.headers(), data }) + const status = res.status() + const body = await res.text() + if (status >= 500) + throw new Odrc5xxError(status, body) + if (!res.ok()) + throw new Error(`${method.toUpperCase()} ${path} -> ${status}: ${body.slice(0, 500)}`) + return body ? JSON.parse(body) as T : (undefined as T) + }) + } + + post(path: string, data: unknown): Promise { + return this.send('post', path, data) + } + + patch(path: string, data: unknown): Promise { + return this.send('patch', path, data) + } + + /** DELETE a resource. No-op semantics are left to the caller (404 is surfaced). */ + async delete(pathOrUrl: string): Promise { + return this.withContext(async (ctx) => { + const res = await ctx.delete(this.url(pathOrUrl), { headers: this.headers() }) + const status = res.status() + if (status >= 500) + throw new Odrc5xxError(status, await res.text()) + if (!res.ok() && status !== 404) + throw new Error(`DELETE ${pathOrUrl} -> ${status}: ${(await res.text()).slice(0, 500)}`) + }) + } + + /** + * The UUID of the first available informatiecategorie — a `gepubliceerd` + * publicatie requires one, and the value list is a fixed prerequisite of the + * environment, so any of them serves for seeding. Throws if none exist. + */ + async firstInformatieCategorie(): Promise { + const cats = await this.list<{ uuid: string }>('informatiecategorieen') + const uuid = cats[0]?.uuid + if (!uuid) + throw new Error('ODRC has no informatiecategorieen — is the value list seeded?') + return uuid + } +} diff --git a/bdd/@publicatiebank/support/organisation.ts b/bdd/@publicatiebank/support/organisation.ts new file mode 100644 index 0000000..ddc3655 --- /dev/null +++ b/bdd/@publicatiebank/support/organisation.ts @@ -0,0 +1,59 @@ +import type { Page } from '@playwright/test' +import { adminResource } from './admin-resource' + +const resource = adminResource({ + path: 'metadata/organisation', + nameCell: 'th.field-naam a', +}) + +/** + * Create a self-added (`zelf_toegevoegd`) organisatie through the Django admin. + * The organisatie API can create (`POST`) but not delete, and cleanup must be + * portable, so the whole own-and-clean-up lifecycle runs through the admin — + * the same approach the information-category scenarios use. The manual add form + * forces `oorsprong = zelf_toegevoegd`; `is_actief` defaults on so the created + * organisatie shows up under the "actief" filter the testscript checks. + * Requires an authenticated admin session on `page`. + */ +export async function addSelfAddedOrganisation(page: Page, naam: string, { actief = true } = {}) { + await page.goto(resource.addFormUrl()) + await page.locator('#id_naam').fill(naam) + if (actief !== (await page.locator('#id_is_actief').isChecked())) + await page.locator('#id_is_actief').setChecked(actief) + await page.locator('input[name="_save"]').click() + // Django redirects to the changelist and shows a success message. + await resource.assertOnChangelist(page) +} + +/** + * List the names of leftover `E2E `-prefixed organisaties via the admin, so a + * hard-killed run can be swept clean. Requires an authenticated admin session. + */ +export function listE2EOrganisationNames(page: Page, prefix = 'E2E ') { + return resource.listE2ENames(page, prefix) +} + +/** + * Whether an organisatie with exactly this naam exists, read from the admin + * changelist. Deterministic verification for the Stagehand-driven scenarios + * (the token API is unreliable while Stagehand drives the same admin — see + * README). Requires an admin session. + */ +export function organisationExists(page: Page, naam: string): Promise { + return resource.exists(page, naam) +} + +/** Whether the organisatie's "Is actief" checkbox is ticked, read from its change page. */ +export async function organisationIsActive(page: Page, naam: string): Promise { + if (!(await resource.open(page, naam))) + return false + return page.locator('#id_is_actief').isChecked() +} + +/** + * Delete an organisatie by name via the admin, for scenario cleanup. No-op if + * it is already gone. + */ +export function deleteOrganisationByName(page: Page, naam: string) { + return resource.remove(page, naam) +} diff --git a/bdd/@publicatiebank/support/poll.ts b/bdd/@publicatiebank/support/poll.ts new file mode 100644 index 0000000..a23b0b4 --- /dev/null +++ b/bdd/@publicatiebank/support/poll.ts @@ -0,0 +1,33 @@ +import { isOdrc5xx } from './odrc' + +/** + * Retry `fn` while it fails with an ODRC 5xx, until it succeeds or `timeout` + * elapses. The GPP-publicatiebank token API 500s intermittently while an admin + * session is active on the same server (see `odrc.ts` / README "Known server + * flake"); this rides that flake out for the publicatie/document lifecycle + * scenarios (TS6–9) that read back through the API. Any non-5xx error (a real + * 4xx, an assertion) propagates immediately. + */ +export interface WaitForOdrcOptions { + /** Total time to keep retrying, ms. Default 30_000. */ + timeout?: number + /** Delay between attempts, ms. Default 1_000. */ + interval?: number +} + +export async function waitForOdrc(fn: () => Promise, options: WaitForOdrcOptions = {}): Promise { + const timeout = options.timeout ?? 30_000 + const interval = options.interval ?? 1_000 + const deadline = Date.now() + timeout + + while (true) { + try { + return await fn() + } + catch (err) { + if (!isOdrc5xx(err) || Date.now() >= deadline) + throw err + await new Promise(resolve => setTimeout(resolve, interval)) + } + } +} diff --git a/bdd/@publicatiebank/support/publication.ts b/bdd/@publicatiebank/support/publication.ts new file mode 100644 index 0000000..eba0362 --- /dev/null +++ b/bdd/@publicatiebank/support/publication.ts @@ -0,0 +1,121 @@ +import type { Page } from '@playwright/test' +import { ENV } from '@/bdd/_core/types' +import { adminResource } from './admin-resource' + +/** + * Publicatie test-data ownership through the Django admin. + * + * The token API cannot be used during a run: woo-publications' user-less service + * token 500s on every API call for as long as any admin session is active on the + * same server (its `SessionProfileMiddleware` dereferences a `None` user — see + * README "Known server flake"), which a Stagehand-driven run always causes. So + * publicaties are seeded, verified and cleaned up through the admin UI (session + * auth = a real user), exactly like the organisatie/onderwerp scenarios. + * + * A `concept` publicatie needs only an officiële titel; `gepubliceerd` also + * enforces a publisher (raw-id field) and an informatiecategorie (select2). + * + * NB: documents are intentionally not seeded — this stack has no Documents API + * configured (`/documenten` POST → 500 "No documents API configured yet"), so + * publicaties are created document-less. + */ +const resource = adminResource({ + path: 'publications/publication', + rowMatch: 'result-row', + changeId: '[0-9a-f-]+', + deleteVia: 'deletelink', +}) + +/** + * Create a `concept` publicatie through the Django admin (deterministic, not the + * action under test). A concept publicatie needs only an officiële titel and the + * status — publisher/informatiecategorie are only enforced on `gepubliceerd`. + * + * We seed through the admin (session auth = a real user) rather than the token + * API because woo-publications' user-less service token 500s on every API call + * for as long as Stagehand drives the admin on the same server (see README + * "Known server flake"). Requires an authenticated admin session on `page`. + */ +export async function addConceptPublication(page: Page, titel: string) { + await page.goto(resource.addFormUrl()) + await page.locator('#id_officiele_titel').fill(titel) + await page.locator('#id_publicatiestatus').selectOption('concept') + await page.locator('input[name="_save"]').click() + await resource.assertOnChangelist(page) +} + +/** + * Resolve the integer admin PK of an organisatie by exact naam (needed to fill + * the publisher raw-id field on the publicatie form). Throws if not found. + */ +async function organisationPk(page: Page, naam: string): Promise { + const orgChangelist = new URL('/admin/metadata/organisation/', ENV.apps.publicatiebank).href + await page.goto(`${orgChangelist}?q=${encodeURIComponent(naam)}`) + const href = await page.locator('#result_list tbody tr th a, #result_list tbody tr td a').first().getAttribute('href') + const pk = href?.match(/organisation\/(\d+)\/change/)?.[1] + if (!pk) + throw new Error(`Could not resolve admin PK for organisatie "${naam}"`) + return pk +} + +/** + * Create a `gepubliceerd` publicatie through the Django admin (deterministic + * prerequisite for the withdraw scenario). Publishing enforces a publisher and + * an informatiecategorie: publisher is a raw-id field (filled with the org's PK), + * informatiecategorie is a select2 autocomplete (first option picked). Requires + * an authenticated admin session and an existing (actief) organisatie `orgNaam`. + */ +export async function addPublishedPublication(page: Page, titel: string, orgNaam: string) { + const pk = await organisationPk(page, orgNaam) + await page.goto(resource.addFormUrl()) + await page.locator('#id_officiele_titel').fill(titel) + await page.locator('#id_publisher').fill(pk) + await page.locator('.field-informatie_categorieen .select2-selection').click() + await page.waitForSelector('.select2-results__option', { timeout: 8000 }) + await page.locator('.select2-results__option').first().click() + await page.locator('#id_publicatiestatus').selectOption('gepubliceerd') + await page.locator('input[name="_save"]').click() + await resource.assertOnChangelist(page) +} + +/** Delete a publicatie by exact titel via the admin. No-op if already gone. */ +export function deletePublicationByTitel(page: Page, titel: string) { + return resource.remove(page, titel) +} + +// --- Admin reads (deterministic while Stagehand drives the admin — see TS3/TS4) - + +/** + * Whether a publicatie with this titel is listed in the admin changelist. + * Deterministic verification for the Stagehand-driven beheer scenarios (the token + * API is unreliable while Stagehand drives the same admin — see README). + */ +export function publicationExistsAdmin(page: Page, titel: string): Promise { + return resource.exists(page, titel) +} + +/** + * The publicatiestatus of a publicatie, read (lower-cased) from the admin + * changelist column — not the change form, whose status field disappears once a + * publicatie is `ingetrokken` (read-only). '' if the row is not found. + */ +export async function publicationStatusAdmin(page: Page, titel: string): Promise { + await page.goto(`${resource.changelistUrl()}?q=${encodeURIComponent(titel)}`) + const row = page.locator('#result_list tbody tr', { hasText: titel }) + if ((await row.count()) === 0) + return '' + const cell = await row.first().locator('td.field-publicatiestatus').textContent() + return (cell ?? '').trim().toLowerCase() +} + +/** The omschrijving textarea value on the change page ('' if not found). */ +export async function publicationOmschrijvingAdmin(page: Page, titel: string): Promise { + if (!(await resource.open(page, titel))) + return '' + return page.locator('#id_omschrijving').inputValue() +} + +/** Officiële titels of leftover `E2E `-prefixed publicaties via the admin. */ +export function listE2EPublicationTitels(page: Page, prefix = 'E2E '): Promise { + return resource.listE2ENames(page, prefix) +} diff --git a/bdd/@publicatiebank/support/topic.ts b/bdd/@publicatiebank/support/topic.ts new file mode 100644 index 0000000..2676b31 --- /dev/null +++ b/bdd/@publicatiebank/support/topic.ts @@ -0,0 +1,75 @@ +import type { Page } from '@playwright/test' +import path from 'node:path' +import { adminResource } from './admin-resource' + +const resource = adminResource({ + path: 'publications/topic', + nameCell: 'th.field-officiele_titel a', + changeId: '[0-9a-f-]+', +}) + +/** A small (≤600×600) PNG the add form accepts as the mandatory afbeelding. */ +export const TOPIC_IMAGE = path.join(__dirname, '..', 'bdd', '@publicatiebank', '@admin', 'fixtures', 'onderwerp.png') + +/** + * Create an onderwerp (Topic) through the Django admin. Onderwerpen are + * `GET`-only over the API, so — like information categories — the only way a + * test can own one is the admin. The add form requires an afbeelding (max + * 600×600 px) plus an officiële titel and a publicatiestatus. + * + * Defaults to `concept` status so the test-owned onderwerp never becomes + * publicly visible on the burgerportaal; pass `status: 'gepubliceerd'` when a + * scenario needs the public effect. Requires an authenticated admin session. + */ +export async function addTopic( + page: Page, + titel: string, + { status = 'concept', promoot = false, omschrijving = '' } = {}, +) { + await page.goto(resource.addFormUrl()) + await page.locator('#id_afbeelding').setInputFiles(TOPIC_IMAGE) + await page.locator('#id_officiele_titel').fill(titel) + if (omschrijving) + await page.locator('#id_omschrijving').fill(omschrijving) + await page.locator('#id_publicatiestatus').selectOption(status) + if (promoot) + await page.locator('#id_promoot').setChecked(true) + await page.locator('input[name="_save"]').click() + await resource.assertOnChangelist(page) +} + +/** + * List the officiële titels of leftover `E2E `-prefixed onderwerpen via the + * admin, so a hard-killed run can be swept clean. Requires an admin session. + */ +export function listE2ETopicNames(page: Page, prefix = 'E2E ') { + return resource.listE2ENames(page, prefix) +} + +/** + * Whether an onderwerp with exactly this titel exists, read from the admin + * changelist. Deterministic verification for the Stagehand-driven scenarios (the + * token API is unreliable while Stagehand drives the same admin — see README). + */ +export function topicExists(page: Page, titel: string): Promise { + return resource.exists(page, titel) +} + +/** Whether the onderwerp's "Promoot" checkbox is ticked, read from its change page. */ +export async function topicIsPromoted(page: Page, titel: string): Promise { + if (!(await resource.open(page, titel))) + return false + return page.locator('#id_promoot').isChecked() +} + +/** The onderwerp's omschrijving text, read from its change page ('' if not found). */ +export async function topicOmschrijving(page: Page, titel: string): Promise { + if (!(await resource.open(page, titel))) + return '' + return page.locator('#id_omschrijving').inputValue() +} + +/** Delete an onderwerp by officiële titel via the admin. No-op if already gone. */ +export function deleteTopicByName(page: Page, titel: string) { + return resource.remove(page, titel) +} diff --git a/bdd/_core/fixture.ts b/bdd/_core/fixture.ts new file mode 100644 index 0000000..fb6a60e --- /dev/null +++ b/bdd/_core/fixture.ts @@ -0,0 +1,350 @@ +import type { ConfigSnapshot } from '@/bdd/@burgerportaal/support/beheer-config' +import type { Stagehand } from '@browserbasehq/stagehand' +import type { AppName, User } from './types' +import { BeheerConfigClient } from '@/bdd/@burgerportaal/support/beheer-config' +import { deleteUsergroupByName, usergroupExists } from '@/bdd/@gpp-app/support/usergroup' +import { addSelfAddedCategory, deleteCategoryByName } from '@/bdd/@publicatiebank/support/information-category' +import { OdrcClient } from '@/bdd/@publicatiebank/support/odrc' +import { addSelfAddedOrganisation, deleteOrganisationByName } from '@/bdd/@publicatiebank/support/organisation' +import { addConceptPublication, deletePublicationByTitel } from '@/bdd/@publicatiebank/support/publication' +import { addTopic, deleteTopicByName } from '@/bdd/@publicatiebank/support/topic' +import { signIn as performSignIn } from '@/bdd/_core/signIn' +import { createStagehand, modelForTags } from '@/bdd/_core/stagehand' +import { adminState, burgerportaalAdminState, regularState } from '@/setup/paths' +import { request as apiRequest } from '@playwright/test' +import { test as base, createBdd } from 'playwright-bdd' +import { makeResourceManager } from './resource-manager' +import { DEFAULT_USER, ENV } from './types' + +/** Mutable holder so hooks and steps can agree on who logs in. */ +export interface CurrentUser { + value?: User +} + +/** + * Owns the self-added information categories a scenario creates. Every category + * added through this manager is deleted again during fixture teardown, so + * scenarios leave no data behind. + */ +export interface CategoryManager { + /** Create a uniquely-named self-added category; returns its name. */ + add: (naam?: string) => Promise + /** Name of the most recently added category (throws if none). */ + last: () => string +} + +/** + * Owns the self-added organisaties a scenario creates. Every organisatie added + * through this manager is deleted again during teardown (via the admin), so + * scenarios leave no data behind. Mirrors {@link CategoryManager}. + */ +export interface OrganisationManager { + /** + * Deterministically create a self-added organisatie (a fast prerequisite for + * scenarios that manipulate an existing one — not the create action under + * test); returns its name. + */ + add: (naam?: string, opts?: { actief?: boolean }) => Promise + /** + * Register a name created some other way (e.g. through Stagehand in the + * scenario under test) so teardown deletes it too. Returns the name. + */ + track: (naam: string) => string + /** A fresh unique `E2E …` name, without creating anything. */ + freshName: () => string + /** Name of the most recently added/tracked organisatie (throws if none). */ + last: () => string +} + +/** + * Owns the onderwerpen (topics) a scenario creates; deleted again in teardown. + * Mirrors {@link CategoryManager}. + */ +export interface TopicManager { + /** Deterministically create an onderwerp (prerequisite); returns its titel. */ + add: (titel?: string, opts?: { status?: string, promoot?: boolean, omschrijving?: string }) => Promise + /** Register a titel created some other way so teardown deletes it. */ + track: (titel: string) => string + /** A fresh unique `E2E …` titel, without creating anything. */ + freshName: () => string + /** Officiële titel of the most recently added/tracked onderwerp (throws if none). */ + last: () => string +} + +/** + * Owns the publicaties a scenario creates. Both create and cleanup run through + * the Django admin (session auth): the token API is unusable while Stagehand + * drives the admin on the same server. A titel created some other way (e.g. by a + * Stagehand mutation, or a rename) can be tracked so cleanup deletes it too. + */ +export interface PublicationManager { + /** + * Seed a `concept` publicatie through the admin (session auth — the token API + * is unusable while Stagehand drives the admin); returns its titel. + */ + seed: (titel?: string) => Promise + /** Register a titel created some other way so cleanup deletes it. */ + track: (titel: string) => string + /** A fresh unique `E2E …` titel, without creating anything. */ + freshName: () => string + /** Titel of the most recently seeded/tracked publicatie (throws if none). */ + last: () => string +} + +/** + * Owns the gpp-app gebruikersgroepen a scenario creates. Groups are mutated + * through the gpp-app UI (Stagehand); this manager verifies + cleans up through + * the odpc JSON API (session-authenticated). + */ +export interface UsergroupManager { + /** Whether a gebruikersgroep with this naam exists (read over the odpc API). */ + exists: (naam: string) => Promise + /** Register a naam created through the UI so cleanup deletes it. */ + track: (naam: string) => string + /** A fresh unique `E2E …` naam, without creating anything. */ + freshName: () => string + /** Naam of the most recently tracked gebruikersgroep (throws if none). */ + last: () => string +} + +/** One GET against the burgerportaal (status + content type + raw body). */ +export interface SitemapResponse { + path: string + status: number + contentType: string + body: string +} + +/** + * Anonymous HTTP client for the public GPP-burgerportaal sitemap. Uses + * Playwright's `request` context (no browser, no auth — the Woo-index harvester + * reads the sitemap signed-out) and remembers the last response so a `When I + * fetch ...` step and its `Then ...` assertions can share it. + */ +export interface SitemapClient { + /** GET `path` on the burgerportaal, store and return the response. */ + get: (path: string) => Promise + /** The most recently fetched response (throws if nothing fetched yet). */ + last: () => SitemapResponse +} + +/** Scenario-scoped scratch space shared between a mutating step and its assertion. */ +export interface BeheerState { + /** Expected config values keyed by field (e.g. the unique welcome text set). */ + expected: Map + /** Public image bytes (hex sha) captured before a replacement, keyed by kind. */ + imageBefore: Map +} + +interface BddFixtures { + /** Selected identity for a live sign-in; set by the auth hooks (see hooks.ts). */ + currentUser: CurrentUser + /** Navigate to `app` and sign in live as `currentUser` (falls back to DEFAULT_USER). */ + signIn: (app: AppName) => Promise + /** Test-owned self-added information categories (created + cleaned up). */ + categories: CategoryManager + /** Test-owned self-added organisaties (created + cleaned up via the admin). */ + organisations: OrganisationManager + /** Test-owned onderwerpen/topics (created + cleaned up via the admin). */ + topics: TopicManager + /** Token-authenticated GPP-publicatiebank (ODRC) API client for read verification (TS6-9). */ + odrc: OdrcClient + /** Test-owned publicaties (seeded + cleaned up via the admin). */ + publications: PublicationManager + /** Test-owned gpp-app gebruikersgroepen (verified + cleaned up via the odpc API). */ + usergroups: UsergroupManager + /** Anonymous HTTP client for the public burgerportaal sitemap. */ + sitemap: SitemapClient + /** + * AI browser (Stagehand + OpenRouter) for the @beheer scenarios. Runs its own + * Chromium with the beheer-admin session injected; the model is chosen from + * the scenario tags. Closed automatically in teardown. + */ + stagehand: Stagehand + /** + * AI browser (Stagehand + OpenRouter) with the publicatiebank Django-admin + * session injected, for the @admin metadata scenarios that mutate the admin UI + * from natural language. Model chosen from the scenario tags; closed in teardown. + */ + adminStagehand: Stagehand + /** + * Authenticated burgerportaal beheer config client. Snapshots the config on + * setup and restores it in teardown, so a scenario owns (and cleans up) every + * change it makes — safe against shared/production environments. + */ + beheer: BeheerConfigClient + /** Scenario-scoped scratch space for the @beheer steps. */ + beheerState: BeheerState + /** Generic scenario-scoped key/value scratch shared between a step and its assertion. */ + scratch: Map +} + +export const test = base.extend({ + /** + * Auth-by-tag: reuse a pre-authenticated session based on the scenario tags. + * `@admin` / `@regular` load the storage state produced by the `setup` + * project; anything else starts signed-out (e.g. the login-flow feature, + * which signs in live via a step). + */ + storageState: async ({ $tags }, use) => { + if ($tags.includes('@admin')) + await use(adminState) + else if ($tags.includes('@regular')) + await use(regularState) + else + await use(undefined) + }, + + // Playwright requires the first arg to be a destructuring pattern; this + // fixture depends on no others. + // eslint-disable-next-line no-empty-pattern + currentUser: async ({}, use) => { + await use({}) + }, + signIn: async ({ page, currentUser }, use) => { + await use(async (app: AppName) => { + const user = currentUser.value ?? ENV.users[DEFAULT_USER] + await performSignIn(page, app, user) + }) + }, + categories: async ({ page }, use, testInfo) => { + const { manager, teardown } = makeResourceManager({ + workerIndex: testInfo.workerIndex, + prefix: 'E2E ', + emptyMessage: 'No self-added information category created in this scenario', + create: name => addSelfAddedCategory(page, name), + remove: name => deleteCategoryByName(page, name), + }) + await use(manager) + await teardown() + }, + organisations: async ({ page }, use, testInfo) => { + const { manager, teardown } = makeResourceManager<{ actief?: boolean }>({ + workerIndex: testInfo.workerIndex, + prefix: 'E2E ', + emptyMessage: 'No self-added organisatie created in this scenario', + create: (name, opts) => addSelfAddedOrganisation(page, name, opts), + // Deterministic (admin selectors), regardless of how the row was created — + // the organisatie API has no DELETE. + remove: name => deleteOrganisationByName(page, name), + }) + await use(manager) + await teardown() + }, + topics: async ({ page }, use, testInfo) => { + const { manager, teardown } = makeResourceManager<{ status?: string, promoot?: boolean, omschrijving?: string }>({ + workerIndex: testInfo.workerIndex, + prefix: 'E2E ', + emptyMessage: 'No onderwerp created in this scenario', + create: (name, opts) => addTopic(page, name, opts), + remove: name => deleteTopicByName(page, name), + }) + await use(manager) + await teardown() + }, + // Token-authenticated ODRC API client. Each call runs in its own throwaway, + // cookieless request context (token + audit headers), so it needs no + // fixture-managed context of its own. + // eslint-disable-next-line no-empty-pattern + odrc: async ({}, use) => { + await use(new OdrcClient()) + }, + publications: async ({ page }, use, testInfo) => { + // Seed + teardown via the admin (session auth), deterministic regardless of how + // the row was created; the run-wide sweep in global-teardown catches leftovers. + const { manager, teardown } = makeResourceManager({ + workerIndex: testInfo.workerIndex, + prefix: 'E2E pub ', + emptyMessage: 'No publicatie seeded/tracked in this scenario', + create: name => addConceptPublication(page, name), + remove: name => deletePublicationByTitel(page, name), + }) + await use(manager) + await teardown() + }, + // eslint-disable-next-line no-empty-pattern + usergroups: async ({}, use, testInfo) => { + // Session-authenticated odpc API client (gpp-app cookies live in adminState). + const ctx = await apiRequest.newContext({ storageState: adminState }) + const { manager, teardown } = makeResourceManager({ + workerIndex: testInfo.workerIndex, + prefix: 'E2E groep ', + emptyMessage: 'No gebruikersgroep created/tracked in this scenario', + // Groups are created through the gpp-app UI (Stagehand), verified + cleaned + // up over the odpc API. + remove: naam => deleteUsergroupByName(ctx, naam), + exists: naam => usergroupExists(ctx, naam), + }) + try { + await use(manager) + } + finally { + await teardown() + await ctx.dispose() + } + }, + sitemap: async ({ request }, use) => { + const base = ENV.apps.burgerportaal.replace(/\/$/, '') + let latest: SitemapResponse | undefined + await use({ + async get(path) { + const response = await request.get(base + path) + latest = { + path, + status: response.status(), + contentType: response.headers()['content-type'] ?? '', + body: await response.text(), + } + return latest + }, + last() { + if (!latest) + throw new Error('No sitemap resource fetched yet in this scenario') + return latest + }, + }) + }, + stagehand: async ({ $tags }, use) => { + const stagehand = await createStagehand({ + model: modelForTags($tags), + storageStatePath: burgerportaalAdminState, + }) + await use(stagehand) + await stagehand.close() + }, + adminStagehand: async ({ $tags }, use) => { + const stagehand = await createStagehand({ + model: modelForTags($tags), + storageStatePath: adminState, + }) + await use(stagehand) + await stagehand.close() + }, + // Depends on no other fixtures; builds its own authenticated API context. + // eslint-disable-next-line no-empty-pattern + beheer: async ({}, use) => { + const context = await apiRequest.newContext({ storageState: burgerportaalAdminState }) + const client = new BeheerConfigClient(context) + let snapshot: ConfigSnapshot | undefined + try { + snapshot = await client.snapshot() + await use(client) + } + finally { + // Own-and-cleanup: put every value back exactly as it was found. + if (snapshot) + await client.restore(snapshot).catch(() => {}) + await context.dispose() + } + }, + // eslint-disable-next-line no-empty-pattern + beheerState: async ({}, use) => { + await use({ expected: new Map(), imageBefore: new Map() }) + }, + // eslint-disable-next-line no-empty-pattern + scratch: async ({}, use) => { + await use(new Map()) + }, +}) + +export const { Given, When, Then, Before, After } = createBdd(test) diff --git a/bdd/_core/keycloak.ts b/bdd/_core/keycloak.ts new file mode 100644 index 0000000..0e91e62 --- /dev/null +++ b/bdd/_core/keycloak.ts @@ -0,0 +1,71 @@ +import type { User } from '@/bdd/_core/types' +import type { Page } from '@playwright/test' +import { Secret, TOTP } from 'otpauth' + +const TOTP_PERIOD_MS = 30_000 + +/** + * Wait until the next TOTP time-window so a *fresh* one-time code is generated. + * Keycloak rejects a code that was already consumed ("Invalid authenticator + * code"), which happens when two logins for the same account land in the same + * 30s window (e.g. the auth setup and the login-flow feature). Waiting for the + * next window and regenerating sidesteps the reuse. + */ +async function waitForNextTotpWindow(page: Page) { + const now = Date.now() + await page.waitForTimeout(TOTP_PERIOD_MS - (now % TOTP_PERIOD_MS) + 500) +} + +/** + * After triggering an OIDC challenge, wait for one of the two outcomes: Keycloak + * either shows its login form (`#username`) or — when a realm-wide SSO session + * already exists — redirects straight to `landedUrl`. Returns whether the login + * form is showing (i.e. whether the caller still needs to fill credentials). The + * publicatiebank admin and the burgerportaal beheer login share this race. + */ +export async function awaitKeycloakFormOrSso(page: Page, landedUrl: RegExp): Promise { + const username = page.locator('#username') + await Promise.race([ + username.waitFor().catch(() => {}), + page.waitForURL(landedUrl).catch(() => {}), + ]) + return username.isVisible().catch(() => false) +} + +/** + * Drive the Keycloak login form (local `gpp-local` realm). + * + * Keycloak shows username + password on one page (#username / #password, + * submit #kc-login), optionally followed by a one-time-code page (#otp) when + * the user has TOTP configured. The OTP submit retries with a fresh code if the + * first is rejected as reused. + */ +export async function fillKeycloakLogin(page: Page, user: User) { + await page.locator('#username').waitFor() + await page.locator('#username').fill(user.email) + await page.locator('#password').fill(user.password) + await page.locator('#kc-login').click() + + // One-time-code page (only shown when the user has TOTP configured). + const otp = page.locator('#otp') + await otp.waitFor({ timeout: 10_000 }).catch(() => {}) + if (!(await otp.isVisible())) + return + + const totp = new TOTP({ secret: Secret.fromBase32(user.otpSecret) }) + + for (let attempt = 0; attempt < 3; attempt++) { + await otp.fill(totp.generate()) + await page.locator('#kc-login').click() + // Submitting the code POSTs the form: on success Keycloak redirects away + // (the #otp field is gone), on failure it re-renders the OTP page with an + // error. Wait for the page to settle, then decide by #otp's presence — do + // not match the error text, which lingers in the DOM across re-renders. + await page.waitForLoadState('networkidle').catch(() => {}) + if (!(await otp.isVisible().catch(() => false))) + return + + // Rejected (reused/expired code): wait for a fresh window and retry. + await waitForNextTotpWindow(page) + } +} diff --git a/bdd/_core/resource-manager.ts b/bdd/_core/resource-manager.ts new file mode 100644 index 0000000..cababbe --- /dev/null +++ b/bdd/_core/resource-manager.ts @@ -0,0 +1,87 @@ +/** + * The shared lifecycle behind every test-owned resource fixture (categories, + * organisaties, onderwerpen, publicaties, gebruikersgroepen). + * + * Each of those fixtures owns the `E2E `-prefixed rows a scenario creates: it + * hands out unique names, remembers what was created or tracked, and deletes it + * all in reverse order during teardown. That scaffolding was copy-pasted five + * times in `fixture.ts`; this factory captures it once. A fixture supplies only + * what is genuinely per-resource — how to create a row, how to delete one, and + * (for the API-backed usergroups) how to check existence. + */ +export interface ResourceManagerConfig { + /** Playwright worker index, to keep names unique across parallel workers. */ + workerIndex: number + /** Name prefix, e.g. `E2E `, `E2E pub `, `E2E groep `. All swept by `E2E `. */ + prefix: string + /** Message thrown by `last()` when nothing has been created/tracked yet. */ + emptyMessage: string + /** Create a row with this name. Omitted for resources created only through the UI. */ + create?: (name: string, opts?: Opts) => Promise + /** Delete a row by name (best-effort in teardown). */ + remove: (name: string) => Promise + /** Whether a row with this name exists. Only for resources with a read API. */ + exists?: (name: string) => Promise +} + +export interface ResourceManager { + /** Create a uniquely-named row (or with the given name); returns the name. */ + add: (name?: string, opts?: Opts) => Promise + /** Alias for {@link add}, read better where creation is "seeding a prerequisite". */ + seed: (name?: string) => Promise + /** Register a name created some other way (e.g. via Stagehand) so teardown deletes it. */ + track: (name: string) => string + /** A fresh unique `${prefix}…` name, without creating anything. */ + freshName: () => string + /** Name of the most recently added/tracked row (throws if none). */ + last: () => string + /** Whether a row with this name exists (throws if no `exists` was configured). */ + exists: (name: string) => Promise +} + +export function makeResourceManager( + config: ResourceManagerConfig, +): { manager: ResourceManager, teardown: () => Promise } { + const created: string[] = [] + let seq = 0 + const freshName = () => `${config.prefix}${config.workerIndex}-${seq++}-${Date.now()}` + + const add = async (name?: string, opts?: Opts) => { + if (!config.create) + throw new Error(`This resource manager has no create step (prefix "${config.prefix}")`) + const value = name ?? freshName() + await config.create(value, opts) + created.push(value) + return value + } + + const manager: ResourceManager = { + add, + seed: name => add(name), + track(name) { + created.push(name) + return name + }, + freshName, + last() { + const name = created.at(-1) + if (!name) + throw new Error(config.emptyMessage) + return name + }, + exists(name) { + if (!config.exists) + throw new Error(`This resource manager has no exists check (prefix "${config.prefix}")`) + return config.exists(name) + }, + } + + // Teardown: remove everything this scenario created/tracked, newest first, + // best-effort — the run-wide sweep in global-teardown catches any leftovers. + const teardown = async () => { + for (const name of [...created].reverse()) + await config.remove(name).catch(() => {}) + } + + return { manager, teardown } +} diff --git a/bdd/_core/signIn.ts b/bdd/_core/signIn.ts new file mode 100644 index 0000000..7ab2177 --- /dev/null +++ b/bdd/_core/signIn.ts @@ -0,0 +1,18 @@ +import type { AppName, User } from '@/bdd/_core/types' +import type { Page } from '@playwright/test' +import { signInToGppApp } from '@/bdd/@gpp-app/support/login' +import { signInToPublicatiebank } from '@/bdd/@publicatiebank/support/login' +import { ENV } from '@/bdd/_core/types' + +/** + * Navigate to `app` and perform a full Keycloak sign-in as `user`. + */ +export async function signIn(page: Page, app: AppName, user: User) { + await page.goto(ENV.apps[app]) + if (app === 'gppApp') { + await signInToGppApp(page, user) + } + else { + await signInToPublicatiebank(page, user) + } +} diff --git a/bdd/_core/stagehand.ts b/bdd/_core/stagehand.ts new file mode 100644 index 0000000..95a7fe6 --- /dev/null +++ b/bdd/_core/stagehand.ts @@ -0,0 +1,169 @@ +import fs from 'node:fs' +import process from 'node:process' +import { CustomOpenAIClient, Stagehand } from '@browserbasehq/stagehand' +import OpenAI from 'openai' + +/** + * Stagehand is Browserbase's AI browser-automation layer on top of Playwright: + * `act()`/`extract()`/`observe()` drive the page from natural-language intent + * via an LLM. We point it at OpenRouter (an OpenAI-compatible gateway) so a + * single key reaches many providers, and let each scenario pick a model by tag. + * + * OpenRouter is OpenAI-wire-compatible, so we use Stagehand's `CustomOpenAIClient` + * with `baseURL` swapped — this side-steps the ai-sdk major-version drift + * between Stagehand's `AISdkClient` (LanguageModelV2) and the newer provider + * packages. + */ + +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1' + +/** Strip a leading/trailing ```json … ``` markdown fence, if present. */ +function stripCodeFence(text: string): string { + const trimmed = text.trim() + if (!trimmed.startsWith('```')) + return text + return trimmed + .replace(/^```[a-z]*[ \t]*\n?/i, '') // opening fence (+ optional language) + .replace(/\n?```$/, '') // closing fence + .trim() +} + +/** + * Stagehand's act()/extract() ask the model for strict JSON and then + * `JSON.parse` the message content. Some models reachable through OpenRouter + * (notably Anthropic Claude) ignore `response_format: json_object` and wrap + * their JSON in a ```json code fence, which breaks the parse. This fetch + * wrapper post-processes chat-completion responses and unwraps any such fence, + * so every model — cheap or expensive, OpenAI/Google/Anthropic — yields + * parseable JSON. It is a no-op for models that already answer cleanly. + */ +async function openRouterFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init) + if (!(response.headers.get('content-type') ?? '').includes('application/json')) + return response + + const text = await response.text() + let body = text + try { + const json = JSON.parse(text) + let changed = false + for (const choice of json.choices ?? []) { + const content = choice?.message?.content + if (typeof content === 'string') { + const stripped = stripCodeFence(content) + if (stripped !== content) { + choice.message.content = stripped + changed = true + } + } + } + if (changed) + body = JSON.stringify(json) + } + catch { + // Not JSON we can post-process (e.g. a streamed/error body) — pass through. + } + + // Rebuild without the original Content-Length (the body may have shrunk). + const headers = new Headers(response.headers) + headers.delete('content-length') + return new Response(body, { status: response.status, statusText: response.statusText, headers }) +} + +/** Default model when a scenario carries no model tag — good-enough + cheap. */ +export const DEFAULT_MODEL = 'google/gemini-2.5-flash' + +/** + * Scenario tag -> OpenRouter model id. A scenario opts into a stronger (or + * cheaper) model by adding one of these tags; the first match wins, otherwise + * {@link DEFAULT_MODEL} is used. Add rows here to expose more models. + */ +export const MODEL_BY_TAG: Record = { + '@expensive-ai': 'anthropic/claude-opus-4.1', + '@anthropic': 'anthropic/claude-sonnet-4.5', + '@openai': 'openai/gpt-4.1', + '@cheap-ai': 'google/gemini-2.5-flash-lite', +} + +/** Resolve the OpenRouter model id for a scenario's tags. */ +export function modelForTags(tags: string[]): string { + for (const tag of Object.keys(MODEL_BY_TAG)) { + if (tags.includes(tag)) + return MODEL_BY_TAG[tag] + } + return DEFAULT_MODEL +} + +/** True when an OpenRouter key is configured (else @beheer scenarios skip). */ +export function hasOpenRouterKey(): boolean { + return !!process.env.OPENROUTER_API_KEY +} + +/** Same Keycloak host mapping the main Playwright config uses (chromium only). */ +const KEYCLOAK_HOST_MAP = '--host-resolver-rules=MAP keycloak.woo-search.local 127.0.0.1' + +export interface StagehandOptions { + /** OpenRouter model id — usually `modelForTags($tags)`. */ + model: string + /** Storage-state file whose cookies get injected to restore an auth session. */ + storageStatePath?: string +} + +/** + * Build and initialise a LOCAL Stagehand driven by an OpenRouter model. + * + * Stagehand launches its own Chromium (separate from Playwright's `page` + * fixture), so we (a) add the same Keycloak host-resolver arg the main config + * uses and (b) inject the saved beheer-admin cookies after init — restoring the + * authenticated session without re-running the OIDC + TOTP flow inside the AI + * browser. + */ +export async function createStagehand(opts: StagehandOptions): Promise { + const apiKey = process.env.OPENROUTER_API_KEY + if (!apiKey) + throw new Error('OPENROUTER_API_KEY is not set (required for the @beheer Stagehand scenarios)') + + const llmClient = new CustomOpenAIClient({ + modelName: opts.model, + client: new OpenAI({ apiKey, baseURL: OPENROUTER_BASE_URL, fetch: openRouterFetch }), + }) + + const stagehand = new Stagehand({ + env: 'LOCAL', + llmClient, + verbose: 0, + localBrowserLaunchOptions: { + headless: !process.env.HEADED, + args: [KEYCLOAK_HOST_MAP], + }, + }) + await stagehand.init() + + if (opts.storageStatePath && fs.existsSync(opts.storageStatePath)) { + const state = JSON.parse(fs.readFileSync(opts.storageStatePath, 'utf-8')) + if (Array.isArray(state.cookies) && state.cookies.length) + await stagehand.context.addCookies(state.cookies) + } + + return stagehand +} + +/** + * The single understudy page Stagehand drives over CDP. It supports + * `.goto()`/`.locator()`/`.setInputFiles()`/`.waitForLoadState()` but NOT + * `getByRole` — use the ordinary session `page` fixture for that. Its type comes + * from Stagehand's bundled playwright-core, distinct from `@playwright/test`'s + * `Page`, so it is inferred rather than annotated. Shared by every vertical that + * drives an AI browser (publicatiebank admin, gpp-app, burgerportaal beheer). + */ +export function stagehandPage(stagehand: Stagehand) { + return stagehand.context.pages()[0] +} + +/** The CDP page type Stagehand drives (see {@link stagehandPage}). */ +export type StagehandPage = ReturnType + +/** Wait for the network to go idle, swallowing the timeout (best-effort settle). */ +export function settle(page: StagehandPage): Promise { + return page.waitForLoadState('networkidle').then(() => {}).catch(() => {}) +} diff --git a/bdd/_core/types.ts b/bdd/_core/types.ts new file mode 100644 index 0000000..bbbea6c --- /dev/null +++ b/bdd/_core/types.ts @@ -0,0 +1,59 @@ +import process from 'node:process' +import { z } from 'zod' +import 'dotenv/config' + +const userSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), + otpSecret: z.string().min(1), +}) +export type User = z.infer + +const envSchema = z.object({ + apps: z.object({ + gppApp: z.string().url(), + publicatiebank: z.string().url(), + burgerportaal: z.string().url(), + }), + users: z.record(z.string(), userSchema), + /** + * GPP-publicatiebank (ODRC) REST API — the create/read/delete backend the + * suite uses to own `organisaties`, `publicaties` and `documenten` test data + * portably (no container access). The API needs a token plus the audit + * headers woo-publications enforces; see `@publicatiebank/support/odrc.ts`. + */ + odrc: z.object({ + baseUrl: z.string().url(), + apiKey: z.string().min(1), + }), +}) +export type Env = z.infer +export type AppName = keyof Env['apps'] + +/** User signed in by the `@auth` hook when no explicit user is set. */ +export const DEFAULT_USER = 'regular' + +export const ENV = envSchema.parse({ + apps: { + gppApp: process.env.GPP_APP_BASE_URL, + publicatiebank: process.env.GPP_PUBLICATIEBANK_BASE_URL, + burgerportaal: process.env.GPP_BURGERPORTAAL_BASE_URL, + }, + users: { + regular: { + email: process.env.DEFAULT_EMAIL, + password: process.env.DEFAULT_PASSWORD, + otpSecret: process.env.DEFAULT_OTP_SECRET, + }, + admin: { + email: process.env.ADMIN_EMAIL, + password: process.env.ADMIN_PASSWORD, + otpSecret: process.env.ADMIN_OTP_SECRET, + }, + }, + odrc: { + // Defaults to the publicatiebank host: the ODRC API lives there under /api/v2. + baseUrl: process.env.ODRC_BASE_URL ?? process.env.GPP_PUBLICATIEBANK_BASE_URL, + apiKey: process.env.ODRC_API_KEY, + }, +} satisfies Env) diff --git a/login-helpers/gpp-app.ts b/login-helpers/gpp-app.ts deleted file mode 100644 index bfe9628..0000000 --- a/login-helpers/gpp-app.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Page } from '@playwright/test' -import type { LoginParams } from './types' -import { loginToAzureEntraId } from './azure' - -export async function login(page: Page, loginParams: LoginParams | undefined) { - const loginLink = page.getByRole('link', { name: 'Inloggen' }) - const publicatiesLink = page.getByRole('link', { name: 'Publicaties' }) - await loginLink.or(publicatiesLink).waitFor() - if (await loginLink.isVisible()) { - await loginLink.click() - await loginToAzureEntraId(page, loginParams) - await publicatiesLink.waitFor() - } -} diff --git a/login-helpers/index.ts b/login-helpers/index.ts deleted file mode 100644 index b3b133b..0000000 --- a/login-helpers/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Page } from '@playwright/test' -import type { LoginParams } from '../login-helpers/types' -import environment from '../environment' -import { login as loginInApp } from '../login-helpers/gpp-app' -import { login as loginMaykin } from '../login-helpers/maykin' - -export const adminState = './.auth/admin.json' -export const regularUserState = './.auth/regular-user.json' - -export async function logIn(page: Page, loginParams?: LoginParams) { - const url = page.url() - if (url.includes(environment.GPP_APP_BASE_URL)) { - await loginInApp(page, loginParams) - } - else { - return loginMaykin(page, loginParams) - } -} diff --git a/setup/auth.setup.ts b/setup/auth.setup.ts new file mode 100644 index 0000000..94ff927 --- /dev/null +++ b/setup/auth.setup.ts @@ -0,0 +1,33 @@ +import { signInToBurgerportaalBeheer } from '@/bdd/@burgerportaal/support/login' +import { verifyWaardelijstPresent } from '@/bdd/@publicatiebank/support/information-category' +import { signIn } from '@/bdd/_core/signIn' +import { ENV } from '@/bdd/_core/types' +import { test as setup } from '@playwright/test' +import { adminState, burgerportaalAdminState, regularState } from './paths' + +/** + * Sign in once per role and persist the session. Runs serially as a project + * dependency before the BDD tests. + * + * The admin logs into the GPP-app first (full Keycloak flow incl. TOTP), which + * establishes a realm-wide SSO session; signing into the publicatiebank admin + * then piggybacks on that session, so a *second* one-time code is never needed. + */ +setup('authenticate as admin', async ({ page }) => { + await signIn(page, 'gppApp', ENV.users.admin) + await signIn(page, 'publicatiebank', ENV.users.admin) + // Reuse the admin session to assert the reference data prerequisite is met. + await verifyWaardelijstPresent(page) + await page.context().storageState({ path: adminState }) + + // Establish the burgerportaal beheer session in the same context so it + // piggybacks the realm-wide SSO session (no second one-time code), then + // persist it separately for the Stagehand-driven @beheer scenarios. + await signInToBurgerportaalBeheer(page, ENV.users.admin) + await page.context().storageState({ path: burgerportaalAdminState }) +}) + +setup('authenticate as regular user', async ({ page }) => { + await signIn(page, 'gppApp', ENV.users.regular) + await page.context().storageState({ path: regularState }) +}) diff --git a/setup/global-teardown.ts b/setup/global-teardown.ts new file mode 100644 index 0000000..c060fb1 --- /dev/null +++ b/setup/global-teardown.ts @@ -0,0 +1,79 @@ +import type { Page } from '@playwright/test' +import { deleteUsergroupByName, listE2EUsergroupNames } from '@/bdd/@gpp-app/support/usergroup' +import { deleteCategoryByName, listE2ECategoryNames } from '@/bdd/@publicatiebank/support/information-category' +import { deleteOrganisationByName, listE2EOrganisationNames } from '@/bdd/@publicatiebank/support/organisation' +import { deletePublicationByTitel, listE2EPublicationTitels } from '@/bdd/@publicatiebank/support/publication' +import { deleteTopicByName, listE2ETopicNames } from '@/bdd/@publicatiebank/support/topic' +import { request as apiRequest, chromium } from '@playwright/test' +import { adminState } from './paths' + +/** + * One admin-swept resource: how to list `E2E `-prefixed leftovers and how to + * delete one. Adding a new metadata resource is one row here, not a new block. + */ +interface AdminSweep { + label: string + list: (page: Page) => Promise + remove: (page: Page, name: string) => Promise +} + +const ADMIN_SWEEPS: AdminSweep[] = [ + { label: 'categories', list: listE2ECategoryNames, remove: deleteCategoryByName }, + { label: 'organisaties', list: listE2EOrganisationNames, remove: deleteOrganisationByName }, + { label: 'onderwerpen', list: listE2ETopicNames, remove: deleteTopicByName }, + { label: 'publicaties', list: listE2EPublicationTitels, remove: deletePublicationByTitel }, +] + +/** + * Safety net for data ownership: scenarios delete the `E2E `-prefixed rows they + * create in fixture teardown, but a hard-killed run (SIGTERM/CI cancel) skips + * that. This sweeps any leftovers after the whole run so test data never + * accumulates. + * + * The metadata resources are swept through the Django admin with the saved admin + * session (`.auth/admin.json`) — no shell/`docker exec` access required. + * Gebruikersgroepen are swept over the odpc JSON API (they have no admin here). + * Best-effort — never fails the run. + */ +async function globalTeardown() { + const browser = await chromium.launch({ + // Reach the local Keycloak issuer host in case the session needs a refresh. + args: ['--host-resolver-rules=MAP keycloak.woo-search.local 127.0.0.1'], + }) + try { + const context = await browser.newContext({ storageState: adminState }) + const page = await context.newPage() + + const counts: string[] = [] + for (const sweep of ADMIN_SWEEPS) { + const names = await sweep.list(page) + for (const name of names) + await sweep.remove(page, name) + counts.push(`${sweep.label}: ${names.length}`) + } + + // Gebruikersgroepen live behind the odpc API, not this admin. + const apiCtx = await apiRequest.newContext({ storageState: adminState }) + try { + const groups = await listE2EUsergroupNames(apiCtx) + for (const naam of groups) + await deleteUsergroupByName(apiCtx, naam) + counts.push(`gebruikersgroepen: ${groups.length}`) + } + finally { + await apiCtx.dispose() + } + + // eslint-disable-next-line no-console + console.log(`[global-teardown] removed stray E2E data — ${counts.join(', ')}`) + } + catch { + // Non-fatal: the admin session may be absent (auth setup failed) or the + // environment unreachable. Leftovers, if any, are swept on the next run. + } + finally { + await browser.close() + } +} + +export default globalTeardown diff --git a/setup/paths.ts b/setup/paths.ts new file mode 100644 index 0000000..0f19bf3 --- /dev/null +++ b/setup/paths.ts @@ -0,0 +1,19 @@ +/** + * Storage-state files produced by `setup/auth.setup.ts` and consumed by the BDD + * `storageState` fixture (see bdd/fixture.ts). Logging in once per role and + * reusing the session avoids re-running the Keycloak TOTP flow for every + * scenario — which would otherwise fail, since parallel scenarios sharing one + * account submit the same one-time code and Keycloak rejects the reuse. + */ +export const adminState = '.auth/admin.json' +export const regularState = '.auth/regular.json' + +/** + * Session for the GPP-burgerportaal beheer (admin config) UI. The beheer app is + * a separate OIDC client (`odbp`) with a cookie-based session, so it needs its + * own storage state. The Stagehand-driven `@beheer` scenarios restore this into + * their (separate) browser context; the config snapshot/restore APIRequestContext + * uses it too. Produced by `setup/auth.setup.ts` piggybacking the admin SSO + * session (no extra TOTP). + */ +export const burgerportaalAdminState = '.auth/burgerportaal-admin.json' From ec3bab0c8b8a04feb062a9e0b6fa0239fac8f0ad Mon Sep 17 00:00:00 2001 From: xiduzo Date: Tue, 14 Jul 2026 13:42:20 +0200 Subject: [PATCH 04/62] docs: document e2e suite and remaining Plateau-4 work Co-Authored-By: Claude Opus 4.8 --- PLAN-plateau4-remaining.md | 80 +++++++++ README.md | 358 ++++++++++++++++++++++++++++++++++++- 2 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 PLAN-plateau4-remaining.md diff --git a/PLAN-plateau4-remaining.md b/PLAN-plateau4-remaining.md new file mode 100644 index 0000000..120b5e1 --- /dev/null +++ b/PLAN-plateau4-remaining.md @@ -0,0 +1,80 @@ +# Plateau-4 e2e — remaining testscripts (TS5–TS9, TS11) + +## FINAL STATUS (2026-07-14) + +| TS | Status | Where | +| ---------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| TS5 gebruikersgroepen | ✅ GREEN | `bdd/@gpp-app/gebruikersgroepen.feature` (Stagehand mutate, odpc-API verify/clean) | +| TS6 publicatie creëren | ⛔ PARKED `@blocked` | `bdd/@gpp-app/publicaties.feature` — no authorised gebruikersgroep membership (`/api/mijn-gebruikersgroepen` = `[]`) | +| TS7 publicatie wijzig/intrek | ⛔ PARKED `@blocked` | same file — same blocker | +| TS8 document beheer | ⛔ PARKED `@blocked` | `bdd/@publicatiebank/@admin/documenten.feature` — no Documents API configured | +| TS9 publicatie beheer | ✅ GREEN | `bdd/@publicatiebank/@admin/publicaties.feature` (Stagehand mutate via admin, admin-read verify) | +| TS11 zoeken/raadplegen | ✅ GREEN | `bdd/@burgerportaal/zoeken.feature` (deterministic; onderwerpen-browse + search-experience; ES search-hits not asserted) | + +Also fixed: TS1 `configuratie.feature` was flaking on the 30s default — added `@timeout:120000`. +Full suite: 37 passed + 3 skipped (the @blocked scenarios), `--workers=1`. tsc + eslint clean. +Parked scenarios are `@blocked` + `Before(@blocked → test.skip(reason))` so they show as _skipped with reason_, never faked green. + +--- + +## Original plan (below) + +Status of the suite before this work: **6 features green** — TS1 config, TS2 informatiecategorieën, +TS3 onderwerpen, TS4 organisaties, TS10 sitemap, gpp-app auth. + +Proven pattern (reuse everywhere): **Stagehand `act()` for UI mutations, deterministic reads for +verification, own-and-cleanup test data, tags `@anthropic @mode:serial @timeout:120000`, `--workers=1`.** + +## Live-verified environment facts (probed 2026-07-13) + +| Resource | API | Notes | +| ---------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `/publicaties` | **full CRUD** | minimal POST = `{officieleTitel, publisher(uuid), informatieCategorieen[], publicatiestatus}` → 201; DELETE → 204 | +| `/documenten` | **BLOCKED** | POST → 500 `RuntimeError: No documents API configured yet! Set up the global configuration.` — no DRC backend wired locally | +| `/organisaties` | POST, no DELETE | create self-added; cleanup via admin | +| `/onderwerpen` | GET-only | create via Django admin (TS3 helpers) | +| `/informatiecategorieen` | GET-only | 18 present | +| publicaties/documenten count | 0 / 0 | clean env — TS11 needs seeded data | + +### Environment blocker + +Documents cannot be created anywhere in this stack (API + gpp-app upload share the same unconfigured +Documents backend). Therefore: + +- **TS8 (document beheer)** — BLOCKED end-to-end. Park with a skipped feature + clear reason until a + Documents API is configured (admin → global configuration / services). +- **TS6/TS7 document-upload steps** — BLOCKED. Implement the publication-level flow only; omit/skip + the document sub-steps with a note. +- Everything publication-level and onderwerp-level is fully testable. + +## Order of work (dependency-sorted) + +1. **Foundation: publication API manager** — `login-helpers/publication.ts` (`OdrcClient`-backed create + - delete of publicaties, tracking) and a `publications` fixture mirroring `organisations`/`topics`. + Seeds a publisher org + picks an infocat. Serves TS9, TS11, and gpp-app verification. +2. **TS9 — publicatie beheer** (publicatiebank Django admin). Seed publicatie via API → edit + metadata/org/onderwerp/status via Stagehand admin → verify via API reads (fresh cookieless + `OdrcClient`; fall back to admin reads on the known 500) → delete via admin → API cleanup. + Admin changelist: `/admin/publications/publication/`. +3. **TS11 — zoeken/raadplegen (burger)** (burgerportaal, no auth). Seed a published publicatie + a + promoted onderwerp → full-text search via Stagehand → assert result visible, open it, read + metadata → onderwerpen page + homepage carousel + tellers. Document-search assertions omitted + (no documents). Reuses the existing burger Stagehand fixture (no admin session needed). +4. **TS6 — creëren publicatie** (gpp-app SPA + burger). Live-recon the SPA first. Stagehand create a + publication (titel/org/infocat/onderwerp, publiceren) → verify in app list + burgerportaal search + (reuse TS11 helpers). Concept→afmaken flow. Document upload steps skipped (blocked). Cleanup via API. +5. **TS7 — wijzigen/intrekken publicatie** (gpp-app SPA). Builds on TS6. Edit fields, intrekken, + verify gone in burger. "Claim collega-publicatie" needs a 2nd user in the same groep — implement + if available, else skip that one scenario with a note. Document sub-steps skipped. +6. **TS5 — gebruikersgroepen/autorisaties** (gpp-app). GATED: the "Gebruikersgroepen" button only + shows for an AD-beheerder. Live-recon whether the admin user has that role. If yes: create group, + authorise org/infocat/onderwerp, verify the authorisation constrains the publication form, edit, + delete. If no beheerder role → park with a skipped feature + reason. + +## Conventions to match + +- Feature dirs `@`-prefixed for tags-from-path; steps colocated. +- New fixtures in `bdd/fixture.ts`; env already has `odrc`. +- `global-teardown.ts` sweeps stray `E2E `-prefixed data — extend for publicaties. +- Every blocked/skipped scenario must `test.skip(...)` with a human reason (never silently pass). +- Update README + memory when done. diff --git a/README.md b/README.md index b032ef4..5d3e587 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,364 @@ Please use vscode and install the recommended extensions. To run the tests, you will need a `.env` file in the root of this repository. See `.env.example` for an example. -To run the tests, run the following commands: +These tests use [**playwright-bdd**](https://vitalets.github.io/playwright-bdd/): +Gherkin `.feature` files run through the Playwright test runner. `bddgen` +generates specs from the features into `.features-gen/` (git-ignored) before +each run — `npm test` does both. + +To run the tests, you will need a `.env` file in the root of this repository. +See `.env.example` for an example. ```sh npm install npx playwright install --with-deps -npm run test +npm run test # bddgen && playwright test +npm run test:ui # interactive UI mode +``` + +### Cross-browser + +The suite runs on **chromium, firefox and webkit**. Two features are scoped +because of local-environment limits (they still run fully on chromium): + +- **`@chromium-only`** — the gpp-app live Keycloak login redirects to + `keycloak.woo-search.local`, which resolves only in chromium via the + `--host-resolver-rules` launch arg (a Chrome-only flag; firefox/webkit ignore + it and cannot reach the host). See `bdd/@gpp-app/authentication.feature`. +- **`@no-webkit`** — the burgerportaal SPA never leaves its loading splash in + webkit on this stack (a webkit-specific boot failure; chromium and firefox + boot it fine). See `bdd/@burgerportaal/zoeken.feature`. + +Both are enforced by tagged `Before` hooks that `test.skip(...)` with a reason, +so the skips stay visible in the report rather than silently passing. SPA nav +that is merely slow to hydrate on non-chromium engines is handled by +`bdd/@gpp-app/support/hydrate.ts` (`waitForWithReload`, a reload-retry). + +## Requirements to run + +Setup and teardown use **only the UI and API** so the suite runs against any +environment (local, staging, production) — they never shell into a container. +One thing the UI/API genuinely cannot create must therefore be present already: + +- **`waardelijst` reference information categories.** The category API is + read-only (`GET` only) and the Django admin forces `oorsprong=zelf_toegevoegd` + on manual adds, so there is no UI/API path to create `waardelijst`-origin + categories. The target environment must ship them. Real environments already + do; to seed a fresh **local** stack, run the app's own fixture command inside + the ODRC (publicatiebank) container: + + ```sh + docker exec python src/manage.py \ + load_information_categories \ + src/woo_publications/fixtures/information_categories.json + ``` + + (`` is typically `gpp-woo-odrc-django-1`.) The command is + idempotent — fixed UUIDs, safe to re-run. + +Their presence is **verified during auth setup** +([`setup/auth.setup.ts`](./setup/auth.setup.ts) → +[`verifyWaardelijstPresent`](./bdd/@publicatiebank/support/information-category.ts)); if they +are missing the run fails immediately with a pointer back here, instead of +producing confusing scenario failures. + +### DiWoo sitemap + +The `@burgerportaal` sitemap scenarios read the public DiWoo sitemap +anonymously (robots.txt → sitemap index → one sitemap per month → `` +entries), exactly like the landelijke Woo-index harvester. They need: + +- **`GPP_BURGERPORTAAL_BASE_URL`** in `.env` (see `.env.example`). +- **The burgerportaal wired to ODRC.** The sitemap reads document data straight + from the ODRC API, so the burgerportaal must have `ODRC_BASE_URL` + + `ODRC_API_KEY` configured and that token must have API **read** authorization. + Without it the sitemap index degrades to empty and the per-month sitemap + `500`s. (In the local `deploy/` stack this is set on the `odbp` service; the + token needs the audit headers the burgerportaal already sends.) + +The scenarios validate the sitemap **structure** on any environment (they pass +against an empty sitemap) and validate document **metadata** for whatever +documents are present. The parts of testscript 10 that need populated, +published+indexed documents are **not** asserted automatically and remain +manual / future work, because they depend on prerequisites the tests cannot +provision portably: + +- **Published document content + metadata sampling** — needs documents created + through the GPP-app and pushed through the ODRC → index pipeline. +- **Change propagation** (create/modify/withdraw/delete → sitemap updates) and + the **1-minute cache override** the testscript calls out as a testomgeving + pre-set (sitemaps are cached ~24h by default). + +### Burgerportaal beheer (Testscript 1 — Stagehand + OpenRouter) + +The `@beheer` scenarios configure the burgerportaal (welkomsttekst, video, logo, +favicon, sfeerfoto, organisatie-URL, voettekst-links) through its **beheer** +interface and verify the result on the public site. They are driven by +[**Stagehand**](https://docs.stagehand.dev) — an AI browser-automation layer +whose `act()`/`extract()` operate the UI from natural-language intent — backed +by a model served through [**OpenRouter**](https://openrouter.ai). They need: + +- **`OPENROUTER_API_KEY`** in `.env` (get one at + ). **Without it every `@beheer` scenario skips**, + so the rest of the suite still runs. +- The **burgerportaal wired to ODRC** (same as the sitemap, above) and the + **beheer admin session** — established automatically by `setup/auth.setup.ts` + (it piggybacks the admin SSO session, so no extra TOTP) and saved to + `.auth/burgerportaal-admin.json`. + +**Model by tag.** Each scenario picks its model from its tags; anything untagged +falls back to a good-enough default. See +[`bdd/_core/stagehand.ts`](./bdd/_core/stagehand.ts). + +| Tag | OpenRouter model | +| --------------- | ------------------------------ | +| _(none)_ | `google/gemini-2.5-flash` | +| `@cheap-ai` | `google/gemini-2.5-flash-lite` | +| `@openai` | `openai/gpt-4.1` | +| `@anthropic` | `anthropic/claude-sonnet-4.5` | +| `@expensive-ai` | `anthropic/claude-opus-4.1` | + +Run them: `npm run test:beheer` (add `--no-deps` to skip auth setup; set +`HEADED=1` to watch the AI browser). + +**How it stays portable + test-owned.** Only the UI _mutations_ go through the +AI (`act()`); assertions are deterministic against the public site — the config +API (`/api/environment/resources`), the rendered DOM (one `extract()`), and raw +image bytes. The `beheer` fixture ([`bdd/_core/fixture.ts`](./bdd/_core/fixture.ts)) +snapshots the full config before each scenario and restores it afterwards, so +runs — including against shared/production environments — leave the portal +exactly as found. Image _replacement_ uses the authenticated upload API (the +same endpoint the beheer UI calls), because the local Stagehand browser is +CDP-based and cannot drive an OS file-picker dialog. + +### Metadata beheer (Testscripts 3 & 4 — Stagehand + deterministic admin reads) + +The `@publicatiebank/@admin` **organisaties** (Testscript 4) and **onderwerpen** +(Testscript 3) scenarios follow the same split as `@beheer`: an AI agent +(**Stagehand** + an OpenRouter model) drives the Dutch Django-admin UI from +natural-language intent for every _mutation_ (add / activate / promote / rename +/ edit / delete / search) — no hand-written selectors for the action under test. +Assertions are made **deterministically by reading the admin back through the +ordinary Playwright `page`** — a _separate_, session-authenticated browser whose +reads are stable (unlike the token API, see the flake note below). They need: + +- **`OPENROUTER_API_KEY`** — **without it every organisatie/onderwerp scenario + skips** (a `Before` hook on `@anthropic`), like `@beheer`. + +The [`OdrcClient`](./bdd/@publicatiebank/support/odrc.ts) (token + `Audit-*` headers, +`ODRC_BASE_URL`/`ODRC_API_KEY` from `.env`) is **not** used to verify these two +features — see the flake note — but is the foundation for the upcoming +publicatie/document scenarios, whose resources expose full create/delete. + +**Carve-outs, honestly.** Reality forced two: + +- **Onderwerp afbeelding upload.** The add form requires an image and a CDP + browser cannot drive an OS file-picker, so the file bytes are set straight on + the `` (same carve-out `@beheer` makes for images); every + other field is still driven by Stagehand. +- **Cleanup is deterministic.** The organisatie API has no `DELETE` and the + onderwerp API is `GET`-only, so the `organisations`/`topics` fixtures + ([`bdd/_core/fixture.ts`](./bdd/_core/fixture.ts)) delete their rows through the admin, and + `globalTeardown` sweeps stray `E2E `-prefixed rows of both kinds. + +**Known server flake (worked around).** The publicatiebank token authenticates +as a _user-less_ token, and woo-publications' `SessionProfileMiddleware` +dereferences a `None` user (500 `AttributeError`) whenever a token API request +runs while an admin session is active on the _same_ server — exactly the case +here, because Stagehand is driving that admin. So these two features **verify by +reading the admin back through the ordinary session-authenticated `page`** +(reads authenticate as a real user, so they never hit the `None`-user path) +rather than through the token API. `@mode:serial` (and running these +`--workers=1`) keeps concurrent load down. This is a server-side bug, not a test +bug; reading the admin keeps verification deterministic regardless. The same +lesson applies to **Testscript 9** below: under a real run the token API 500s +_persistently_ while Stagehand drives the admin, so publicaties are seeded, +verified and cleaned up through the admin UI too — never the token API. + +### Publicatie beheer (Testscript 9 — Stagehand + admin reads) + +`@publicatiebank/@admin` **publicaties** (`publicaties.feature`) mirror +Testscripts 3 & 4: Stagehand drives the Django-admin mutations (edit omschrijving, +rename, withdraw, delete, search), verified by reading the admin back through the +ordinary `page`. Publicaties are seeded through the admin add form (a `concept` +needs only a titel; the withdraw scenario seeds a `gepubliceerd` one — publisher +is a raw-id field filled with the org's admin PK, informatiecategorie a select2) +and cleaned up through the admin ([`bdd/@publicatiebank/support/publication.ts`](./bdd/@publicatiebank/support/publication.ts), +`publications` fixture). Needs `OPENROUTER_API_KEY` (else skips). + +### Gebruikersgroepen (Testscript 5 — Stagehand + odpc API) + +`@gpp-app` **gebruikersgroepen** (`gebruikersgroepen.feature`): Stagehand drives +the GPP-app SPA (create / rename / delete a group); verification and cleanup go +through the odpc JSON API `/api/gebruikersgroepen` ([`bdd/@gpp-app/support/usergroup.ts`](./bdd/@gpp-app/support/usergroup.ts), +`usergroups` fixture). The GPP-app is cookie-authenticated, so `adminStagehand`'s +`adminState` cookies sign it in; the SPA hydrates routes only via click-navigation +from the app root (deep-links render nothing). The admin account carries the +AD-beheerder role. Needs `OPENROUTER_API_KEY` (else skips). + +### Burger zoeken en raadplegen (Testscript 11 — deterministic, public) + +`@burgerportaal` **zoeken** (`zoeken.feature`) is read-only against the public +portal, so it runs deterministically through the ordinary `page` (no Stagehand / +no OpenRouter). It seeds a promoted, published onderwerp (served live from the +publicatiebank, browsable in seconds) and asserts onderwerpen-browsing plus the +search _experience_ (the `/zoeken` results page opens). + +### Parked (environment-blocked) — Testscripts 6, 7, 8 + +These are present as `@blocked` features that **skip with a reason** (visible in +the report — never faked green): + +- **Testscript 8** (document beheer, `documenten.feature`) — this stack has **no + Documents API configured** (`/documenten` POST → 500 "No documents API + configured yet"), so no document can exist to manage. +- **Testscripts 6 & 7** (gpp-app publicatie create / wijzig / intrek, + `@gpp-app/publicaties.feature`) — no available account is a member of an + **authorised gebruikersgroep** (`/api/mijn-gebruikersgroepen` = `[]`), so the + GPP-app "Nieuwe publicatie" form errors and renders nothing to drive. (All + backing API calls return 200; the blocker is missing membership, not a server + error.) Unblock by giving a test account membership of a group authorised for an + organisatie + informatiecategorie, then implement the flow (documents remain + blocked by the Documents-API gap above). + +Search _hit_ indexing and the homepage counters on the burgerportaal are likewise +not asserted anywhere: they come from woo-search's Elasticsearch, whose +harvest/index pipeline is a documented environment prerequisite not active here +(see the sitemap note). + +## Test data ownership + +Apart from the `waardelijst` prerequisite above, the suite makes and cleans up +all the data it needs — through the UI/API, portable across environments. + +- **Self-added categories** — each scenario that needs one creates it through + the Django admin via the `categories` fixture ([`bdd/_core/fixture.ts`](./bdd/_core/fixture.ts)), + which deletes everything it created in teardown. Names are unique per worker, + so parallel scenarios don't interfere. +- **Self-added organisaties & onderwerpen** — the `organisations` / `topics` + fixtures ([`bdd/_core/fixture.ts`](./bdd/_core/fixture.ts)) create rows through the admin + and delete them in teardown (unique per worker). See the metadata-beheer + section above for why these are admin-owned rather than API-owned. +- **Safety net** — `globalTeardown` ([`setup/global-teardown.ts`](./setup/global-teardown.ts)) + drives the admin UI with the saved admin session to sweep any `E2E `-prefixed + categories, organisaties and onderwerpen left by a hard-killed run, so data + never accumulates. No container access needed. + +The API exposes full create/delete for `publicaties` and `documenten` (unlike +`organisaties`, which has no `DELETE`, and `onderwerpen`, which is `GET`-only), +so future publicatie/document features should own those directly via the +[`OdrcClient`](./bdd/@publicatiebank/support/odrc.ts) following the same create-then-clean-up +pattern. + +## Directory structure + +The code is organised **vertically**: each GPP-Woo bounded context is a folder +holding its features, steps _and_ its support code (page/API helpers), so code +that changes together lives together. Directories whose name starts with `@` +become [tags-from-path](https://vitalets.github.io/playwright-bdd/#/writing-features/tags-from-path), +so a feature's location determines its tags; folders **without** `@` (`_core/` +and each vertical's `support/`) are plain modules, not tags. + +``` +bdd/ +├── _core/ # test harness + cross-cutting primitives (not a tag) +│ ├── fixture.ts # createBdd() + tag-driven storageState + signIn + resource managers +│ ├── types.ts # zod-parsed ENV (apps + users + odrc) +│ ├── resource-manager.ts # makeResourceManager() — shared own-and-cleanup lifecycle +│ ├── signIn.ts # app-dispatching Keycloak sign-in +│ ├── keycloak.ts # Keycloak login form + TOTP + SSO-or-redirect race +│ └── stagehand.ts # Stagehand/OpenRouter wiring + stagehandPage()/settle() +├── @gpp-app/ # tag: @gpp-app +│ ├── support/ # login.ts · usergroup.ts (odpc API) · hydrate.ts +│ ├── authentication.feature +│ ├── steps.ts +│ ├── gebruikersgroepen.feature # Testscript 5 — Stagehand + odpc API +│ ├── gebruikersgroepen-steps.ts +│ ├── publicaties.feature # Testscripts 6 & 7 — PARKED (@blocked) +│ └── publicaties-steps.ts +├── @burgerportaal/ # tag: @burgerportaal (public, signed-out) +│ ├── support/ # login.ts · beheer-config.ts · sitemap.ts +│ ├── sitemap.feature +│ ├── steps.ts +│ ├── zoeken.feature # Testscript 11 — deterministic (no AI) +│ ├── zoeken-steps.ts +│ └── @beheer/ # tag: @beheer (Stagehand + OpenRouter config UI) +│ ├── configuratie.feature +│ ├── steps.ts +│ └── fixtures/ # test images (logo.svg, favicon.png, sfeerfoto.png) +└── @publicatiebank/ # tag: @publicatiebank + ├── support/ # admin-resource.ts · admin-driver.ts · odrc.ts · poll.ts + │ # login.ts · information-category/organisation/topic/publication.ts + ├── steps.ts + └── @admin/ # tag: @admin (in addition to @publicatiebank) + ├── informatiecategorieen.feature # deterministic Playwright (no AI) + ├── organisaties.feature # Testscript 4 — Stagehand + admin reads + ├── onderwerpen.feature # Testscript 3 — Stagehand + admin reads + ├── publicaties.feature # Testscript 9 — Stagehand + admin reads + ├── documenten.feature # Testscript 8 — PARKED (@blocked) + ├── steps.ts # organisatie steps + ├── onderwerpen-steps.ts # onderwerp steps + ├── publicaties-steps.ts # publicatie steps + ├── documenten-steps.ts # parked-skip steps + └── fixtures/ # onderwerp.png (topic afbeelding) + +setup/ # kept out of ./bdd so bddgen ignores it +├── auth.setup.ts # signs in once per role -> .auth/*.json; verifies waardelijst +├── paths.ts # storage-state file paths +└── global-teardown.ts # sweeps leftover E2E test data (admin UI + odpc API) +``` + +A vertical's `support/` code is meant to be imported by that vertical's steps +(and by the composition roots — `_core/fixture.ts`, `_core/signIn.ts`, +`setup/`). `_core/` holds only things genuinely shared across verticals +(Keycloak, Stagehand plumbing, the ENV parser, the fixture graph); it never +imports _from_ a vertical except in those composition roots. + +## Authentication by tag + +Sign-in is Keycloak OIDC (+ TOTP). To avoid re-running TOTP for every scenario — +which fails when parallel scenarios share one account and submit the same +one-time code — the `setup` project ([`setup/auth.setup.ts`](./setup/auth.setup.ts)) +signs in **once per role** and saves the session to `.auth/*.json`. It runs as a +project dependency before the tests. + +**The scenario's tags then select which saved session to restore** (in +[`bdd/_core/fixture.ts`](./bdd/_core/fixture.ts), via the `storageState` fixture keyed on +playwright-bdd's `$tags`): + +| Tag | Restored session | +| ----------- | --------------------- | +| `@admin` | `ADMIN_*` (both apps) | +| `@regular` | `DEFAULT_*` (GPP-app) | +| _(neither)_ | none — signed out | + +The `@beheer` scenarios are a special case: their Playwright `page` is +signed-out, but Stagehand's own browser gets the saved +`.auth/burgerportaal-admin.json` session injected as cookies (see +[`bdd/_core/stagehand.ts`](./bdd/_core/stagehand.ts)). + +Because `@admin` comes from the folder name, a feature placed in +`bdd/@publicatiebank/@admin/` runs as admin without any tag in the `.feature` +file. The `Given I am logged in to ...` steps then just navigate — the session +is already present. The GPP-app **authentication** feature is the exception: it +is untagged (no restored session) and signs in **live** through a step, so it +actually exercises the login flow. `ENV.users` lives in +[`bdd/_core/types.ts`](./bdd/_core/types.ts). + +```gherkin +Feature: Informatiecategorieën management + Background: + Given I am logged in to the publicatiebank admin # @admin (from path) -> admin user + And I open the "Informatiecategorieën" metadata page +``` + +Run a subset by tag: + +```sh +npm run test:admin # bddgen && playwright test --grep @admin +npm run test:gpp-app +npm run test:publicatiebank +npm run test:burgerportaal # public sitemap; add --no-deps to skip auth setup +npm run test:beheer # Stagehand config UI (needs OPENROUTER_API_KEY) +npx playwright test --grep "@publicatiebank and not @admin" ``` From 9900fadb1d5a49232ce1a589f4fe1dfcc353db22 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Tue, 14 Jul 2026 13:55:16 +0200 Subject: [PATCH 05/62] refactor(e2e): promote admin page-objects to playwright-bdd fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @admin steps each rebuilt an adminDriver locally (`function org(sh){…}`) and threaded `adminStagehand` through every step. Promote the three nouns to `orgAdmin`/`topicAdmin`/`pubAdmin` fixtures so steps receive a ready-built driver by destructuring, per the playwright-bdd add-fixtures pattern. Noun→URL configs move to support/admin-uis.ts. Step text unchanged; typecheck + lint clean; bddgen 11 features → 11 specs. Co-Authored-By: Claude Opus 4.8 --- .../@admin/onderwerpen-steps.ts | 61 ++++++++----------- .../@admin/publicaties-steps.ts | 56 +++++++---------- bdd/@publicatiebank/@admin/steps.ts | 59 +++++++----------- bdd/@publicatiebank/support/admin-uis.ts | 29 +++++++++ bdd/_core/fixture.ts | 25 ++++++++ 5 files changed, 123 insertions(+), 107 deletions(-) create mode 100644 bdd/@publicatiebank/support/admin-uis.ts diff --git a/bdd/@publicatiebank/@admin/onderwerpen-steps.ts b/bdd/@publicatiebank/@admin/onderwerpen-steps.ts index bdd0128..0014da6 100644 --- a/bdd/@publicatiebank/@admin/onderwerpen-steps.ts +++ b/bdd/@publicatiebank/@admin/onderwerpen-steps.ts @@ -1,14 +1,12 @@ -import type { Stagehand } from '@browserbasehq/stagehand' -import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' import { TOPIC_IMAGE, topicExists, topicIsPromoted, topicOmschrijving } from '@/bdd/@publicatiebank/support/topic' -import { ENV } from '@/bdd/_core/types' import { expect } from '@playwright/test' import { Given, Then, When } from '../../_core/fixture' /** * Testscript 3 (onderwerpen) steps. UI *mutations* run through the Django admin - * via Stagehand `act()` behind the shared {@link adminDriver} seam — except the - * mandatory afbeelding on the add form, whose bytes are set straight on the + * via Stagehand `act()` behind the `topicAdmin` fixture (a ready-built + * {@link AdminDriver} bound to `adminStagehand`) — except the mandatory + * afbeelding on the add form, whose bytes are set straight on the * (a CDP browser cannot drive an OS file-picker). Assertions * are made *deterministically* by reading the admin back through the ordinary * Playwright `page` (session-authenticated, stable), because the token API is @@ -19,17 +17,10 @@ import { Given, Then, When } from '../../_core/fixture' * with the organisatie steps (see steps.ts). */ -const pub = ENV.apps.publicatiebank.replace(/\/$/, '') -const TOPIC_CHANGELIST = `${pub}/admin/publications/topic/` -const TOPIC_ADD = `${pub}/admin/publications/topic/add/` const READ = { timeout: 10_000, intervals: [400, 800, 1500] } -function onderwerp(stagehand: Stagehand) { - return adminDriver(stagehand, { noun: 'onderwerp', changelist: TOPIC_CHANGELIST, add: TOPIC_ADD }) -} - -Given('the publicatiebank onderwerp admin is open', async ({ adminStagehand }) => { - await onderwerp(adminStagehand).openList() +Given('the publicatiebank onderwerp admin is open', async ({ topicAdmin }) => { + await topicAdmin.openList() }) // Prerequisite (deterministic, not the action under test). @@ -39,15 +30,14 @@ Given('an onderwerp', async ({ topics }) => { // --- Add (Stagehand for fields; image via setInputFiles carve-out) ---------- -When('I add an onderwerp through the admin', async ({ adminStagehand, topics }) => { +When('I add an onderwerp through the admin', async ({ topicAdmin, topics }) => { const titel = topics.freshName() - const d = onderwerp(adminStagehand) - await d.openAdd() + await topicAdmin.openAdd() // File carve-out: set the required afbeelding bytes directly on the input. - await d.page.locator('#id_afbeelding').setInputFiles(TOPIC_IMAGE) - await d.act(`Fill the "Officiële titel" field with: ${titel}`) - await d.act('Select "Concept" as the publicatiestatus') - await d.save() + await topicAdmin.page.locator('#id_afbeelding').setInputFiles(TOPIC_IMAGE) + await topicAdmin.act(`Fill the "Officiële titel" field with: ${titel}`) + await topicAdmin.act('Select "Concept" as the publicatiestatus') + await topicAdmin.save() topics.track(titel) }) @@ -58,11 +48,10 @@ Then('the onderwerp exists in the API', async ({ page, topics }) => { // --- Promote --------------------------------------------------------------- -When('I tick the {string} checkbox and save the onderwerp', async ({ adminStagehand, topics }, label: string) => { - const d = onderwerp(adminStagehand) - await d.open(topics.last()) - await d.act(`Tick the "${label}" checkbox`) - await d.save() +When('I tick the {string} checkbox and save the onderwerp', async ({ topicAdmin, topics }, label: string) => { + await topicAdmin.open(topics.last()) + await topicAdmin.act(`Tick the "${label}" checkbox`) + await topicAdmin.save() }) Then('the onderwerp is promoted in the API', async ({ page, topics }) => { @@ -72,13 +61,12 @@ Then('the onderwerp is promoted in the API', async ({ page, topics }) => { // --- Edit omschrijving ----------------------------------------------------- -When('I change the onderwerp omschrijving and save it', async ({ adminStagehand, topics, scratch }) => { +When('I change the onderwerp omschrijving and save it', async ({ topicAdmin, topics, scratch }) => { const omschrijving = `E2E gewijzigde omschrijving ${Date.now()}` scratch.set('topic:omschrijving', omschrijving) - const d = onderwerp(adminStagehand) - await d.open(topics.last()) - await d.act(`Replace the contents of the "Omschrijving" field with: ${omschrijving}`) - await d.save() + await topicAdmin.open(topics.last()) + await topicAdmin.act(`Replace the contents of the "Omschrijving" field with: ${omschrijving}`) + await topicAdmin.save() }) Then('the onderwerp has the new omschrijving in the API', async ({ page, topics, scratch }) => { @@ -89,10 +77,9 @@ Then('the onderwerp has the new omschrijving in the API', async ({ page, topics, // --- Delete ---------------------------------------------------------------- -When('I delete the onderwerp through the admin', async ({ adminStagehand, topics }) => { - const d = onderwerp(adminStagehand) - await d.open(topics.last()) - await d.removeCurrent() +When('I delete the onderwerp through the admin', async ({ topicAdmin, topics }) => { + await topicAdmin.open(topics.last()) + await topicAdmin.removeCurrent() }) Then('the onderwerp no longer exists in the API', async ({ page, topics }) => { @@ -102,8 +89,8 @@ Then('the onderwerp no longer exists in the API', async ({ page, topics }) => { // --- Search (UI read under test) ------------------------------------------- -When('I search the admin for the onderwerp', async ({ adminStagehand, topics }) => { - await onderwerp(adminStagehand).search(topics.last()) +When('I search the admin for the onderwerp', async ({ topicAdmin, topics }) => { + await topicAdmin.search(topics.last()) }) Then('the onderwerp is shown in the admin results', async ({ page, topics }) => { diff --git a/bdd/@publicatiebank/@admin/publicaties-steps.ts b/bdd/@publicatiebank/@admin/publicaties-steps.ts index 64d470b..182ad3b 100644 --- a/bdd/@publicatiebank/@admin/publicaties-steps.ts +++ b/bdd/@publicatiebank/@admin/publicaties-steps.ts @@ -1,35 +1,27 @@ -import type { Stagehand } from '@browserbasehq/stagehand' -import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' import { addPublishedPublication, publicationExistsAdmin, publicationOmschrijvingAdmin, publicationStatusAdmin, } from '@/bdd/@publicatiebank/support/publication' -import { ENV } from '@/bdd/_core/types' import { expect } from '@playwright/test' import { Given, Then, When } from '../../_core/fixture' /** * Testscript 9 (publicatie beheer) steps. Publicaties are seeded and cleaned up * over the admin by the `publications` fixture; the beheer *mutations* run - * through the Django admin via Stagehand `act()` behind the shared - * {@link adminDriver} seam, and assertions read the admin back through the - * ordinary session-authenticated `page` (stable, unlike the token API while - * Stagehand drives the same server — see README "Known server flake"). + * through the Django admin via Stagehand `act()` behind the `pubAdmin` fixture + * (a ready-built {@link AdminDriver} bound to `adminStagehand`), and assertions + * read the admin back through the ordinary session-authenticated `page` (stable, + * unlike the token API while Stagehand drives the same server — see README + * "Known server flake"). * * The `@anthropic` skip guard (no OpenRouter key → skip) and the `adminStagehand` * fixture are shared with the organisatie/onderwerp steps (see steps.ts). */ -const pub = ENV.apps.publicatiebank.replace(/\/$/, '') -const PUB_CHANGELIST = `${pub}/admin/publications/publication/` const READ = { timeout: 10_000, intervals: [400, 800, 1500] } -function publicatie(stagehand: Stagehand) { - return adminDriver(stagehand, { noun: 'publicatie', changelist: PUB_CHANGELIST, rowLink: 'titel link' }) -} - // Prerequisites (deterministic, not the action under test), seeded through the // admin add form — the token API is unusable while Stagehand drives the admin. Given('a concept publicatie', async ({ publications }) => { @@ -48,13 +40,12 @@ Given('a published publicatie', async ({ page, publications, organisations }) => // --- Edit omschrijving ------------------------------------------------------ -When('I change the publicatie omschrijving through the admin', async ({ adminStagehand, publications, scratch }) => { +When('I change the publicatie omschrijving through the admin', async ({ pubAdmin, publications, scratch }) => { const omschrijving = `E2E gewijzigde omschrijving ${Date.now()}` scratch.set('pub:omschrijving', omschrijving) - const d = publicatie(adminStagehand) - await d.open(publications.last()) - await d.act(`Replace the contents of the "Omschrijving" field with: ${omschrijving}`) - await d.save() + await pubAdmin.open(publications.last()) + await pubAdmin.act(`Replace the contents of the "Omschrijving" field with: ${omschrijving}`) + await pubAdmin.save() }) Then('the publicatie has the new omschrijving', async ({ page, publications, scratch }) => { @@ -65,14 +56,13 @@ Then('the publicatie has the new omschrijving', async ({ page, publications, scr // --- Rename ----------------------------------------------------------------- -When('I rename the publicatie through the admin', async ({ adminStagehand, publications, scratch }) => { +When('I rename the publicatie through the admin', async ({ pubAdmin, publications, scratch }) => { const oldTitel = publications.last() const newTitel = `${publications.freshName()} hernoemd` scratch.set('pub:oldTitel', oldTitel) - const d = publicatie(adminStagehand) - await d.open(oldTitel) - await d.act(`Replace the contents of the "Officiële titel" field with: ${newTitel}`) - await d.save() + await pubAdmin.open(oldTitel) + await pubAdmin.act(`Replace the contents of the "Officiële titel" field with: ${newTitel}`) + await pubAdmin.save() // Track the new titel so cleanup deletes the renamed publicatie too. publications.track(newTitel) }) @@ -86,11 +76,10 @@ Then('the publicatie is known under its new titel and not the old one', async ({ // --- Withdraw (intrekken) --------------------------------------------------- -When('I set the publicatiestatus to {string} and save the publicatie', async ({ adminStagehand, publications }, label: string) => { - const d = publicatie(adminStagehand) - await d.open(publications.last()) - await d.act(`Select "${label}" as the publicatiestatus`) - await d.save() +When('I set the publicatiestatus to {string} and save the publicatie', async ({ pubAdmin, publications }, label: string) => { + await pubAdmin.open(publications.last()) + await pubAdmin.act(`Select "${label}" as the publicatiestatus`) + await pubAdmin.save() }) Then('the publicatie has status {string}', async ({ page, publications }, status: string) => { @@ -100,10 +89,9 @@ Then('the publicatie has status {string}', async ({ page, publications }, status // --- Delete ----------------------------------------------------------------- -When('I delete the publicatie through the admin', async ({ adminStagehand, publications }) => { - const d = publicatie(adminStagehand) - await d.open(publications.last()) - await d.removeCurrent() +When('I delete the publicatie through the admin', async ({ pubAdmin, publications }) => { + await pubAdmin.open(publications.last()) + await pubAdmin.removeCurrent() }) Then('the publicatie no longer exists', async ({ page, publications }) => { @@ -113,8 +101,8 @@ Then('the publicatie no longer exists', async ({ page, publications }) => { // --- Search (UI read under test) -------------------------------------------- -When('I search the admin for the publicatie', async ({ adminStagehand, publications }) => { - await publicatie(adminStagehand).search(publications.last()) +When('I search the admin for the publicatie', async ({ pubAdmin, publications }) => { + await pubAdmin.search(publications.last()) }) Then('the publicatie is shown in the admin results', async ({ page, publications }) => { diff --git a/bdd/@publicatiebank/@admin/steps.ts b/bdd/@publicatiebank/@admin/steps.ts index 830d2e2..4fc063a 100644 --- a/bdd/@publicatiebank/@admin/steps.ts +++ b/bdd/@publicatiebank/@admin/steps.ts @@ -1,16 +1,14 @@ -import type { Stagehand } from '@browserbasehq/stagehand' -import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' import { organisationExists, organisationIsActive } from '@/bdd/@publicatiebank/support/organisation' import { hasOpenRouterKey } from '@/bdd/_core/stagehand' -import { ENV } from '@/bdd/_core/types' import { expect } from '@playwright/test' import { Before, Given, test, Then, When } from '../../_core/fixture' /** * Testscript 4 (organisaties) steps. UI *mutations* run through the Django admin * via Stagehand `act()` on Dutch labels — no hand-written selectors — behind the - * shared {@link adminDriver} seam. Assertions are made *deterministically* by - * reading the admin back through the ordinary Playwright `page` (a separate, + * `orgAdmin` fixture (a ready-built {@link AdminDriver} bound to `adminStagehand`; + * see _core/fixture.ts). Assertions are made *deterministically* by reading the + * admin back through the ordinary Playwright `page` (a separate, * session-authenticated browser): the token API is unreliable while Stagehand * drives the same server (see README "Known server flake"), whereas admin reads * authenticate as a real user and are stable. @@ -19,23 +17,16 @@ import { Before, Given, test, Then, When } from '../../_core/fixture' * renaming (naam is visible in the changelist) and cleanup is admin-driven. */ -const pub = ENV.apps.publicatiebank.replace(/\/$/, '') -const ORG_CHANGELIST = `${pub}/admin/metadata/organisation/` -const ORG_ADD = `${pub}/admin/metadata/organisation/add/` const READ = { timeout: 10_000, intervals: [400, 800, 1500] } -function org(stagehand: Stagehand) { - return adminDriver(stagehand, { noun: 'organisatie', changelist: ORG_CHANGELIST, add: ORG_ADD }) -} - // Skip the feature when no model key is configured (like @beheer), so the rest // of the suite stays green without OpenRouter. Before({ tags: '@anthropic' }, async () => { test.skip(!hasOpenRouterKey(), 'Set OPENROUTER_API_KEY to run the Stagehand @admin scenarios') }) -Given('the publicatiebank organisatie admin is open', async ({ adminStagehand }) => { - await org(adminStagehand).openList() +Given('the publicatiebank organisatie admin is open', async ({ orgAdmin }) => { + await orgAdmin.openList() }) // --- prerequisites (deterministic, not the action under test) -------------- @@ -50,13 +41,12 @@ Given('a self-added organisatie that is not active', async ({ organisations }) = // --- Add (mutation via Stagehand) ------------------------------------------ -When('I add a self-added organisatie through the admin', async ({ adminStagehand, organisations }) => { +When('I add a self-added organisatie through the admin', async ({ orgAdmin, organisations }) => { const naam = organisations.freshName() - const d = org(adminStagehand) - await d.openAdd() - await d.act(`Fill the "Naam" field with: ${naam}`) - await d.act('Make sure the "Is actief" checkbox is ticked') - await d.save() + await orgAdmin.openAdd() + await orgAdmin.act(`Fill the "Naam" field with: ${naam}`) + await orgAdmin.act('Make sure the "Is actief" checkbox is ticked') + await orgAdmin.save() organisations.track(naam) }) @@ -67,11 +57,10 @@ Then('the organisatie exists in the API and is active', async ({ page, organisat // --- Activate -------------------------------------------------------------- -When('I tick the {string} checkbox and save the organisatie', async ({ adminStagehand, organisations }, label: string) => { - const d = org(adminStagehand) - await d.open(organisations.last()) - await d.act(`Tick the "${label}" checkbox`) - await d.save() +When('I tick the {string} checkbox and save the organisatie', async ({ orgAdmin, organisations }, label: string) => { + await orgAdmin.open(organisations.last()) + await orgAdmin.act(`Tick the "${label}" checkbox`) + await orgAdmin.save() }) Then('the organisatie is active in the API', async ({ page, organisations }) => { @@ -81,14 +70,13 @@ Then('the organisatie is active in the API', async ({ page, organisations }) => // --- Rename (edit) --------------------------------------------------------- -When('I rename the organisatie and save it', async ({ adminStagehand, organisations, scratch }) => { +When('I rename the organisatie and save it', async ({ orgAdmin, organisations, scratch }) => { const oldName = organisations.last() const newName = `${organisations.freshName()} hernoemd` scratch.set('org:oldName', oldName) - const d = org(adminStagehand) - await d.open(oldName) - await d.act(`Replace the contents of the "Naam" field with: ${newName}`) - await d.save() + await orgAdmin.open(oldName) + await orgAdmin.act(`Replace the contents of the "Naam" field with: ${newName}`) + await orgAdmin.save() // Track the new name so teardown deletes the renamed row too. organisations.track(newName) }) @@ -102,10 +90,9 @@ Then('the API knows the organisatie under its new name and not the old one', asy // --- Delete ---------------------------------------------------------------- -When('I delete the organisatie through the admin', async ({ adminStagehand, organisations }) => { - const d = org(adminStagehand) - await d.open(organisations.last()) - await d.removeCurrent() +When('I delete the organisatie through the admin', async ({ orgAdmin, organisations }) => { + await orgAdmin.open(organisations.last()) + await orgAdmin.removeCurrent() }) Then('the organisatie no longer exists in the API', async ({ page, organisations }) => { @@ -115,8 +102,8 @@ Then('the organisatie no longer exists in the API', async ({ page, organisations // --- Search (UI read under test) ------------------------------------------- -When('I search the admin for the self-added organisatie', async ({ adminStagehand, organisations }) => { - await org(adminStagehand).search(organisations.last()) +When('I search the admin for the self-added organisatie', async ({ orgAdmin, organisations }) => { + await orgAdmin.search(organisations.last()) }) Then('the self-added organisatie is shown in the admin results', async ({ page, organisations }) => { diff --git a/bdd/@publicatiebank/support/admin-uis.ts b/bdd/@publicatiebank/support/admin-uis.ts new file mode 100644 index 0000000..211e46a --- /dev/null +++ b/bdd/@publicatiebank/support/admin-uis.ts @@ -0,0 +1,29 @@ +import type { AdminDriverConfig } from './admin-driver' +import { ENV } from '@/bdd/_core/types' + +/** + * Django-admin driver configs — one per publicatiebank noun the @admin + * scenarios mutate. Kept here (not in the step files) so the `orgAdmin` / + * `topicAdmin` / `pubAdmin` fixtures in `_core/fixture.ts` are the single place + * a step receives a ready-built {@link AdminDriver}, instead of every step + * re-deriving the changelist URL and re-constructing the driver. + */ +const pub = ENV.apps.publicatiebank.replace(/\/$/, '') + +export const ORGANISATION_ADMIN: AdminDriverConfig = { + noun: 'organisatie', + changelist: `${pub}/admin/metadata/organisation/`, + add: `${pub}/admin/metadata/organisation/add/`, +} + +export const TOPIC_ADMIN: AdminDriverConfig = { + noun: 'onderwerp', + changelist: `${pub}/admin/publications/topic/`, + add: `${pub}/admin/publications/topic/add/`, +} + +export const PUBLICATION_ADMIN: AdminDriverConfig = { + noun: 'publicatie', + changelist: `${pub}/admin/publications/publication/`, + rowLink: 'titel link', +} diff --git a/bdd/_core/fixture.ts b/bdd/_core/fixture.ts index fb6a60e..6d7062f 100644 --- a/bdd/_core/fixture.ts +++ b/bdd/_core/fixture.ts @@ -1,8 +1,11 @@ import type { ConfigSnapshot } from '@/bdd/@burgerportaal/support/beheer-config' +import type { AdminDriver } from '@/bdd/@publicatiebank/support/admin-driver' import type { Stagehand } from '@browserbasehq/stagehand' import type { AppName, User } from './types' import { BeheerConfigClient } from '@/bdd/@burgerportaal/support/beheer-config' import { deleteUsergroupByName, usergroupExists } from '@/bdd/@gpp-app/support/usergroup' +import { adminDriver } from '@/bdd/@publicatiebank/support/admin-driver' +import { ORGANISATION_ADMIN, PUBLICATION_ADMIN, TOPIC_ADMIN } from '@/bdd/@publicatiebank/support/admin-uis' import { addSelfAddedCategory, deleteCategoryByName } from '@/bdd/@publicatiebank/support/information-category' import { OdrcClient } from '@/bdd/@publicatiebank/support/odrc' import { addSelfAddedOrganisation, deleteOrganisationByName } from '@/bdd/@publicatiebank/support/organisation' @@ -167,6 +170,16 @@ interface BddFixtures { * from natural language. Model chosen from the scenario tags; closed in teardown. */ adminStagehand: Stagehand + /** + * Django-admin CRUD driver for organisaties, bound to `adminStagehand`. + * Steps receive it ready-built (`async ({ orgAdmin }) => …`) instead of + * reconstructing the driver each step. See {@link AdminDriver}. + */ + orgAdmin: AdminDriver + /** Django-admin CRUD driver for onderwerpen, bound to `adminStagehand`. */ + topicAdmin: AdminDriver + /** Django-admin CRUD driver for publicaties, bound to `adminStagehand`. */ + pubAdmin: AdminDriver /** * Authenticated burgerportaal beheer config client. Snapshots the config on * setup and restores it in teardown, so a scenario owns (and cleans up) every @@ -320,6 +333,18 @@ export const test = base.extend({ await use(stagehand) await stagehand.close() }, + // Page-object drivers bound to the shared adminStagehand browser. Lazy: only + // the noun a scenario destructures is built, and all three share one browser + // if a scenario needs more than one. + orgAdmin: async ({ adminStagehand }, use) => { + await use(adminDriver(adminStagehand, ORGANISATION_ADMIN)) + }, + topicAdmin: async ({ adminStagehand }, use) => { + await use(adminDriver(adminStagehand, TOPIC_ADMIN)) + }, + pubAdmin: async ({ adminStagehand }, use) => { + await use(adminDriver(adminStagehand, PUBLICATION_ADMIN)) + }, // Depends on no other fixtures; builds its own authenticated API context. // eslint-disable-next-line no-empty-pattern beheer: async ({}, use) => { From 5d5926f3f36765a92d25a5d2488a37348195f896 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Wed, 15 Jul 2026 14:24:46 +0200 Subject: [PATCH 06/62] fix(e2e): correct TOPIC_IMAGE path + enable aiFix report prompts The TOPIC_IMAGE path doubled its prefix after the vertical restructure moved topic.ts into support/ (ENOENT at runtime). Also enable playwright-bdd's aiFix promptAttachment so failing tests carry a ready-to-paste fix prompt in the report. Co-Authored-By: Claude Opus 4.8 --- bdd/@publicatiebank/support/topic.ts | 2 +- playwright.config.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bdd/@publicatiebank/support/topic.ts b/bdd/@publicatiebank/support/topic.ts index 2676b31..c397a44 100644 --- a/bdd/@publicatiebank/support/topic.ts +++ b/bdd/@publicatiebank/support/topic.ts @@ -9,7 +9,7 @@ const resource = adminResource({ }) /** A small (≤600×600) PNG the add form accepts as the mandatory afbeelding. */ -export const TOPIC_IMAGE = path.join(__dirname, '..', 'bdd', '@publicatiebank', '@admin', 'fixtures', 'onderwerp.png') +export const TOPIC_IMAGE = path.join(__dirname, '..', '@admin', 'fixtures', 'onderwerp.png') /** * Create an onderwerp (Topic) through the Django admin. Onderwerpen are diff --git a/playwright.config.ts b/playwright.config.ts index aeeea6a..dac34f4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,6 +18,12 @@ dotenv.config({ path: path.resolve(__dirname, '.env') }) */ const testDir = defineBddConfig({ featuresRoot: './bdd', + /* Attach a ready-to-paste "Fix with AI" prompt (failing step, error, snippet) + to every failing test in the HTML report. + https://vitalets.github.io/playwright-bdd/#/configuration/options?id=aifix */ + aiFix: { + promptAttachment: true, + }, }) /** From c5d66836c025977dfba68d196b9b6b285984f5fa Mon Sep 17 00:00:00 2001 From: xiduzo Date: Wed, 15 Jul 2026 14:27:38 +0200 Subject: [PATCH 07/62] test(e2e): implement TS6/7 publicatie + TS8 document beheer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblock the three parked eindgebruiker/document testscripts. TS6/7 (gpp-app publicatie create/withdraw): the blocker was data — no account was in an authorised gebruikersgroep, so /api/mijn-gebruikersgroepen was empty and "Nieuwe publicatie" errored. New authProfile fixture seeds the prerequisite over the odpc API (identity from /api/me); publicatie-ui.ts fills the form deterministically by input value=uuid (option-groups are custom widgets), Stagehand drives navigation + Publiceren/Intrekken. Verified via the deterministic publicatiestatus read through the publicatiebank admin. TS8 (document beheer): provision the Documenten API (setup/provision-documenten-api.sh wires the woo-publications DRC service + GlobalConfiguration; OpenZaak was already set up + also applies the token-auth fix live). document.ts seeds a published document over the token API (Host header via APIRequestContext so OpenZaak resolves the informatieobjecttype URL; upload URLs rewritten to localhost), docAdmin driver withdraws/deletes it, verified via the admin changelist. Needs --workers=1. Also: documents added to the global-teardown sweep; plan doc updated. Co-Authored-By: Claude Opus 4.8 --- PLAN-plateau4-remaining.md | 74 +++++++- bdd/@gpp-app/publicaties-steps.ts | 74 +++++--- bdd/@gpp-app/publicaties.feature | 40 ++--- bdd/@gpp-app/support/publicatie-ui.ts | 81 +++++++++ bdd/@gpp-app/support/usergroup.ts | 75 ++++++++ .../@admin/documenten-steps.ts | 60 +++++-- bdd/@publicatiebank/@admin/documenten.feature | 32 ++-- bdd/@publicatiebank/support/admin-uis.ts | 6 + bdd/@publicatiebank/support/document.ts | 166 ++++++++++++++++++ bdd/_core/fixture.ts | 88 +++++++++- setup/global-teardown.ts | 3 + setup/provision-documenten-api.sh | 82 +++++++++ 12 files changed, 702 insertions(+), 79 deletions(-) create mode 100644 bdd/@gpp-app/support/publicatie-ui.ts create mode 100644 bdd/@publicatiebank/support/document.ts create mode 100755 setup/provision-documenten-api.sh diff --git a/PLAN-plateau4-remaining.md b/PLAN-plateau4-remaining.md index 120b5e1..3f90668 100644 --- a/PLAN-plateau4-remaining.md +++ b/PLAN-plateau4-remaining.md @@ -5,16 +5,82 @@ | TS | Status | Where | | ---------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------ | | TS5 gebruikersgroepen | ✅ GREEN | `bdd/@gpp-app/gebruikersgroepen.feature` (Stagehand mutate, odpc-API verify/clean) | -| TS6 publicatie creëren | ⛔ PARKED `@blocked` | `bdd/@gpp-app/publicaties.feature` — no authorised gebruikersgroep membership (`/api/mijn-gebruikersgroepen` = `[]`) | -| TS7 publicatie wijzig/intrek | ⛔ PARKED `@blocked` | same file — same blocker | -| TS8 document beheer | ⛔ PARKED `@blocked` | `bdd/@publicatiebank/@admin/documenten.feature` — no Documents API configured | +| TS6 publicatie creëren | ✅ GREEN | `bdd/@gpp-app/publicaties.feature` — unblocked (see below) | +| TS7 publicatie intrekken | ✅ GREEN | same file — create-then-withdraw via the gpp-app SPA | +| TS8 document beheer | ✅ GREEN | `bdd/@publicatiebank/@admin/documenten.feature` — unblocked via provisioning + a token-auth fix (see TS8 note) | | TS9 publicatie beheer | ✅ GREEN | `bdd/@publicatiebank/@admin/publicaties.feature` (Stagehand mutate via admin, admin-read verify) | | TS11 zoeken/raadplegen | ✅ GREEN | `bdd/@burgerportaal/zoeken.feature` (deterministic; onderwerpen-browse + search-experience; ES search-hits not asserted) | Also fixed: TS1 `configuratie.feature` was flaking on the 30s default — added `@timeout:120000`. -Full suite: 37 passed + 3 skipped (the @blocked scenarios), `--workers=1`. tsc + eslint clean. Parked scenarios are `@blocked` + `Before(@blocked → test.skip(reason))` so they show as _skipped with reason_, never faked green. +## TS6/TS7 unblocked (2026-07-15) + +The blocker was *data*, not code: no account was a member of an authorised +gebruikersgroep, so `/api/mijn-gebruikersgroepen` was `[]` and the "Nieuwe +publicatie" form errored. Verified fix, all deterministic: + +- **`authProfile` fixture** (`bdd/_core/fixture.ts`) seeds the prerequisite over + the **odpc API** (the group *UI* is what TS5 covers): reads the caller's real + identity claim from `/api/me` (odpc matches group `GebruikerId` against + `preferred_username`, case-insensitive — for the admin that is `admin`), creates + an actief organisatie via the `organisations` fixture, resolves its uuid + a + first informatiecategorie uuid from `/api/v2/{organisaties,informatiecategorieen}`, + then `POST /api/gebruikersgroepen` with `gekoppeldeGebruikers:[id]` + + `gekoppeldeWaardelijsten:[orgUuid,catUuid]`. Helpers live in + `bdd/@gpp-app/support/usergroup.ts`; cleanup deletes the group by uuid. +- **`createAndPublishViaUi` / `withdrawViaUi`** (`bdd/@gpp-app/support/publicatie-ui.ts`): + Stagehand drives navigation (root → Mijn publicaties → Nieuwe publicatie) and + the Publiceren / Ja-publiceren / Publicatie-intrekken buttons; the dependent + form (native profiel `, then a titel input + * plus organisatie-radio / informatiecategorie-checkbox option-groups that only + * render once a profiel is chosen), so it is filled deterministically by + * id/value — the same carve-out the onderwerp add form uses for its file input. + * Publishing a document-less publicatie opens a "Publicatie zonder documenten" + * confirm dialog ("Ja, publiceren"). + */ +export async function createAndPublishViaUi(stagehand: Stagehand, opts: PublicatieInput) { + const page = stagehandPage(stagehand) + await openMijnPublicaties(stagehand) + await stagehand.act('Click the "Nieuwe publicatie" button') + await settle(page) + // Choosing a profiel reveals the rest of the form (Vue v-if); fill it deterministically. + // Each control's value is a uuid; radio/checkbox are toggled with a click (the + // Stagehand page's Locator has no typed check(), and they start unselected). + await page.locator('#gebruikersgroep').selectOption(opts.profielUuid) + await page.locator('#titel').fill(opts.titel) + await page.locator(`input[type="radio"][value="${opts.organisatieUuid}"]`).click() + await page.locator(`input[type="checkbox"][value="${opts.informatiecategorieUuid}"]`).click() + // Publish (the action under test) and confirm the document-less publish. + await stagehand.act('Click the "Publiceren" button') + await settle(page) + await stagehand.act('Click the "Ja, publiceren" button to confirm publishing without documents') + await settle(page) +} + +/** + * Open a publicatie by titel from "Mijn publicaties" and withdraw it + * ("Publicatie intrekken"), confirming if prompted. + */ +export async function withdrawViaUi(stagehand: Stagehand, titel: string) { + const page = stagehandPage(stagehand) + await openMijnPublicaties(stagehand) + await stagehand.act(`Open the publicatie titled "${titel}" by clicking it`) + await settle(page) + await stagehand.act('Click the "Publicatie intrekken" button') + await stagehand.act('Confirm the intrekken if a confirmation dialog appears') + await settle(page) +} diff --git a/bdd/@gpp-app/support/usergroup.ts b/bdd/@gpp-app/support/usergroup.ts index e2932dc..8c8151b 100644 --- a/bdd/@gpp-app/support/usergroup.ts +++ b/bdd/@gpp-app/support/usergroup.ts @@ -49,3 +49,78 @@ export async function deleteUsergroupByName(ctx: APIRequestContext, naam: string export async function listE2EUsergroupNames(ctx: APIRequestContext, prefix = 'E2E '): Promise { return (await listUsergroups(ctx)).map(g => g.naam).filter(n => n.startsWith(prefix)) } + +// --- Authorised-group seeding (unblocks the eindgebruiker publicatie flows) ---- +// +// Creating/editing a publicatie in the gpp-app requires the signed-in user to be +// a member of a gebruikersgroep that is authorised for at least one organisatie +// and one informatiecategorie (the "profiel" a publicatie is made under). TS6/TS7 +// need that as a *prerequisite*, so it is seeded deterministically over the odpc +// JSON API (the group create/edit UI itself is what TS5 tests). odpc matches a +// group member's `gebruikerId` against the caller's identity claim +// (preferred_username, case-insensitive — see ODPC.Server MijnGebruikersgroepen), +// so we read that claim back from `/api/me` rather than guessing it. + +function apiUrl(path: string): string { + return new URL(path, ENV.apps.gppApp).href +} + +async function firstPage(ctx: APIRequestContext, path: string): Promise { + const res = await ctx.get(apiUrl(path)) + if (!res.ok()) + throw new Error(`GET ${path} -> ${res.status()}: ${(await res.text()).slice(0, 300)}`) + const body = await res.json() + return Array.isArray(body) ? body : (body.results ?? []) +} + +/** The identity claim odpc matches group membership on (e.g. `admin`). */ +export async function currentUserId(ctx: APIRequestContext): Promise { + const res = await ctx.get(apiUrl('/api/me')) + if (!res.ok()) + throw new Error(`GET /api/me -> ${res.status()}`) + const me = await res.json() + if (!me?.id) + throw new Error(`/api/me returned no id: ${JSON.stringify(me).slice(0, 200)}`) + return me.id as string +} + +/** UUID of an organisatie by exact naam, from the gpp-app waardelijst. Throws if absent. */ +export async function resolveOrganisatieUuid(ctx: APIRequestContext, naam: string): Promise { + const orgs = await firstPage<{ uuid: string, naam: string }>(ctx, '/api/v2/organisaties') + const uuid = orgs.find(o => o.naam === naam)?.uuid + if (!uuid) + throw new Error(`Organisatie "${naam}" not visible in the gpp-app waardelijst (${orgs.length} present)`) + return uuid +} + +/** The first available informatiecategorie (uuid + naam) from the gpp-app waardelijst. Throws if none. */ +export async function firstInformatiecategorie(ctx: APIRequestContext): Promise<{ uuid: string, naam: string }> { + const cats = await firstPage<{ uuid: string, naam: string }>(ctx, '/api/v2/informatiecategorieen') + const cat = cats[0] + if (!cat?.uuid) + throw new Error('No informatiecategorie available in the gpp-app waardelijst') + return { uuid: cat.uuid, naam: cat.naam } +} + +/** + * Create a gebruikersgroep with the given member(s) and authorised waardelijst + * uuids (organisatie + informatiecategorie) over the odpc API. Returns its uuid. + * The caller must have the odpc-admin role (the admin session does). + */ +export async function createAuthorisedGroup( + ctx: APIRequestContext, + opts: { naam: string, gebruikerId: string, waardelijstUuids: string[] }, +): Promise { + const res = await ctx.post(apiUrl('/api/gebruikersgroepen'), { + data: { + naam: opts.naam, + omschrijving: 'E2E authorised profiel (prerequisite for publicatie flows)', + gekoppeldeWaardelijsten: opts.waardelijstUuids, + gekoppeldeGebruikers: [opts.gebruikerId], + }, + }) + if (!res.ok()) + throw new Error(`POST gebruikersgroepen -> ${res.status()}: ${(await res.text()).slice(0, 300)}`) + const body = await res.json() + return body.uuid as string +} diff --git a/bdd/@publicatiebank/@admin/documenten-steps.ts b/bdd/@publicatiebank/@admin/documenten-steps.ts index 6c7a3d6..48b5849 100644 --- a/bdd/@publicatiebank/@admin/documenten-steps.ts +++ b/bdd/@publicatiebank/@admin/documenten-steps.ts @@ -1,28 +1,56 @@ -import { Before, Given, test, Then, When } from '../../_core/fixture' +import { documentExistsAdmin, documentStatusAdmin, seedPublishedDocument } from '@/bdd/@publicatiebank/support/document' +import { expect } from '@playwright/test' +import { Given, Then, When } from '../../_core/fixture' /** - * Testscript 8 (document beheer) is parked: this stack has no Documents API - * configured, so no document can exist to manage (see documenten.feature and - * PLAN-plateau4-remaining.md). The @blocked scenario is skipped-with-reason so it - * stays visible in the report; these step bodies exist only so bddgen can resolve - * it and never run. If a Documents API is configured later, replace them with - * real admin mutations mirroring publicaties-steps.ts. + * Testscript 8 (document beheer) steps. A published document is seeded through the + * token API (`documents` fixture tracks it for admin cleanup); the beheer + * mutations run through the Django admin via Stagehand `act()` behind the + * `docAdmin` fixture, and assertions read the admin back through the ordinary + * session-authenticated `page` (stable, unlike the token API while Stagehand + * drives the same server — see README "Known server flake"). + * + * The `@anthropic` skip guard (no OpenRouter key → skip) and the `adminStagehand` + * fixture are shared with the organisatie/onderwerp/publicatie steps (see steps.ts). */ -const BLOCKED = 'TS8 is blocked: no Documents API configured on this stack (see documenten.feature)' +const READ = { timeout: 10_000, intervals: [400, 800, 1500] } -Before({ tags: '@blocked' }, async () => { - test.skip(true, BLOCKED) +// Prerequisite (deterministic, not the action under test): a gepubliceerd +// publicatie owning a gepubliceerd document with real uploaded content. Seeded +// through the token API — the admin add form cannot register a document in the +// Documenten API (the informatieobjecttype URL host would not resolve; see +// support/document.ts). +Given('a published document', async ({ organisations, publications, documents }) => { + const orgNaam = await organisations.add() + const publicatieTitel = publications.freshName() + const documentTitel = documents.freshName() + await seedPublishedDocument(publicatieTitel, documentTitel, orgNaam) + publications.track(publicatieTitel) + documents.track(documentTitel) }) -Given('the Documents API is configured', async () => { - throw new Error(BLOCKED) +// --- Withdraw (intrekken) --------------------------------------------------- + +When('I withdraw the document through the admin', async ({ docAdmin, documents }) => { + await docAdmin.open(documents.last()) + // The publicatiestatus is a native act() on the publicatiestatus dropdown is the single flakiest Stagehand step. Set it with a native selectOption on #id_publicatiestatus instead (Stagehand still opens + saves). The publicaties step previously used act(); documenten already did this. Both use the Stagehand understudy locator on the same tab after adminDriver.open() settles the change page. Co-Authored-By: Claude Opus 4.8 --- bdd/@publicatiebank/@admin/documenten-steps.ts | 3 ++- bdd/@publicatiebank/@admin/publicaties-steps.ts | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bdd/@publicatiebank/@admin/documenten-steps.ts b/bdd/@publicatiebank/@admin/documenten-steps.ts index 3df718f..721155f 100644 --- a/bdd/@publicatiebank/@admin/documenten-steps.ts +++ b/bdd/@publicatiebank/@admin/documenten-steps.ts @@ -35,7 +35,8 @@ Given('a published document', async ({ organisations, publications, documents }) When('I withdraw the document through the admin', async ({ docAdmin, documents }) => { await docAdmin.open(documents.last()) // The publicatiestatus is a native . await docAdmin.page.locator('#id_publicatiestatus').selectOption('ingetrokken') await docAdmin.save() }) diff --git a/bdd/@publicatiebank/@admin/publicaties-steps.ts b/bdd/@publicatiebank/@admin/publicaties-steps.ts index c37b4cf..64cdfd0 100644 --- a/bdd/@publicatiebank/@admin/publicaties-steps.ts +++ b/bdd/@publicatiebank/@admin/publicaties-steps.ts @@ -78,7 +78,12 @@ Then('the publicatie is known under its new titel and not the old one', async ({ When('I set the publicatiestatus to {string} and save the publicatie', async ({ pubAdmin, publications }, label: string) => { await pubAdmin.open(publications.last()) - await pubAdmin.act(`Select "${label}" as the publicatiestatus`) + // The publicatiestatus is a native . Option value = lowercased label + // (gepubliceerd / ingetrokken / concept). Stagehand still opens + saves. + await pubAdmin.page.locator('#id_publicatiestatus').selectOption(label.toLowerCase()) await pubAdmin.save() }) From 30e288f1108b98dfc4b5222f195d46934930c8c2 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Thu, 16 Jul 2026 12:30:32 +0200 Subject: [PATCH 17/62] test(e2e): tag AI tiers, quarantine TS8, drop redundant folder tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @expensive-ai (Claude Sonnet) on the repeat AI-flaky scenarios: the whole gebruikersgroepen feature (serial-first flake cascades), onderwerpen "Edit omschrijving", organisaties "Rename". - @fixme quarantine on the documenten (TS8) feature: the seed works once provision-documenten-api.sh is run, but the withdraw step is unreliable — adminDriver.open()'s row-click doesn't consistently reach the document change page (its changelist link differs from the publicatie one). Pre-existing gap; real fix is to open the change page by URL. - Drop folder-name tags repeated on Feature lines (@publicatiebank/@admin, @gpp-app, @burgerportaal) — tags-from-path already applies them. Co-Authored-By: Claude Opus 4.8 --- bdd/@burgerportaal/zoeken.feature | 2 +- bdd/@gpp-app/gebruikersgroepen.feature | 5 ++++- bdd/@gpp-app/publicaties.feature | 5 ++++- bdd/@publicatiebank/@admin/documenten.feature | 20 +++++++++++++++---- .../@admin/onderwerpen.feature | 3 +++ .../@admin/organisaties.feature | 3 +++ 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/bdd/@burgerportaal/zoeken.feature b/bdd/@burgerportaal/zoeken.feature index cae2a73..e7f11cf 100644 --- a/bdd/@burgerportaal/zoeken.feature +++ b/bdd/@burgerportaal/zoeken.feature @@ -20,7 +20,7 @@ # WebKit on this stack (a WebKit-specific boot failure; Chromium and Firefox boot # it fine), so its steps can never see the search field. Skip on WebKit (see # bdd/@burgerportaal/zoeken-steps.ts) rather than fail on an app that won't boot. -@burgerportaal @admin @mode:serial @timeout:120000 @no-webkit +@admin @mode:serial @timeout:120000 @no-webkit Feature: Zoeken en raadplegen op het GPP-burgerportaal As a burger I want to search and browse the public portal diff --git a/bdd/@gpp-app/gebruikersgroepen.feature b/bdd/@gpp-app/gebruikersgroepen.feature index 6540e57..f14f898 100644 --- a/bdd/@gpp-app/gebruikersgroepen.feature +++ b/bdd/@gpp-app/gebruikersgroepen.feature @@ -12,7 +12,10 @@ # fetches through the same service token that intermittently 500s under load # (see README "Known server flake") and are not exposed on the group list API, so # they are not asserted here. -@gpp-app @ai @mode:serial @timeout:120000 +# @expensive-ai — every scenario is a short Stagehand act and the default model +# flakes on them (create/rename left unapplied); since they run serially, a +# first-scenario flake skips the rest, so the whole feature runs on Claude Sonnet. +@ai @expensive-ai @mode:serial @timeout:120000 Feature: Gebruikersgroepen beheren in de GPP-app As functioneel beheer I want to create, rename and delete gebruikersgroepen diff --git a/bdd/@gpp-app/publicaties.feature b/bdd/@gpp-app/publicaties.feature index b20993b..42654e5 100644 --- a/bdd/@gpp-app/publicaties.feature +++ b/bdd/@gpp-app/publicaties.feature @@ -15,9 +15,12 @@ # read-back both go through the session-authenticated publicatiebank admin. # The withdraw scenario seeds its publicatie through the same (slow) Stagehand # publish flow before withdrawing, so the whole feature runs on a 240s budget. -@gpp-app @admin @ai @mode:serial @timeout:240000 +@admin @ai @mode:serial @timeout:240000 Feature: Publicaties creëren en intrekken in de GPP-app + # @expensive-ai — the multi-step create+publish wizard flakes on the default + # model (publicatie left unpublished); Claude Sonnet completes it reliably. + @expensive-ai Scenario: Create a publicatie Given the signed-in user belongs to an authorised gebruikersgroep When I create and publish a publicatie through the gpp-app diff --git a/bdd/@publicatiebank/@admin/documenten.feature b/bdd/@publicatiebank/@admin/documenten.feature index 2762e54..6010331 100644 --- a/bdd/@publicatiebank/@admin/documenten.feature +++ b/bdd/@publicatiebank/@admin/documenten.feature @@ -8,10 +8,22 @@ # the ordinary session-authenticated `page` (stable), exactly like the publicatie # beheer (TS9). # -# Runs with `--workers=1`: the token-API seed 500s while any admin session drives -# the same odrc server (the user-less-token flake — see support/odrc.ts), so no -# other feature may drive the admin concurrently. -@publicatiebank @admin @ai @mode:serial @timeout:120000 +# SETUP PREREQUISITE: run `setup/provision-documenten-api.sh` once per fresh stack. +# It wires the Documenten API (so `POST /documenten` registers in OpenZaak) AND +# live-patches ODRC's token auth to return AnonymousUser instead of None — without +# that patch the cookieless token seed 500s in ODRC's sessionprofile middleware +# ('NoneType' has no attribute 'is_authenticated'). A `docker compose down`/`up` +# resets the container to the unpatched image, so re-run the script after it. +# +# @fixme — the seed works once provisioned, but the "withdraw through the admin" +# step is unreliable: adminDriver.open()'s Stagehand act ("click the row link") +# does not consistently land on the *document* change page (its changelist row +# link differs from the publicatie one), so the deterministic #id_publicatiestatus +# selectOption fails with StagehandElementNotFoundError. Fix by opening the change +# page by URL (needs the document's admin pk) instead of the act-click. This is a +# pre-existing reliability gap (TS8 was already listed as residual-flaky), not the +# provision issue above. Remove @fixme once open() reaches the doc change page. +@ai @fixme @mode:serial @timeout:120000 Feature: Document beheer in de GPP-publicatiebank Scenario: Withdraw a document diff --git a/bdd/@publicatiebank/@admin/onderwerpen.feature b/bdd/@publicatiebank/@admin/onderwerpen.feature index 090e31d..d5a2290 100644 --- a/bdd/@publicatiebank/@admin/onderwerpen.feature +++ b/bdd/@publicatiebank/@admin/onderwerpen.feature @@ -41,6 +41,9 @@ Feature: Configureren van onderwerpen When I tick the "Promoot" checkbox and save the onderwerp Then the onderwerp is promoted in the API + # @expensive-ai — the omschrijving edit act flakes on the default model (field + # left blank); Claude Sonnet applies it reliably. + @expensive-ai Scenario: Edit the omschrijving of an onderwerp Given an onderwerp When I change the onderwerp omschrijving and save it diff --git a/bdd/@publicatiebank/@admin/organisaties.feature b/bdd/@publicatiebank/@admin/organisaties.feature index 7456c72..50c6bc2 100644 --- a/bdd/@publicatiebank/@admin/organisaties.feature +++ b/bdd/@publicatiebank/@admin/organisaties.feature @@ -46,6 +46,9 @@ Feature: Configureren van organisaties When I tick the "Actief" checkbox and save the organisatie Then the organisatie is active in the API + # @expensive-ai — the rename act flakes on the default model; Claude Sonnet + # applies it reliably. + @expensive-ai Scenario: Rename a self-added organisatie Given a self-added organisatie When I rename the organisatie and save it From 70bbcb15597dae19c63e980203aec35e19e436b2 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Thu, 16 Jul 2026 12:30:39 +0200 Subject: [PATCH 18/62] docs(e2e): document tags-from-path rule and TS8 provision prerequisite - Add an explicit rule in "Authentication by tag": don't repeat folder-name tags on the Feature line; tags-from-path already applies every @-segment. - Update the TS8 entry: it's implemented (seed works) but @fixme-quarantined on the withdraw step, and needs provision-documenten-api.sh per fresh stack (a down/up reverts the ODRC auth patch, reintroducing the user-less-token 500). Co-Authored-By: Claude Opus 4.8 --- README.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d5f9efd..f10a795 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,16 @@ search _experience_ (the `/zoeken` results page opens). These are present as `@blocked` features that **skip with a reason** (visible in the report — never faked green): -- **Testscript 8** (document beheer, `documenten.feature`) — this stack has **no - Documents API configured** (`/documenten` POST → 500 "No documents API - configured yet"), so no document can exist to manage. +- **Testscript 8** (document beheer, `documenten.feature`) — implemented, seed + works, but **`@fixme`-quarantined** on the "withdraw through the admin" step: + `adminDriver.open()`'s Stagehand row-click doesn't reliably land on the + *document* change page (its changelist row link differs from the publicatie + one), so the deterministic `#id_publicatiestatus` selectOption fails. Fix by + navigating to the change page by URL. Also needs **`setup/provision-documenten-api.sh` + run once per fresh stack** (wires the Documenten API + live-patches ODRC token + auth to `AnonymousUser`, without which the cookieless token seed 500s in the + sessionprofile middleware; a `docker compose down`/`up` reverts it — re-run the + script). See [`support/document.ts`](./bdd/@publicatiebank/support/document.ts). - **Testscripts 6 & 7** (gpp-app publicatie create / wijzig / intrek, `@gpp-app/publicaties.feature`) — no available account is a member of an **authorised gebruikersgroep** (`/api/mijn-gebruikersgroepen` = `[]`), so the @@ -385,10 +392,18 @@ signed-out, but Stagehand's own browser gets the saved Because `@admin` comes from the folder name, a feature placed in `bdd/@publicatiebank/@admin/` runs as admin without any tag in the `.feature` file. The `Given I am logged in to ...` steps then just navigate — the session -is already present. The GPP-app **authentication** feature is the exception: it -is untagged (no restored session) and signs in **live** through a step, so it -actually exercises the login flow. `ENV.users` lives in -[`bdd/_core/types.ts`](./bdd/_core/types.ts). +is already present. + +> **Don't repeat folder-name tags on the `Feature:` line.** tags-from-path +> already applies every `@`-prefixed path segment, so a feature under +> `bdd/@publicatiebank/@admin/` is `@publicatiebank @admin` automatically — +> writing `@publicatiebank @admin` again on the feature line is redundant noise. +> Only put tags there that the path does **not** give you: `@ai`, `@expensive-ai`, +> `@mode:serial`, `@timeout:…`, `@no-webkit`, `@fixme`, or a functional +> `@admin`/`@regular` when the folder isn't already one of those. The GPP-app **authentication** feature is the exception: it +> is untagged (no restored session) and signs in **live** through a step, so it +> actually exercises the login flow. `ENV.users` lives in +> [`bdd/_core/types.ts`](./bdd/_core/types.ts). ```gherkin Feature: Informatiecategorieën management From a4fbfd5d5914df5a3ca8fa0bfa572a324f422fe1 Mon Sep 17 00:00:00 2001 From: xiduzo Date: Thu, 16 Jul 2026 13:08:35 +0200 Subject: [PATCH 19/62] fix(e2e): open document change page deterministically, drop TS8 @fixme The Stagehand act() row-click did not reliably land on the document change page; the changelist search + row click now runs via the session page (openDocumentAdmin). Stagehand still drives the save/delete mutations. Co-Authored-By: Claude Fable 5 --- .../@admin/documenten-steps.ts | 19 ++++++++++--------- bdd/@publicatiebank/@admin/documenten.feature | 14 +++++--------- bdd/@publicatiebank/support/document.ts | 11 +++++++++++ 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/bdd/@publicatiebank/@admin/documenten-steps.ts b/bdd/@publicatiebank/@admin/documenten-steps.ts index 721155f..ccc6181 100644 --- a/bdd/@publicatiebank/@admin/documenten-steps.ts +++ b/bdd/@publicatiebank/@admin/documenten-steps.ts @@ -1,4 +1,4 @@ -import { documentExistsAdmin, documentStatusAdmin, seedPublishedDocument } from '@/bdd/@publicatiebank/support/document' +import { documentExistsAdmin, documentStatusAdmin, openDocumentAdmin, seedPublishedDocument } from '@/bdd/@publicatiebank/support/document' import { expect } from '@playwright/test' import { Given, Then, When } from '../../_core/fixture' @@ -32,12 +32,13 @@ Given('a published document', async ({ organisations, publications, documents }) // --- Withdraw (intrekken) --------------------------------------------------- -When('I withdraw the document through the admin', async ({ docAdmin, documents }) => { - await docAdmin.open(documents.last()) - // The publicatiestatus is a native . - await docAdmin.page.locator('#id_publicatiestatus').selectOption('ingetrokken') +When('I withdraw the document through the admin', async ({ page, docAdmin, documents }) => { + // Open the change page deterministically via the session `page` (same tab as + // Stagehand after CDP adoption) — the act() row-click did not reliably land on + // the *document* change page. The publicatiestatus is a native , then a titel input - * plus organisatie-radio / informatiecategorie-checkbox option-groups that only - * render once a profiel is chosen), so it is filled deterministically by - * id/value — the same carve-out the onderwerp add form uses for its file input. - * Publishing a document-less publicatie opens a "Publicatie zonder documenten" - * confirm dialog ("Ja, publiceren"). + * has no Documents API). The form itself is a dependent set of custom widgets (a + * native profiel ` + * keeps the selected `