From c13a2ce7c7c38fe44bee89782900e95c78bdf98b Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 20:54:10 +0200 Subject: [PATCH] Replace Playwright smoke tests with Selenium --- README.md | 22 +- TASKS.md | 37 +- package-lock.json | 195 ++++++--- package.json | 7 +- playwright.config.js | 13 - scripts/ci-local.ps1 | 11 +- scripts/run-playwright.ps1 | 63 --- scripts/run-selenium.js | 90 ++++ tests/e2e/dom-wrap-addon/content.js | 77 ++++ tests/e2e/dom-wrap-addon/manifest.json | 17 + tests/e2e/lib/selenium-smoke.js | 364 ++++++++++++++++ tests/e2e/smoke.js | 580 +++++++++++++++++++++++++ tests/e2e/smoke.spec.js | 457 ------------------- 13 files changed, 1313 insertions(+), 620 deletions(-) delete mode 100644 playwright.config.js delete mode 100644 scripts/run-playwright.ps1 create mode 100644 scripts/run-selenium.js create mode 100644 tests/e2e/dom-wrap-addon/content.js create mode 100644 tests/e2e/dom-wrap-addon/manifest.json create mode 100644 tests/e2e/lib/selenium-smoke.js create mode 100644 tests/e2e/smoke.js delete mode 100644 tests/e2e/smoke.spec.js diff --git a/README.md b/README.md index f1c6d87..5484877 100644 --- a/README.md +++ b/README.md @@ -128,35 +128,35 @@ This rewrite is not complete yet. Follow `TASKS.md` for the execution plan. Prerequisites: - .NET SDK 10.0+ -- PowerShell 7+ - Node.js 22+ +- Firefox +- geckodriver Initial setup: -```powershell +```bash dotnet tool restore npm ci -npm exec playwright install chromium ``` Run locally: 1. Start the app: - ```powershell + ```bash dotnet run --project RpgRoller/RpgRoller.csproj ``` 2. Open `http://localhost:5000` or the URL printed in the console. -3. Expect the current app to show either the static auth page at `/` or the authenticated workspace at `/`, depending on whether a valid session cookie already exists. +3. Expect `/` to redirect to `/login` when anonymous and to `/play` when a valid session cookie already exists. -Playwright helpers: +Browser smoke helpers: - Run the checked-in smoke suite against an isolated temporary SQLite database: - ```powershell - pwsh ./scripts/run-playwright.ps1 + ```bash + node ./scripts/run-selenium.js ``` -- Run Playwright directly when the app is already running: - ```powershell - npm run e2e +- Run the Selenium smoke suite directly when the app is already running: + ```bash + npm run e2e:smoke ``` VS Code launch profiles in `.vscode/launch.json`: diff --git a/TASKS.md b/TASKS.md index 2981063..54c6930 100644 --- a/TASKS.md +++ b/TASKS.md @@ -10,18 +10,19 @@ After this change, the browser URL will match the authenticated screen the user This matters because the current authenticated workspace is still one large, structurally dynamic Blazor Server surface. `POSTMORTEM.md` shows that this architecture is fragile when browser extensions mutate form-related DOM during startup. The route-first rewrite reduces the amount of UI that wakes up at once, removes the dual-purpose `/` shell, and makes the authenticated shell easier to reason about, test, and evolve. -The change is complete when a human can run the app, open `/`, observe the correct redirect based on auth state, log in at `/login`, land on `/play`, navigate to `/campaigns` and `/admin` with real URLs, refresh any of those routes without being thrown back to `/`, and run the automated host and Playwright tests that prove the new behavior. +The change is complete when a human can run the app, open `/`, observe the correct redirect based on auth state, log in at `/login`, land on `/play`, navigate to `/campaigns` and `/admin` with real URLs, refresh any of those routes without being thrown back to `/`, and run the automated host and Selenium tests that prove the new behavior. ## Progress -- [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and Playwright tests to define the rewrite around real routes instead of `sessionStorage` screen switching. +- [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and frontend smoke tests to define the rewrite around real routes instead of `sessionStorage` screen switching. - [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction. - [x] (2026-05-04 18:29Z) Implemented a host-level `/` redirect to `/login` or `/play`, moved the static auth document to `/login`, switched login/logout targets to `/play` and `/login`, and updated the root-path host and smoke coverage to the new contract. +- [x] (2026-05-04 19:26Z) Replaced the checked-in Playwright smoke coverage with a geckodriver+Selenium smoke runner, including a Firefox DOM-wrap addon for extension-like startup mutations, and updated repo scripts/docs to the new browser verification path. - [ ] Introduce real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving current behavior. - [ ] Remove `screen` as a `sessionStorage` routing mechanism and replace menu actions with URL navigation. - [ ] Split the large `Workspace` render tree so play, campaign management, and admin each own a smaller subtree. - [ ] Reduce `OnAfterRenderAsync` to the smallest practical scope and keep staged startup out of the authenticated shell root. -- [ ] Update host tests, Playwright smoke tests, and docs so the new route model is the only documented and verified behavior. +- [ ] Update host tests, Selenium smoke tests, and docs so the new route model is the only documented and verified behavior. ## Surprises & Discoveries @@ -29,7 +30,7 @@ The change is complete when a human can run the app, open `/`, observe the corre Evidence: `RpgRoller/Components/RpgRollerApiClient.cs` calls `js.InvokeAsync("rpgRollerApi.request", ...)`, which means authenticated data fetches currently depend on an interactive render before they can run. - Observation: the current smoke suite encodes the old dual-purpose `/` behavior and will fail as soon as `/` becomes a redirect entry point. - Evidence: `tests/e2e/smoke.spec.js` currently expects anonymous `GET /` to render static auth markup and authenticated `GET /` to render the Blazor workspace shell. + Evidence: the checked-in smoke coverage originally expected anonymous `GET /` to render static auth markup and authenticated `GET /` to render the Blazor workspace shell, so it had to be rewritten when `/` became a redirect entry point. - Observation: the current host test also encodes an outdated assumption about `/`. Evidence: `RpgRoller.Tests/Api/FrontendHostTests.cs` currently asserts that `GET /` returns HTTP 200 and a Blazor shell containing `_framework/blazor.web.js`. @@ -40,8 +41,8 @@ The change is complete when a human can run the app, open `/`, observe the corre - Observation: the repository-wide backend suite currently contains a missing-fixture failure unrelated to the route-first rewrite. Evidence: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` failed in `HostingCoverageTests.InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling` because `RpgRoller/App_Data/rpgroller.development.db` is not present in the worktree. -- Observation: the locally installed Snap Firefox build on this machine does not complete Playwright’s Firefox control handshake. - Evidence: Playwright launched `/usr/bin/firefox` with `-juggler-pipe` and stalled before page automation began, so Milestone 1 browser verification was completed with `geckodriver` plus Selenium against the same temporary app instance instead. +- Observation: the locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control. + Evidence: Playwright stalled during the `-juggler-pipe` handshake, while a `geckodriver` plus Selenium session against `/snap/firefox/current/usr/lib/firefox/firefox` completed the same Milestone 1 verification successfully. ## Decision Log @@ -65,12 +66,18 @@ The change is complete when a human can run the app, open `/`, observe the corre Rationale: the current workspace is dense and risk-prone. A staged rewrite keeps the app working while the route model changes, and it gives the test suite meaningful checkpoints. Date/Author: 2026-05-04 / Codex +- Decision: standardize frontend smoke verification on geckodriver plus Selenium instead of Playwright in this repository. + Rationale: the user updated the repo instructions to make Selenium the required browser automation path, and the locally installed Firefox stack works reliably through geckodriver while Playwright cannot control the Snap Firefox build on this machine. + Date/Author: 2026-05-04 / Codex and user + ## Outcomes & Retrospective At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about `/`, `Home.razor`, and `sessionStorage`-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order. After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests to `/` are now redirected before Blazor renders, the static auth document lives at `/login`, and successful login lands on `/play`. The main residual risk is that the authenticated shell is still monolithic behind the new `/play` route, so later milestones still need to replace in-memory screen switching with real route ownership. +After the Selenium migration iteration, the repository’s browser smoke coverage once again matches the documented verification path. The smoke suite now runs against Firefox through geckodriver, and the DOM-wrap regression coverage remains intact through a temporary test addon. The next risk is purely architectural again: the authenticated shell still uses in-memory screen switching, so Milestone 2 remains the next code change on the critical path. + This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering. ## Context and Orientation @@ -151,7 +158,7 @@ Start by inspecting the current route and auth files before editing: sed -n '1,260p' RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs sed -n '1,260p' RpgRoller/wwwroot/js/rpgroller-api.js sed -n '1,220p' RpgRoller.Tests/Api/FrontendHostTests.cs - sed -n '1,260p' tests/e2e/smoke.spec.js + sed -n '1,260p' tests/e2e/smoke.js When implementing Milestone 1, update the host test first so the intended redirect behavior is explicit: @@ -176,7 +183,7 @@ Then verify in a browser: When implementing route pages and navigation, prefer running the focused smoke suite against a temporary database: - pwsh ./scripts/run-playwright.ps1 + node ./scripts/run-selenium.js If the app is already running and a faster inner loop is needed, run the checked-in smoke file directly: @@ -186,7 +193,7 @@ After each milestone that touches C# files, run the relevant test suite and then dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings -After major frontend milestones, repeat browser verification in Chromium and Firefox. If a Firefox profile with RoboForm is available, include that manual check and record the result in `Surprises & Discoveries` or `Outcomes & Retrospective`. +After major frontend milestones, repeat browser verification in Firefox. If a Firefox profile with RoboForm is available, include that manual check and record the result in `Surprises & Discoveries` or `Outcomes & Retrospective`. ## Validation and Acceptance @@ -212,7 +219,7 @@ Automated coverage: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` passes. -`pwsh ./scripts/run-playwright.ps1` passes against a temporary SQLite database. +`node ./scripts/run-selenium.js` passes against a temporary SQLite database. If any previous tests are deleted or renamed because they encoded the old `/` behavior, replace them with tests that prove the new route model instead of simply removing coverage. @@ -222,9 +229,9 @@ This rewrite should be implemented as a sequence of additive, testable steps. Ea The safest recovery strategy is to keep the current workspace internals temporarily while introducing the new route model. That means it is acceptable to reuse `Workspace` behind the new page routes during Milestone 2, as long as the route behavior is correct and clearly transitional. After that, extract route-specific subtrees in Milestone 3. -When changing redirects or login targets, update the host and Playwright assertions in the same commit as the code change so the repository never has code and tests describing different route contracts. +When changing redirects or login targets, update the host and Selenium assertions in the same commit as the code change so the repository never has code and tests describing different route contracts. -Use a temporary SQLite database for Playwright verification, as required by the repo instructions, so browser tests do not mutate the canonical development database. +Use a temporary SQLite database for Selenium verification, as required by the repo instructions, so browser tests do not mutate the canonical development database. ## Artifacts and Notes @@ -234,10 +241,8 @@ Current evidence that must be retired by this rewrite: Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("_framework/blazor.web.js", html); - tests/e2e/smoke.spec.js - test("home page loads auth entry points", ...) - test("home document renders static auth markup without bootstrapping blazor", ...) - test("authenticated home document avoids prerendered workspace shell", ...) + tests/e2e/smoke.js + browser checks for anonymous `/`, static `/login`, authenticated `/`, and the authenticated workspace flows Current evidence that explains the bootstrap constraint: diff --git a/package-lock.json b/package-lock.json index f8bb833..57cf943 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,70 +6,167 @@ "": { "name": "rpgroller", "devDependencies": { - "@playwright/test": "^1.59.1" + "selenium-webdriver": "^4.43.0" } }, - "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/selenium-webdriver": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz", + "integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" - }, - "bin": { - "playwright": "cli.js" + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.5", + "ws": "^8.20.0" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">= 20.0.0" } }, - "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, "engines": { - "node": ">=18" + "node": ">=14.14" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "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 + } } } } diff --git a/package.json b/package.json index 42e706c..d6e8c6b 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,10 @@ "name": "rpgroller", "private": true, "scripts": { - "e2e": "playwright test", - "e2e:smoke": "playwright test tests/e2e/smoke.spec.js --reporter=line", - "e2e:install": "playwright install chromium" + "e2e": "node scripts/run-selenium.js", + "e2e:smoke": "node tests/e2e/smoke.js" }, "devDependencies": { - "@playwright/test": "^1.59.1" + "selenium-webdriver": "^4.43.0" } } diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index ddd2abf..0000000 --- a/playwright.config.js +++ /dev/null @@ -1,13 +0,0 @@ -const { defineConfig } = require("@playwright/test"); - -module.exports = defineConfig({ - testDir: "./tests/e2e", - timeout: 30_000, - fullyParallel: false, - reporter: "line", - use: { - baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5000", - headless: true, - trace: "retain-on-failure" - } -}); diff --git a/scripts/ci-local.ps1 b/scripts/ci-local.ps1 index 5e18c27..7e0bf62 100644 --- a/scripts/ci-local.ps1 +++ b/scripts/ci-local.ps1 @@ -1,6 +1,7 @@ param( [switch]$SkipDotnetRestore, [switch]$SkipBuild, + [switch]$SkipBrowserSmoke, [switch]$SkipPlaywright ) @@ -66,10 +67,6 @@ try { npm ci } - Invoke-Step -Name "Ensure Playwright browser" -Action { - npm exec playwright install chromium - } - Invoke-Step -Name "Run tests" -Action { if ($SkipBuild) { dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity minimal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings --results-directory $testResultsRoot @@ -83,9 +80,9 @@ try { pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 -ResultsRoot $testResultsRoot } - if (-not $SkipPlaywright) { - Invoke-Step -Name "Run Playwright smoke test" -Action { - pwsh ./scripts/run-playwright.ps1 + if (-not ($SkipBrowserSmoke -or $SkipPlaywright)) { + Invoke-Step -Name "Run Selenium smoke test" -Action { + node ./scripts/run-selenium.js } } diff --git a/scripts/run-playwright.ps1 b/scripts/run-playwright.ps1 deleted file mode 100644 index 467c1a0..0000000 --- a/scripts/run-playwright.ps1 +++ /dev/null @@ -1,63 +0,0 @@ -param( - [string]$BaseUrl = "http://127.0.0.1:5095", - [string]$Spec = "tests/e2e/smoke.spec.js" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$repoRoot = Split-Path -Parent $scriptDir -$appUrl = [Uri]$BaseUrl -$healthUrl = "$BaseUrl/api/health" -$tempDbPath = Join-Path $env:TEMP ("rpgroller-playwright-{0}.db" -f [Guid]::NewGuid().ToString("N")) -$process = $null - -Push-Location $repoRoot -try { - $env:ConnectionStrings__RpgRoller = "Data Source=$tempDbPath" - $env:PLAYWRIGHT_BASE_URL = $BaseUrl - - $process = Start-Process dotnet -ArgumentList @( - "run", - "--project", - "RpgRoller/RpgRoller.csproj", - "--verbosity" - "minimal" - "--urls", - $BaseUrl - ) -WorkingDirectory $repoRoot -PassThru -NoNewWindow - - $response = $null - for ($i = 0; $i -lt 60; $i++) { - try { - $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 - if ($response.StatusCode -eq 200) { - break - } - } - catch { - Start-Sleep -Milliseconds 500 - } - - Start-Sleep -Milliseconds 500 - } - - if (-not $response -or $response.StatusCode -ne 200) { - throw "Application failed to start on $BaseUrl." - } - - npm exec playwright test $Spec -- --reporter=line - if ($LASTEXITCODE -ne 0) { - throw "Playwright exited with code $LASTEXITCODE." - } -} -finally { - if ($process -and -not $process.HasExited) { - Stop-Process -Id $process.Id -Force - } - - Remove-Item Env:\ConnectionStrings__RpgRoller -ErrorAction SilentlyContinue - Remove-Item Env:\PLAYWRIGHT_BASE_URL -ErrorAction SilentlyContinue - Pop-Location -} diff --git a/scripts/run-selenium.js b/scripts/run-selenium.js new file mode 100644 index 0000000..8250e35 --- /dev/null +++ b/scripts/run-selenium.js @@ -0,0 +1,90 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +const repoRoot = path.resolve(__dirname, ".."); +const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5095"; +const healthUrl = new URL("/api/health", baseUrl).toString(); +const smokeScript = process.argv[2] || "tests/e2e/smoke.js"; +const tempDbPath = path.join(os.tmpdir(), `rpgroller-selenium-${Date.now()}-${Math.random().toString(16).slice(2)}.db`); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForHealthCheck() { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(healthUrl); + if (response.ok) { + return; + } + } catch { + } + + await delay(500); + } + + throw new Error(`Application failed to start on ${baseUrl}.`); +} + +function spawnProcess(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + child.once("error", reject); + resolve(child); + }); +} + +async function run() { + const app = await spawnProcess( + "dotnet", + ["run", "--project", "RpgRoller/RpgRoller.csproj", "--verbosity", "minimal", "--urls", baseUrl], + { + cwd: repoRoot, + stdio: "inherit", + env: { + ...process.env, + ConnectionStrings__RpgRoller: `Data Source=${tempDbPath}` + } + } + ); + + try { + await waitForHealthCheck(); + + const smoke = await spawnProcess("node", [smokeScript], { + cwd: repoRoot, + stdio: "inherit", + env: { + ...process.env, + SELENIUM_BASE_URL: baseUrl + } + }); + + const exitCode = await new Promise((resolve, reject) => { + smoke.once("error", reject); + smoke.once("exit", resolve); + }); + + if (exitCode !== 0) { + throw new Error(`Selenium smoke exited with code ${exitCode}.`); + } + } finally { + app.kill("SIGTERM"); + await delay(500); + if (!app.killed) { + app.kill("SIGKILL"); + } + + if (fs.existsSync(tempDbPath)) { + fs.rmSync(tempDbPath, { force: true }); + } + } +} + +run().catch((error) => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/tests/e2e/dom-wrap-addon/content.js b/tests/e2e/dom-wrap-addon/content.js new file mode 100644 index 0000000..69fbcc1 --- /dev/null +++ b/tests/e2e/dom-wrap-addon/content.js @@ -0,0 +1,77 @@ +(function injectDomWrapScript() { + const script = document.createElement("script"); + script.textContent = `(() => { + const wrappedMarker = "rrWrappedByTest"; + const errorPatterns = /error applying batch|unhandled exception on the current circuit/i; + const errors = []; + + window.__rrDomWrapTestErrors = errors; + + const originalConsoleError = console.error.bind(console); + console.error = (...args) => { + const text = args.map((arg) => String(arg)).join(" "); + if (errorPatterns.test(text)) { + errors.push(text); + } + + originalConsoleError(...args); + }; + + window.addEventListener("error", (event) => { + const text = [event.message, event.filename, event.lineno, event.colno].filter(Boolean).join(" "); + if (errorPatterns.test(text)) { + errors.push(text); + } + }); + + window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason ? String(event.reason) : ""; + if (errorPatterns.test(reason)) { + errors.push(reason); + } + }); + + function wrapControl(element) { + if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") { + return; + } + + const parent = element.parentNode; + if (!parent) { + return; + } + + const wrapper = document.createElement("span"); + wrapper.dataset[wrappedMarker] = "1"; + element.dataset[wrappedMarker] = "1"; + parent.insertBefore(wrapper, element); + wrapper.appendChild(element); + } + + function queueWrap(node) { + if (!(node instanceof Element)) { + return; + } + + if (node.matches("input, select")) { + queueMicrotask(() => wrapControl(node)); + } + + node.querySelectorAll("input, select").forEach((element) => { + queueMicrotask(() => wrapControl(element)); + }); + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach(queueWrap); + }); + }); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + document.querySelectorAll("input, select").forEach((element) => queueWrap(element)); + })();`; + + (document.documentElement || document).appendChild(script); + script.remove(); +})(); diff --git a/tests/e2e/dom-wrap-addon/manifest.json b/tests/e2e/dom-wrap-addon/manifest.json new file mode 100644 index 0000000..80db9ba --- /dev/null +++ b/tests/e2e/dom-wrap-addon/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 2, + "name": "RpgRoller DOM Wrap Smoke", + "version": "1.0", + "description": "Wraps input controls at document start to mimic extension behavior during smoke tests.", + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "content.js" + ], + "run_at": "document_start" + } + ] +} diff --git a/tests/e2e/lib/selenium-smoke.js b/tests/e2e/lib/selenium-smoke.js new file mode 100644 index 0000000..850fce2 --- /dev/null +++ b/tests/e2e/lib/selenium-smoke.js @@ -0,0 +1,364 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { Builder, By, until } = require("selenium-webdriver"); +const firefox = require("selenium-webdriver/firefox"); + +const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5000"; +const defaultPassword = "Password123"; +let uniqueSuffix = 0; + +function absoluteUrl(relativePath) { + return new URL(relativePath, baseUrl).toString(); +} + +function normalizeText(text) { + return String(text || "").replace(/\s+/g, " ").trim(); +} + +function uniqueName(prefix) { + uniqueSuffix += 1; + return `${prefix}-${Date.now()}-${uniqueSuffix}`; +} + +function formatCookie(sessionCookie) { + return `${sessionCookie.name}=${sessionCookie.value}`; +} + +function parseSessionCookie(setCookieHeader) { + assert.ok(setCookieHeader, "Missing Set-Cookie header for session login."); + const match = setCookieHeader.match(/(?:^|,\s*)rpgroller_session=([^;]+)/); + assert.ok(match, `Could not find rpgroller_session in Set-Cookie header: ${setCookieHeader}`); + return { name: "rpgroller_session", value: match[1] }; +} + +async function request(relativePath, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.cookie) { + headers.set("cookie", typeof options.cookie === "string" ? options.cookie : formatCookie(options.cookie)); + } + + let body; + if (options.json !== undefined) { + headers.set("content-type", "application/json"); + headers.set("accept", "application/json"); + body = JSON.stringify(options.json); + } + + return fetch(absoluteUrl(relativePath), { + method: options.method || "GET", + headers, + body, + redirect: options.redirect || "follow" + }); +} + +async function postJson(relativePath, payload, options = {}) { + const response = await request(relativePath, { + method: "POST", + json: payload, + cookie: options.cookie, + redirect: options.redirect + }); + + assert.equal(response.status, 200, `POST ${relativePath} failed with ${response.status}.`); + return response.json(); +} + +async function deleteJson(relativePath, options = {}) { + const response = await request(relativePath, { + method: "DELETE", + cookie: options.cookie + }); + + assert.equal(response.status, 200, `DELETE ${relativePath} failed with ${response.status}.`); + return response.json(); +} + +async function registerUser(username, displayName, password = defaultPassword) { + return postJson("/api/auth/register", { username, password, displayName }); +} + +async function loginUser(username, password = defaultPassword) { + const response = await request("/api/auth/login", { + method: "POST", + json: { username, password }, + redirect: "manual" + }); + + assert.equal(response.status, 200, `Login for ${username} failed with ${response.status}.`); + const sessionCookie = parseSessionCookie(response.headers.get("set-cookie")); + const user = await response.json(); + return { sessionCookie, user }; +} + +async function registerAndLoginApi(username, displayName, password = defaultPassword) { + await registerUser(username, displayName, password); + return loginUser(username, password); +} + +function resolveFirefoxBinary() { + const candidates = [ + process.env.FIREFOX_BINARY, + "/snap/firefox/current/usr/lib/firefox/firefox", + "/usr/bin/firefox" + ]; + + return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null; +} + +async function createDriver(options = {}) { + const firefoxOptions = new firefox.Options().addArguments("-headless"); + const binary = resolveFirefoxBinary(); + if (binary) { + firefoxOptions.setBinary(binary); + } + + const driver = await new Builder() + .forBrowser("firefox") + .setFirefoxOptions(firefoxOptions) + .build(); + + await driver.manage().setTimeouts({ + implicit: 0, + pageLoad: 30000, + script: 30000 + }); + + if (options.addonPath) { + await driver.installAddon(path.resolve(options.addonPath), true); + } + + return driver; +} + +async function withDriver(options, callback) { + const driver = await createDriver(options); + try { + return await callback(driver); + } finally { + await driver.quit(); + } +} + +async function seedAuthenticatedBrowser(driver, sessionCookie) { + await driver.get(absoluteUrl("/login")); + await driver.manage().addCookie({ + name: sessionCookie.name, + value: sessionCookie.value, + path: "/" + }); +} + +async function waitFor(driver, predicate, message, timeout = 15000) { + await driver.wait(async () => Boolean(await predicate()), timeout, message); +} + +async function waitForUrl(driver, relativePath, timeout = 15000) { + const expectedUrl = absoluteUrl(relativePath); + await driver.wait(until.urlIs(expectedUrl), timeout, `Expected URL ${expectedUrl}.`); +} + +async function waitForSelector(driver, selector, timeout = 15000) { + const element = await driver.wait(until.elementLocated(By.css(selector)), timeout, `Expected selector ${selector}.`); + await driver.wait(until.elementIsVisible(element), timeout, `Expected visible selector ${selector}.`); + return element; +} + +async function waitForText(driver, text, timeout = 15000) { + await waitFor( + driver, + () => driver.executeScript((expected) => document.body.innerText.includes(expected), text), + `Expected page text "${text}".`, + timeout + ); +} + +async function waitForAbsent(driver, selector, timeout = 15000) { + await waitFor( + driver, + () => driver.executeScript((css) => !document.querySelector(css), selector), + `Expected selector ${selector} to be absent.`, + timeout + ); +} + +async function selectorCount(driver, selector) { + return driver.executeScript((css) => document.querySelectorAll(css).length, selector); +} + +async function hasSelector(driver, selector) { + return driver.executeScript((css) => Boolean(document.querySelector(css)), selector); +} + +async function elementText(driver, selector) { + const text = await driver.executeScript((css) => document.querySelector(css)?.textContent || "", selector); + return normalizeText(text); +} + +async function allTexts(driver, selector) { + const texts = await driver.executeScript( + (css) => [...document.querySelectorAll(css)].map((element) => element.textContent || ""), + selector + ); + + return texts.map(normalizeText); +} + +async function getValue(driver, selector) { + return driver.executeScript((css) => document.querySelector(css)?.value ?? null, selector); +} + +async function getClassName(driver, selector) { + return driver.executeScript((css) => document.querySelector(css)?.className ?? "", selector); +} + +async function getAttribute(driver, selector, attributeName) { + return driver.executeScript( + (css, attribute) => document.querySelector(css)?.getAttribute(attribute) ?? null, + selector, + attributeName + ); +} + +async function isChecked(driver, selector) { + return driver.executeScript((css) => Boolean(document.querySelector(css)?.checked), selector); +} + +async function clickSelector(driver, selector) { + const element = await waitForSelector(driver, selector); + await element.click(); +} + +async function clickText(driver, selector, text, options = {}) { + const matched = await driver.executeScript( + (css, expectedText, contains, last) => { + const candidates = [...document.querySelectorAll(css)]; + const normalized = expectedText.replace(/\s+/g, " ").trim(); + const match = candidates.filter((candidate) => { + const candidateText = (candidate.textContent || "").replace(/\s+/g, " ").trim(); + return contains ? candidateText.includes(normalized) : candidateText === normalized; + }); + + const element = last ? match.at(-1) : match[0]; + if (!element) { + return false; + } + + element.click(); + return true; + }, + selector, + text, + Boolean(options.contains), + Boolean(options.last) + ); + + assert.ok(matched, `Could not find ${selector} with text "${text}".`); +} + +async function fillInput(driver, selector, value) { + const updated = await driver.executeScript( + (css, nextValue) => { + const input = document.querySelector(css); + if (!input) { + return false; + } + + input.focus(); + input.value = nextValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }, + selector, + value + ); + + assert.ok(updated, `Could not find input ${selector}.`); +} + +async function clickLabel(driver, labelText) { + const clicked = await driver.executeScript((text) => { + const label = [...document.querySelectorAll("label")].find( + (element) => (element.textContent || "").replace(/\s+/g, " ").trim() === text + ); + if (!label) { + return false; + } + + const targetId = label.getAttribute("for"); + const target = targetId ? document.getElementById(targetId) : label.querySelector("input,select,textarea"); + if (!target) { + return false; + } + + target.click(); + target.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }, labelText); + + assert.ok(clicked, `Could not find label "${labelText}".`); +} + +async function clickByTitle(driver, title) { + const clicked = await driver.executeScript((expectedTitle) => { + const button = [...document.querySelectorAll("[title]")].find((element) => element.getAttribute("title") === expectedTitle); + if (!button) { + return false; + } + + button.click(); + return true; + }, title); + + assert.ok(clicked, `Could not find element with title "${title}".`); +} + +async function getDomWrapErrors(driver) { + return driver.executeScript(() => window.__rrDomWrapTestErrors || []); +} + +async function runSmokeTests(tests) { + for (let index = 0; index < tests.length; index += 1) { + const testCase = tests[index]; + console.log(`[${index + 1}/${tests.length}] ${testCase.name}`); + await testCase.run(); + console.log(`PASS ${testCase.name}`); + } +} + +module.exports = { + Key, + absoluteUrl, + allTexts, + baseUrl, + clickByTitle, + clickLabel, + clickSelector, + clickText, + defaultPassword, + deleteJson, + elementText, + fillInput, + formatCookie, + getAttribute, + getClassName, + getDomWrapErrors, + getValue, + hasSelector, + isChecked, + postJson, + registerAndLoginApi, + request, + runSmokeTests, + seedAuthenticatedBrowser, + selectorCount, + uniqueName, + waitFor, + waitForAbsent, + waitForSelector, + waitForText, + waitForUrl, + withDriver +}; diff --git a/tests/e2e/smoke.js b/tests/e2e/smoke.js new file mode 100644 index 0000000..eb826e6 --- /dev/null +++ b/tests/e2e/smoke.js @@ -0,0 +1,580 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { + Key, + absoluteUrl, + allTexts, + clickByTitle, + clickLabel, + clickSelector, + clickText, + elementText, + fillInput, + getAttribute, + getClassName, + getDomWrapErrors, + getValue, + hasSelector, + isChecked, + postJson, + registerAndLoginApi, + request, + runSmokeTests, + seedAuthenticatedBrowser, + selectorCount, + uniqueName, + waitFor, + waitForAbsent, + waitForSelector, + waitForText, + waitForUrl, + withDriver +} = require("./lib/selenium-smoke"); + +const domWrapAddonPath = path.join(__dirname, "dom-wrap-addon"); + +async function openAuthenticatedPlay(driver, sessionCookie) { + await seedAuthenticatedBrowser(driver, sessionCookie); + await driver.get(absoluteUrl("/play")); + await waitForText(driver, "Campaign Log"); +} + +const tests = [ + { + name: "home page loads auth entry points", + run: async () => withDriver({}, async (driver) => { + await driver.get(absoluteUrl("/")); + await waitForUrl(driver, "/login"); + await waitForText(driver, "RpgRoller"); + assert.deepEqual(await allTexts(driver, "h2"), ["Register", "Login"]); + assert.equal(await hasSelector(driver, "#register-username"), true); + assert.equal(await hasSelector(driver, "#login-password"), true); + }) + }, + { + name: "root document redirects anonymous users to login", + run: async () => { + const response = await request("/", { redirect: "manual" }); + assert.equal(response.status, 302); + assert.equal(response.headers.get("location"), "/login"); + } + }, + { + name: "login document renders static auth markup without bootstrapping blazor", + run: async () => { + const response = await request("/login"); + assert.equal(response.status, 200); + + const html = await response.text(); + assert.ok(!html.includes("Connecting...")); + assert.ok(html.includes("Register or log in to join a campaign session.")); + assert.ok(!html.includes("_framework/blazor.web.js")); + assert.ok(!html.includes("