Replace Playwright smoke tests with Selenium
This commit is contained in:
22
README.md
22
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`:
|
||||
|
||||
37
TASKS.md
37
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:
|
||||
|
||||
|
||||
193
package-lock.json
generated
193
package-lock.json
generated
@@ -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,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
"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,
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
90
scripts/run-selenium.js
Normal file
90
scripts/run-selenium.js
Normal file
@@ -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;
|
||||
});
|
||||
77
tests/e2e/dom-wrap-addon/content.js
Normal file
77
tests/e2e/dom-wrap-addon/content.js
Normal file
@@ -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();
|
||||
})();
|
||||
17
tests/e2e/dom-wrap-addon/manifest.json
Normal file
17
tests/e2e/dom-wrap-addon/manifest.json
Normal file
@@ -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": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
]
|
||||
}
|
||||
364
tests/e2e/lib/selenium-smoke.js
Normal file
364
tests/e2e/lib/selenium-smoke.js
Normal file
@@ -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
|
||||
};
|
||||
580
tests/e2e/smoke.js
Normal file
580
tests/e2e/smoke.js
Normal file
@@ -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("<!--Blazor:"));
|
||||
assert.ok(html.includes("data-auth-page"));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "authenticated root document redirects to play",
|
||||
run: async () => {
|
||||
const username = uniqueName("doc-auth");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Document Auth");
|
||||
const response = await request("/", {
|
||||
cookie: sessionCookie,
|
||||
redirect: "manual"
|
||||
});
|
||||
|
||||
assert.equal(response.status, 302);
|
||||
assert.equal(response.headers.get("location"), "/play");
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "successful login transitions to play workspace",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("login");
|
||||
await postJson("/api/auth/register", {
|
||||
username,
|
||||
password: "Password123",
|
||||
displayName: "Login Flow"
|
||||
});
|
||||
|
||||
await driver.get(absoluteUrl("/login"));
|
||||
await fillInput(driver, "#login-username", username);
|
||||
await fillInput(driver, "#login-password", "Password123");
|
||||
await clickText(driver, "button", "Login");
|
||||
|
||||
await waitForUrl(driver, "/play");
|
||||
await waitForText(driver, "Campaign Log");
|
||||
assert.equal(await selectorCount(driver, "#login-username"), 0);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "workspace stays usable when input controls are DOM-wrapped during mount",
|
||||
run: async () => withDriver({ addonPath: domWrapAddonPath }, async (driver) => {
|
||||
const username = uniqueName("wrapped");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Wrapped Inputs");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Wrapped Inputs",
|
||||
rulesetId: "d6"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Wrapper Hero",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitForSelector(driver, "#skill-filter-input");
|
||||
await waitForSelector(driver, "#custom-roll-expression");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Stealth"))),
|
||||
"Expected Roll Stealth button."
|
||||
);
|
||||
|
||||
assert.deepEqual(await getDomWrapErrors(driver), []);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster open-ended roll detail renders specialized dice chips",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Smoke");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Open Ender",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const skill = await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Open Sight",
|
||||
diceRollDefinition: "d100!+85",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 95
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
let qualifyingRoll = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const roll = await postJson(`/api/skills/${skill.id}/roll`, { visibility: "public" }, { cookie: sessionCookie });
|
||||
if (roll.dice.some((die) => die.kind === "rolemaster-open-ended-high" || die.kind === "rolemaster-open-ended-low-subtract")) {
|
||||
qualifyingRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(qualifyingRoll, null, "Expected an open-ended Rolemaster roll within 12 attempts.");
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitForSelector(driver, ".log-panel .log-entry");
|
||||
|
||||
await clickSelector(driver, ".log-panel .log-entry-toggle");
|
||||
await waitForSelector(driver, ".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
|
||||
assert.equal(await hasSelector(driver, ".log-detail .roll-dice-strip"), true);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster automatic retry badge shows before detail expands",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm-retry");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Retry Smoke");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster Retry Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Retry Hero",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const skill = await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Retry Sight",
|
||||
diceRollDefinition: "d100!+10",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
let retriedRoll = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const roll = await postJson(`/api/skills/${skill.id}/roll`, { visibility: "public" }, { cookie: sessionCookie });
|
||||
if (roll.breakdown.includes("retry(+")) {
|
||||
retriedRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(retriedRoll, null, "Expected a retry-enabled Rolemaster roll within 10 attempts.");
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll(".log-panel .log-entry")].some((entry) => entry.textContent.includes("retry +"))),
|
||||
"Expected retry roll entry."
|
||||
);
|
||||
|
||||
const collapsedState = await driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
const retryEntry = entries.at(-1);
|
||||
if (!retryEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
badgeTexts: [...retryEntry.querySelectorAll(".log-event-badge")].map((element) => element.textContent || ""),
|
||||
summaryText: retryEntry.querySelector(".log-summary-text")?.textContent || "",
|
||||
detailCount: retryEntry.querySelectorAll(".log-detail").length
|
||||
};
|
||||
});
|
||||
|
||||
assert.ok(collapsedState);
|
||||
assert.ok(collapsedState.badgeTexts.some((badgeText) => /Retry \+(5|10)/.test(badgeText)));
|
||||
assert.match(collapsedState.summaryText, /retry \+(5|10)/i);
|
||||
assert.equal(collapsedState.detailCount, 0);
|
||||
|
||||
await clickText(driver, ".log-panel .log-entry-toggle", "Details", { contains: true, last: true }).catch(async () => {
|
||||
const toggled = await driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
const retryEntry = entries.at(-1);
|
||||
const toggle = retryEntry?.querySelector(".log-entry-toggle");
|
||||
if (!toggle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
toggle.click();
|
||||
return true;
|
||||
});
|
||||
|
||||
assert.ok(toggled, "Could not expand retry entry.");
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
return (entries.at(-1)?.querySelectorAll(".log-detail .die-chip").length || 0) === 2;
|
||||
}),
|
||||
"Expected two retry detail dice chips."
|
||||
);
|
||||
|
||||
const detailState = await driver.executeScript(() => {
|
||||
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
|
||||
const text = entry.textContent || "";
|
||||
return text.includes("Retry Sight") && text.includes("retry +");
|
||||
});
|
||||
const retryEntry = entries.at(-1);
|
||||
const chips = [...(retryEntry?.querySelectorAll(".log-detail .die-chip") || [])];
|
||||
return chips.map((chip) => chip.getAttribute("title") || "");
|
||||
});
|
||||
|
||||
assert.equal(detailState.length, 2);
|
||||
assert.match(detailState[0], /attempt 1/i);
|
||||
assert.match(detailState[1], /retry attempt 2/i);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm-modal");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Modal Smoke");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster Modal Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Observer",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Observation",
|
||||
diceRollDefinition: "d100!+50",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Observation"))),
|
||||
"Expected Roll Observation button."
|
||||
);
|
||||
|
||||
await clickText(driver, "button", "Roll Observation", { contains: true });
|
||||
await waitForSelector(driver, ".rolemaster-roll-modal");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => document.activeElement?.id === "rolemaster-situational-modifier"),
|
||||
"Expected modifier input to be focused."
|
||||
);
|
||||
|
||||
await (await waitForSelector(driver, "#rolemaster-situational-modifier")).sendKeys(Key.ESCAPE);
|
||||
await waitForAbsent(driver, ".rolemaster-roll-modal");
|
||||
|
||||
await clickText(driver, "button", "Roll Observation", { contains: true });
|
||||
await waitForSelector(driver, ".rolemaster-roll-modal");
|
||||
await driver.executeScript(() => {
|
||||
document.querySelector(".modal-overlay")?.click();
|
||||
});
|
||||
await waitForAbsent(driver, ".rolemaster-roll-modal");
|
||||
|
||||
await clickText(driver, "button", "Roll Observation", { contains: true });
|
||||
await waitForSelector(driver, ".rolemaster-roll-modal");
|
||||
await fillInput(driver, "#rolemaster-situational-modifier", "1001");
|
||||
await clickText(driver, ".rolemaster-roll-modal button", "Roll");
|
||||
await waitForText(driver, "Enter a whole number between -1000 and 1000.");
|
||||
assert.equal(await hasSelector(driver, ".rolemaster-roll-modal"), true);
|
||||
|
||||
await fillInput(driver, "#rolemaster-situational-modifier", "");
|
||||
await (await waitForSelector(driver, "#rolemaster-situational-modifier")).sendKeys(Key.ENTER);
|
||||
await waitForAbsent(driver, ".rolemaster-roll-modal");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => document.querySelector(".log-panel .log-entry.expanded")?.textContent.includes("Observation") || false),
|
||||
"Expected expanded Observation log entry."
|
||||
);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "newly rolled log entry auto-expands",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("d6-log");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "D6 Auto Expand");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "D6 Auto Expand",
|
||||
rulesetId: "d6"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "Auto Hero",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Stealth"))),
|
||||
"Expected Roll Stealth button."
|
||||
);
|
||||
await clickText(driver, "button", "Roll Stealth", { contains: true });
|
||||
await waitForSelector(driver, ".log-panel .log-entry.expanded");
|
||||
assert.equal(await hasSelector(driver, ".log-panel .log-entry.expanded .roll-dice-strip"), true);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "custom roll composer keeps parse errors inline and records successful rolls",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("custom-roll");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Custom Roller");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Custom Roll Campaign",
|
||||
rulesetId: "dnd5e"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson("/api/characters", {
|
||||
name: "Improviser",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const input = document.querySelector("#custom-roll-expression");
|
||||
const button = document.querySelector(".custom-roll-composer button");
|
||||
return Boolean(input && button && !input.disabled && !button.disabled);
|
||||
}),
|
||||
"Expected custom roll composer to be interactive."
|
||||
);
|
||||
await fillInput(driver, "#custom-roll-expression", "bad");
|
||||
await clickText(driver, ".custom-roll-composer button", "Roll");
|
||||
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const input = document.querySelector("#custom-roll-expression");
|
||||
return Boolean(input && /error/.test(input.className));
|
||||
}),
|
||||
"Expected custom roll input to show an inline validation error."
|
||||
);
|
||||
assert.match(await getClassName(driver, "#custom-roll-expression"), /error/);
|
||||
assert.match(await getAttribute(driver, "#custom-roll-expression", "title"), /Expected dnd5e format like 2d12\+2\./);
|
||||
assert.equal(await selectorCount(driver, ".toast.error"), 0);
|
||||
|
||||
await fillInput(driver, "#custom-roll-expression", "1d20+5");
|
||||
await clickText(driver, ".custom-roll-composer button", "Roll");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => {
|
||||
const className = document.querySelector("#custom-roll-expression")?.className || "";
|
||||
const firstLogEntry = document.querySelector(".log-panel .log-entry");
|
||||
return !/error/.test(className) && Boolean(firstLogEntry?.textContent.includes("Custom roll"));
|
||||
}),
|
||||
"Expected successful custom roll entry."
|
||||
);
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Rolemaster UI exposes conditional create and edit fields",
|
||||
run: async () => withDriver({}, async (driver) => {
|
||||
const username = uniqueName("rm-ui");
|
||||
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster UI");
|
||||
|
||||
const campaign = await postJson("/api/campaigns", {
|
||||
name: "Rolemaster UI Campaign",
|
||||
rulesetId: "rolemaster"
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
const character = await postJson("/api/characters", {
|
||||
name: "UI Character",
|
||||
campaignId: campaign.id
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skill-groups`, {
|
||||
name: "Awareness",
|
||||
diceRollDefinition: "d100!+15",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await postJson(`/api/characters/${character.id}/skills`, {
|
||||
name: "Perception",
|
||||
diceRollDefinition: "d100!+25",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
}, { cookie: sessionCookie });
|
||||
|
||||
await openAuthenticatedPlay(driver, sessionCookie);
|
||||
await waitForSelector(driver, "#workspace-screen-menu-button");
|
||||
|
||||
await clickSelector(driver, "#workspace-screen-menu-button");
|
||||
await clickText(driver, ".screen-menu .menu-item", "Campaign Management");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Add campaign"))),
|
||||
"Expected Campaign Management controls."
|
||||
);
|
||||
await clickText(driver, "button", "Add campaign", { contains: true });
|
||||
await waitForSelector(driver, "#campaign-ruleset");
|
||||
assert.equal(await elementText(driver, "#campaign-ruleset option[value='rolemaster']"), "Rolemaster");
|
||||
await clickText(driver, "button", "Cancel");
|
||||
|
||||
await clickSelector(driver, "#workspace-screen-menu-button");
|
||||
await clickText(driver, ".screen-menu .menu-item", "Play");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Add group"))),
|
||||
"Expected Play controls after returning from Campaign Management."
|
||||
);
|
||||
|
||||
await clickText(driver, "button", "Add group", { contains: true });
|
||||
await waitForSelector(driver, "#skill-group-expression");
|
||||
assert.equal(await selectorCount(driver, "#skill-group-wild-dice"), 0);
|
||||
assert.equal(await getValue(driver, "#skill-group-expression"), "d100");
|
||||
await fillInput(driver, "#skill-group-expression", "d100!+15");
|
||||
await waitForSelector(driver, "#skill-group-fumble-range");
|
||||
await fillInput(driver, "#skill-group-fumble-range", "");
|
||||
await clickText(driver, "button", "Create Group");
|
||||
await waitForText(driver, "Open-ended Rolemaster groups require a fumble range.");
|
||||
await clickText(driver, "button", "Cancel");
|
||||
|
||||
await clickText(driver, "button", "Add skill", { contains: true });
|
||||
await waitForSelector(driver, "#skill-create-expression");
|
||||
assert.equal(await getValue(driver, "#skill-create-expression"), "d100!+15");
|
||||
await fillInput(driver, "#skill-create-expression", "15d10");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-create-fumble-range").then((count) => count === 0),
|
||||
"Expected no create fumble range for non-open-ended expression."
|
||||
);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
|
||||
"Expected no auto retry checkbox for non-open-ended expression."
|
||||
);
|
||||
|
||||
await fillInput(driver, "#skill-create-expression", "d100!+25");
|
||||
await waitForSelector(driver, "#skill-create-fumble-range");
|
||||
await waitForSelector(driver, "#skill-auto-retry");
|
||||
await clickLabel(driver, "Automatic retry");
|
||||
await fillInput(driver, "#skill-create-expression", "d10");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
|
||||
"Expected create auto retry checkbox to disappear."
|
||||
);
|
||||
|
||||
await fillInput(driver, "#skill-create-expression", "d100!+25");
|
||||
await waitForSelector(driver, "#skill-auto-retry");
|
||||
assert.equal(await isChecked(driver, "#skill-auto-retry"), false);
|
||||
await clickText(driver, "button", "Cancel");
|
||||
|
||||
await clickByTitle(driver, "Edit skill");
|
||||
await waitForSelector(driver, "#skill-edit-expression");
|
||||
assert.equal(await getValue(driver, "#skill-edit-expression"), "d100!+25");
|
||||
assert.equal(await getValue(driver, "#skill-edit-fumble-range"), "5");
|
||||
assert.equal(await isChecked(driver, "#skill-auto-retry"), true);
|
||||
await fillInput(driver, "#skill-edit-expression", "d10");
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-edit-fumble-range").then((count) => count === 0),
|
||||
"Expected edit fumble range to disappear."
|
||||
);
|
||||
await waitFor(
|
||||
driver,
|
||||
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
|
||||
"Expected edit auto retry checkbox to disappear."
|
||||
);
|
||||
|
||||
await fillInput(driver, "#skill-edit-expression", "d100!+25");
|
||||
await waitForSelector(driver, "#skill-auto-retry");
|
||||
assert.equal(await isChecked(driver, "#skill-auto-retry"), false);
|
||||
await clickText(driver, "button", "Cancel");
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
runSmokeTests(tests).catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,457 +0,0 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
async function postJson(request, url, data) {
|
||||
const response = await request.post(url, { data });
|
||||
expect(response.ok()).toBeTruthy();
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function registerAndLogin(request, username, displayName) {
|
||||
await postJson(request, "/api/auth/register", {
|
||||
username,
|
||||
password: "Password123",
|
||||
displayName
|
||||
});
|
||||
|
||||
const loginResponse = await request.post("/api/auth/login", {
|
||||
data: {
|
||||
username,
|
||||
password: "Password123"
|
||||
}
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test("home page loads auth entry points", async ({ page }) => {
|
||||
await page.goto("/play");
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.locator("h1")).toContainText("RpgRoller");
|
||||
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
||||
await expect(page.getByLabel("Username").first()).toBeVisible();
|
||||
await expect(page.getByLabel("Password").nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test("root document redirects anonymous users to login", async ({ request }) => {
|
||||
const response = await request.get("/", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers()["location"]).toBe("/login");
|
||||
});
|
||||
|
||||
test("login document renders static auth markup without bootstrapping blazor", async ({ request }) => {
|
||||
const response = await request.get("/login");
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const html = await response.text();
|
||||
expect(html).not.toContain("Connecting...");
|
||||
expect(html).toContain("Register or log in to join a campaign session.");
|
||||
expect(html).not.toContain("_framework/blazor.web.js");
|
||||
expect(html).not.toContain("<!--Blazor:");
|
||||
expect(html).toContain("data-auth-page");
|
||||
});
|
||||
|
||||
test("authenticated root document redirects to play", async ({ request }) => {
|
||||
const username = `doc-auth-${Date.now()}`;
|
||||
const password = "Password123";
|
||||
|
||||
await postJson(request, "/api/auth/register", {
|
||||
username,
|
||||
password,
|
||||
displayName: "Document Auth"
|
||||
});
|
||||
|
||||
const loginResponse = await request.post("/api/auth/login", {
|
||||
data: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
|
||||
const response = await request.get("/", { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(302);
|
||||
expect(response.headers()["location"]).toBe("/play");
|
||||
});
|
||||
|
||||
test("successful login transitions to play workspace", async ({ page, context }) => {
|
||||
const username = `login-${Date.now()}`;
|
||||
const password = "Password123";
|
||||
|
||||
await postJson(context.request, "/api/auth/register", {
|
||||
username,
|
||||
password,
|
||||
displayName: "Login Flow"
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.locator("#login-username").fill(username);
|
||||
await page.locator("#login-password").fill(password);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/play$/);
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator("#login-username")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("workspace stays usable when input controls are DOM-wrapped during mount", async ({ page, context }) => {
|
||||
const username = `wrapped-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Wrapped Inputs");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Wrapped Inputs",
|
||||
rulesetId: "d6"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Wrapper Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const wrappedMarker = "rrWrappedByTest";
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
const blazorErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = message.text();
|
||||
if (/error applying batch|unhandled exception on the current circuit/i.test(text)) {
|
||||
blazorErrors.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator("#skill-filter-input")).toBeVisible();
|
||||
await expect(page.locator("#custom-roll-expression")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Roll Stealth" })).toBeVisible();
|
||||
expect(blazorErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
|
||||
const username = `rm-${Date.now()}`;
|
||||
const displayName = "Rolemaster Smoke";
|
||||
|
||||
await registerAndLogin(context.request, username, displayName);
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Open Ender",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Open Sight",
|
||||
diceRollDefinition: "d100!+85",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 95
|
||||
});
|
||||
|
||||
await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
await expect(page.locator(".log-panel .log-entry").first()).toBeVisible();
|
||||
await expect(page.locator(".log-panel .log-event-badge")).toContainText(["Fumble"]);
|
||||
|
||||
const logEntry = page.locator(".log-panel .log-entry-toggle").first();
|
||||
await expect(logEntry).toBeVisible();
|
||||
await logEntry.click();
|
||||
|
||||
const rolemasterFollowUpDice = page.locator(".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
|
||||
await expect(rolemasterFollowUpDice.first()).toBeVisible();
|
||||
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Rolemaster automatic retry badge shows before detail expands", async ({ page, context }) => {
|
||||
const username = `rm-retry-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Retry Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Retry Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Retry Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Retry Sight",
|
||||
diceRollDefinition: "d100!+10",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
let retriedRoll = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const roll = await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
|
||||
if (roll.breakdown.includes("retry(+")) {
|
||||
retriedRoll = roll;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(retriedRoll, "expected a retry-enabled Rolemaster roll within 10 attempts").not.toBeNull();
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const retryEntry = page.locator(".log-panel .log-entry").filter({ hasText: "retry +" }).last();
|
||||
await expect(retryEntry).toBeVisible();
|
||||
await expect(retryEntry.locator(".log-event-badge")).toContainText([/Retry \+(5|10)/]);
|
||||
await expect(retryEntry.locator(".log-summary-text")).toContainText(/retry \+(5|10)/);
|
||||
await expect(retryEntry.locator(".log-detail")).toHaveCount(0);
|
||||
|
||||
await retryEntry.locator(".log-entry-toggle").click();
|
||||
const detailDice = retryEntry.locator(".log-detail .die-chip");
|
||||
await expect(detailDice).toHaveCount(2);
|
||||
await expect(detailDice.nth(0)).toHaveAttribute("title", /attempt 1/i);
|
||||
await expect(detailDice.nth(1)).toHaveAttribute("title", /retry attempt 2/i);
|
||||
});
|
||||
|
||||
test("Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop", async ({ page, context }) => {
|
||||
const username = `rm-modal-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Modal Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Modal Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Observer",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Observation",
|
||||
diceRollDefinition: "d100!+50",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const rollButton = page.getByRole("button", { name: "Roll Observation" });
|
||||
const modal = page.getByRole("dialog", { name: "Rolemaster situational modifier" });
|
||||
const modifierInput = page.locator("#rolemaster-situational-modifier");
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modifierInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await page.locator(".modal-overlay").click({ position: { x: 8, y: 8 } });
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await modifierInput.fill("1001");
|
||||
await modal.getByRole("button", { name: "Roll" }).click();
|
||||
await expect(page.getByText("Enter a whole number between -1000 and 1000.")).toBeVisible();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modifierInput.fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal).toHaveCount(0);
|
||||
await expect(page.locator(".log-panel .log-entry.expanded").first()).toContainText("Observation");
|
||||
});
|
||||
|
||||
test("newly rolled log entry auto-expands", async ({ page, context }) => {
|
||||
const username = `d6-log-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "D6 Auto Expand");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "D6 Auto Expand",
|
||||
rulesetId: "d6"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Auto Hero",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Stealth",
|
||||
diceRollDefinition: "2D+1",
|
||||
wildDice: 1,
|
||||
allowFumble: true
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Roll Stealth" }).click();
|
||||
|
||||
const expandedEntry = page.locator(".log-panel .log-entry.expanded").first();
|
||||
await expect(expandedEntry).toBeVisible();
|
||||
await expect(expandedEntry.locator(".log-detail .roll-dice-strip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("custom roll composer keeps parse errors inline and records successful rolls", async ({ page, context }) => {
|
||||
const username = `custom-roll-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Custom Roller");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Custom Roll Campaign",
|
||||
rulesetId: "dnd5e"
|
||||
});
|
||||
await postJson(context.request, "/api/characters", {
|
||||
name: "Improviser",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const composer = page.locator(".custom-roll-composer");
|
||||
const input = page.locator("#custom-roll-expression");
|
||||
await input.fill("bad");
|
||||
await composer.getByRole("button", { name: "Roll" }).click();
|
||||
|
||||
await expect(input).toHaveClass(/error/);
|
||||
await expect(input).toHaveAttribute("title", /Expected dnd5e format like 2d12\+2\./);
|
||||
await expect(page.locator(".toast.error")).toHaveCount(0);
|
||||
|
||||
await input.fill("1d20+5");
|
||||
await composer.getByRole("button", { name: "Roll" }).click();
|
||||
|
||||
await expect(input).not.toHaveClass(/error/);
|
||||
await expect(page.locator(".log-panel .log-entry").first()).toContainText("Custom roll");
|
||||
});
|
||||
|
||||
test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => {
|
||||
const username = `rm-ui-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster UI");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster UI Campaign",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "UI Character",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skill-groups`, {
|
||||
name: "Awareness",
|
||||
diceRollDefinition: "d100!+15",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Perception",
|
||||
diceRollDefinition: "d100!+25",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/play");
|
||||
await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
|
||||
|
||||
await page.locator("#workspace-screen-menu-button").click();
|
||||
await page.getByRole("menuitem", { name: "Campaign Management" }).click();
|
||||
await page.getByRole("button", { name: "Add campaign" }).click();
|
||||
await expect(page.locator("#campaign-ruleset option[value='rolemaster']")).toHaveText("Rolemaster");
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.locator("#workspace-screen-menu-button").click();
|
||||
await page.getByRole("menuitem", { name: "Play" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add group" }).click();
|
||||
await expect(page.locator("#skill-group-wild-dice")).toHaveCount(0);
|
||||
await expect(page.locator("#skill-group-expression")).toHaveValue("d100");
|
||||
await page.locator("#skill-group-expression").fill("d100!+15");
|
||||
await expect(page.locator("#skill-group-fumble-range")).toBeVisible();
|
||||
await page.locator("#skill-group-fumble-range").fill("");
|
||||
await page.getByRole("button", { name: "Create Group" }).click();
|
||||
await expect(page.getByText("Open-ended Rolemaster groups require a fumble range.")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Add skill" }).first().click();
|
||||
await expect(page.locator("#skill-create-expression")).toHaveValue("d100!+15");
|
||||
await page.locator("#skill-create-expression").fill("15d10");
|
||||
await expect(page.locator("#skill-create-fumble-range")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-create-expression").fill("d100!+25");
|
||||
await expect(page.locator("#skill-create-fumble-range")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await page.getByLabel("Automatic retry").check();
|
||||
await page.locator("#skill-create-expression").fill("d10");
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-create-expression").fill("d100!+25");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.locator("button[title='Edit skill']").first().click();
|
||||
await expect(page.locator("#skill-edit-expression")).toHaveValue("d100!+25");
|
||||
await expect(page.locator("#skill-edit-fumble-range")).toHaveValue("5");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeChecked();
|
||||
await page.locator("#skill-edit-expression").fill("d10");
|
||||
await expect(page.locator("#skill-edit-fumble-range")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
|
||||
await page.locator("#skill-edit-expression").fill("d100!+25");
|
||||
await expect(page.getByLabel("Automatic retry")).toBeVisible();
|
||||
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
Reference in New Issue
Block a user