33 Commits

Author SHA1 Message Date
adlee-was-taken
b9cc7d29cf Merge feat/soak-harness: multiplayer soak & UX test harness + v3.3.4
All checks were successful
Build & Deploy Staging / build-and-deploy (release) Successful in 24s
29 commits implementing a standalone Playwright-based soak runner
that drives 16 authenticated browser sessions across 4 concurrent
rooms. Two scenarios (populate + stress with chaos injection),
click-to-watch live video via CDP screencast, per-room failure
isolation, and test-account separation via is_test_account column.

Server-side: schema migration, register flow flagging, stats
include_test filter, admin panel badges + toggle.

Harness: tests/soak/ package with SessionPool, RoomCoordinator,
Watchdog, Dashboard (HTTP+WS), Screencaster, 27 Vitest tests.

Version bumped from v3.1.6 to v3.3.4.
2026-04-11 22:58:36 -04:00
adlee-was-taken
b8bc432175 feat(soak): artifacts, graceful shutdown, health probes, smoke script, v3.3.4
Batched remaining harness tasks (27-30, 33):

Task 27 — Artifact capture on failure: screenshots, HTML snapshots,
game state JSON, and console error tails are captured into
tests/soak/artifacts/<run-id>/ when a scenario throws. Successful
runs get a summary.json. Old runs (>7d) are pruned on startup.

Task 28 — Graceful shutdown: first SIGINT/SIGTERM flips the abort
signal (scenarios finish current turn then unwind). 10s after, a
hard-kill fires if cleanup hangs. Double Ctrl-C = immediate exit.
Exit codes: 0 success, 1 errors, 2 interrupted.

Task 29 — Periodic health probes: every 30s GET /health against the
target server. Three consecutive failures abort the run with
health_fatal, preventing staging outages from being misattributed
to harness bugs. Corrected endpoint from /api/health to /health
per server/routers/health.py.

Task 30 — Smoke test script: tests/soak/scripts/smoke.sh, a 60s
end-to-end canary that health-probes the target, seeds if needed,
and runs one minimal populate game.

Task 33 — Version bump to v3.3.4: both index.html footers (was
v3.1.6), new footer added to admin.html (had none), pyproject.toml.

Also fixes discovered during stress testing:
- SessionPool sets baseURL on all contexts so relative goto('/')
  resolves correctly between games (was "invalid URL" error)
- RoomCoordinator key is now unique per game-start (Date.now
  suffix) so Deferred promises don't carry stale room codes from
  previous games

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:57:15 -04:00
adlee-was-taken
d3b468575b feat(soak): per-room watchdog + heartbeat wiring + multi-game lobby fix
Watchdog class with 4 Vitest tests (27 total now), wired into
ctx.heartbeat in the runner. One watchdog per room with a 60s
timeout; firing logs an error, marks the room's dashboard tile
as errored, and triggers the abort signal so the scenario unwinds.
Watchdogs are explicitly stopped in the runner's finally block
so pending timers don't keep the node process alive on exit.

Also fixes a multi-game bug discovered during stress scenario
verification: after a game ends sessions stay parked on the
game_over screen, which hides the lobby and makes a subsequent
#create-room-btn click time out. runOneMultiplayerGame now
navigates every session to / before each game — localStorage
auth persists so nothing re-logs in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:52:49 -04:00
adlee-was-taken
921a6ad984 feat(soak): stress scenario with chaos injection
Rapid short games with a parallel chaos loop that has a 5% per-turn
chance of firing one of:
  - rapid_clicks: 5 quick clicks at the player's own cards
  - tab_blur:    window blur/focus event pair
  - brief_offline: 300ms network outage via context.setOffline

Chaos counts roll up into ScenarioResult.customMetrics.chaos_fired.

Important detail: chaos loop has a 3-second initial delay so room
creation, joiners, and game start can complete without interference.
Chaos during lobby setup (especially brief_offline) was causing
#create-room-btn to go unstable.

Verified: stress smoke with --games-per-room=3, 4 accounts + 1 CPU,
first game completed with 37 turns and chaos events fired across all
three event types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:49:30 -04:00
adlee-was-taken
c307027dd0 feat(soak): --watch=tiled launches N headed host windows
SessionPool accepts headedHostCount; when > 0 it launches a second
Chromium in headed mode and creates the first N sessions in it.
Joiners (sessions N..count) stay headless in the main browser.

Each headed context gets a 960×900 viewport — tall enough to show
the full game table (deck + opponent row + own 2×3 card grid +
status area) without clipping. Horizontal tiling still fits two
windows side-by-side on a 1920-wide display.

window.moveTo is kept as a best-effort tile-placement hint, but
viewport from newContext() is what actually sizes the window
(window.resizeTo is a no-op on modern Chromium / Wayland).

Verified: 1-room tiled run plays a full game cleanly; 2-room
parallel tiled had one window get closed mid-run, which is
consistent with a user manually dismissing a window — tiled mode
is a best-effort hands-on debugging aid, not an automation mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:01:21 -04:00
adlee-was-taken
21fe53eaf7 feat(soak): click-to-watch live video via CDP screencast
Runner creates a Screencaster before sessions are acquired, then
wires its start/stop into DashboardServer.onStartStream / onStopStream
after sessions exist (the handlers close over a sessionsByKey map).
Clicking a player tile in the dashboard starts a CDP screencast on
that session's page, forwards JPEG frames as WS "frame" messages.
Closing the modal (or disconnecting the WS) stops all screencasts.

Verified end-to-end: programmatically connected WS, sent start_stream,
received 5 frames (13.7KB each), sent stop_stream, screencast_stopped
log line fired, run completed cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:54:52 -04:00
adlee-was-taken
34ce7d1d32 feat(soak): Screencaster — CDP Page.startScreencast wrapper
Attach/detach CDP sessions per Playwright Page, start/stop JPEG
screencasts with configurable quality and frame rate, forward each
frame to a callback. Used by the dashboard for click-to-watch
live video (wired in Task 23).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:06:17 -04:00
adlee-was-taken
796de876b7 feat(soak): wire --watch=dashboard in runner
Starts DashboardServer on port 7777 (or --dashboard-port), uses its
reporter as ctx.dashboard, auto-opens the URL via xdg-open/open/start.
Cleans up on exit. WS client connections logged as info events so
you can see when the browser attaches.

Verified: 2-account populate run with --watch=dashboard serves the
static page on :7777, accepts WS connections, cleanly shuts down
when the run completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:05:44 -04:00
adlee-was-taken
a35e789eb9 feat(soak): dashboard status grid UI
Static HTML page served by DashboardServer. Renders the 2×2 room
grid with progress bars and player tiles, subscribes to WS events,
updates tiles live. Click-to-watch modal is wired but receives
frames once the CDP screencaster ships in Task 22.

Adds escapeHtml() on all user-controlled strings (roomId, player
key) — not strictly needed for our trusted bot traffic but cheap
XSS hardening against future scenarios that accept user input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:55:21 -04:00
adlee-was-taken
9d1d4f899b feat(soak): DashboardServer — vanilla http + ws
Serves one static HTML page, accepts WS connections, broadcasts
room_state/log/metric messages to all clients. Replays current
state to late-joining clients so refreshing the dashboard during
a run shows the right grid. Exposes a reporter() method that
returns a DashboardReporter scenarios can call without knowing
about sockets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:53:59 -04:00
adlee-was-taken
d1688aae0b feat(soak): runner.ts end-to-end with --watch=none
First full end-to-end milestone: parses CLI, builds SessionPool +
RoomCoordinator, loads a scenario by name, runs it, reports results,
cleans up. Watch modes other than "none" log a warning and fall back
until Tasks 19-24 implement them.

Smoke test passed against local dev:
  bun run soak -- --scenario=populate --accounts=2 --rooms=1
    --cpus-per-room=0 --games-per-room=1 --holes=1 --watch=none
  → Games completed: 1, Errors: 0, Duration: 78.2s, exit 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:52:08 -04:00
adlee-was-taken
a6a276b509 fix(bot): GolfBot handles authenticated sessions + num-decks stepper
Two small fixes to tests/e2e/bot/golf-bot.ts needed to run the bot
from the soak harness with authenticated accounts:

1. createGame and joinGame now check whether #player-name is
   visible before filling it. Authenticated sessions hide that
   input (the server uses the logged-in username); guest sessions
   still fill it as before. Existing e2e tests behave identically
   since they register guests who always see the input.

2. startGame's 'decks' option was calling selectOption on
   #num-decks, which is a hidden input inside a stepper widget,
   not a <select>. Replaced with stepper-click logic that
   increments/decrements until the hidden input matches the
   target value.

End-to-end verified via the soak runner: 2 authenticated sessions
played a complete 1-hole game against local dev, 26 turns, exit 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:52:07 -04:00
adlee-was-taken
6df81e6f8d feat(soak): CLI parsing + config precedence
parseArgs pulls --scenario/--rooms/--watch/etc from argv,
mergeConfig layers scenarioDefaults → env → CLI so CLI flags
always win. 12 Vitest unit tests cover both parse happy/edge
paths and the 4-way merge precedence matrix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:25:04 -04:00
adlee-was-taken
2c20b6c7b5 feat(soak): populate scenario + scenario registry
Partitions sessions into N rooms, runs gamesPerRoom games per room
in parallel via Promise.allSettled so a failure in one room never
unwinds the others. Errors roll up into ScenarioResult.errors.

Verified via tsx: listScenarios() returns [populate], getScenario()
resolves by name and returns undefined for unknown names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:23:56 -04:00
adlee-was-taken
722934bdf2 feat(soak): shared runOneMultiplayerGame helper
Encapsulates the host-creates/joiners-join/loop-until-done flow so
populate and stress scenarios don't duplicate it. Honors abort
signal and a max-duration timeout, heartbeats on every turn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:23:00 -04:00
adlee-was-taken
2a86b3cc54 feat(soak): scripts/seed-accounts.ts CLI wrapper
Thin standalone entry for pre-seeding N accounts before the first
harness run. Wraps SessionPool.seed and writes .env.stresstest.

End-to-end verified: ran against local dev with --count=4, all 4
accounts landed in the DB with is_test_account=TRUE, cred file
written with correct format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:22:10 -04:00
adlee-was-taken
3bc0270eb9 feat(soak): SessionPool — seed, login, acquire contexts
Owns BrowserContexts, seeds via POST /api/auth/register with the
invite code on cold start, warm-starts via localStorage injection of
the cached JWT, falls back to POST /api/auth/login if the token is
rejected. Exposes acquire(n) for scenarios.

Infrastructure changes needed to import the real GolfBot class from
tests/e2e/bot/ without the Task-10 structural-interface workaround:
 - Add @playwright/test as devDep so value-imports in e2e/bot/*.ts
   resolve at runtime (Page/Locator/expect are pulled even as types)
 - Remove rootDir from tsconfig so TS follows cross-package imports;
   add a paths entry so TS can resolve @playwright/test from the soak
   package's node_modules when compiling files under tests/e2e/bot
 - Drop the local GolfBot structural interface + its placeholder
   GamePhase/StartGameOptions/TurnResult types; re-export the real
   class from types.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:19:39 -04:00
adlee-was-taken
066e482f06 feat(soak): structured JSONL logger with child contexts
Single file, no transport, writes one JSON line per call to stdout.
Child loggers inherit parent meta so scenarios can bind room/game
context once and forget about it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:12:27 -04:00
adlee-was-taken
02642840da feat(soak): RoomCoordinator with host→joiners handoff
Lazy Deferred per roomId with a timeout on await. Lets concurrent
joiner sessions block until their host announces the room code
without polling or page scraping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:11:05 -04:00
adlee-was-taken
1565046ab7 feat(soak): core types + Deferred primitive
Establishes the Scenario/Session/Logger/DashboardReporter contracts
the rest of the harness builds on. Deferred is the building block
for RoomCoordinator's host→joiners handoff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:09:36 -04:00
adlee-was-taken
5478a4299e feat(soak): scaffold tests/soak package
Placeholder runner, tsconfig with @bot alias to tests/e2e/bot,
gitignored .env.stresstest + artifacts. Real behavior follows
in Task 10 onward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:19:09 -04:00
adlee-was-taken
835a79cc0f docs: soak harness bring-up steps
Documents the one-time UPDATE invite_codes SET marks_as_test = TRUE
step required before running tests/soak against each environment,
schema verification queries, and the expected filter behavior post-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:23:13 -04:00
adlee-was-taken
983518e93d feat(admin): visible Test/Test-seed badges + filter toggle
Users table shows [Test] next to soak-harness accounts, invite codes
list shows [Test-seed] next to codes that flag new accounts as test,
and a new "Include test accounts" checkbox lets admins hide bot
traffic from the user list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:20:09 -04:00
adlee-was-taken
917ef2a239 feat(server): admin users list surfaces is_test_account
UserDetails carries the new column, search_users selects and
optionally filters on it, and the /api/admin/users route accepts
?include_test=false to hide soak-harness accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:13:41 -04:00
adlee-was-taken
b5a25b4ae5 feat(server): stats queries support include_test filter
Leaderboard and rank queries take an optional include_test param
(default false). Real users never see soak-harness traffic unless
they explicitly opt in via ?include_test=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:33:38 -04:00
adlee-was-taken
0891e6c979 feat(server): register flow flags accounts from test-seed invites
When a user registers with an invite_code whose marks_as_test=TRUE,
their users_v2.is_test_account is set to TRUE. Normal invite codes
and invite-less signups are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:25:00 -04:00
adlee-was-taken
1f20ac9535 feat(server): expose marks_as_test on InviteCode
Adds the field to the dataclass, SELECT list in get_invite_codes,
and a new get_invite_code_details helper that the register flow
will use to discover whether an invite should flag new accounts
as test accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:11:34 -04:00
adlee-was-taken
8e23adee14 feat(server): propagate is_test_account through User model & store
User dataclass, create_user, and all SELECT lists now round-trip the
new column. Value is always FALSE until Task 4 wires the register
flow to the invite code's marks_as_test flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:01:53 -04:00
adlee-was-taken
3817566ed5 docs: rename test-account index to match users_v2 convention
Post-review fix for Task 1: code reviewer flagged that
idx_users_v2_is_test_account didn't match the idx_users_<suffix>
convention used by every other index in user_store.py. The
implementation commit (d163675) was amended to use
idx_users_test_account; this commit updates the plan and spec
docs so they stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:52:55 -04:00
adlee-was-taken
d16367582c feat(server): add is_test_account + marks_as_test schema
New columns support separating soak-harness test traffic from real
user traffic in stats queries. Rebuilds leaderboard_overall matview
to include is_test_account so the fast path stays filterable.

Migration is idempotent via DO $$ / IF NOT EXISTS blocks inside
SCHEMA_SQL, which runs on every server startup — same mechanism
every existing post-v1 column migration uses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:52:18 -04:00
adlee-was-taken
e8051b256b docs(plan): harden soak-harness schema migration for deploy
Makes the deployment path explicit in Task 1: traces the existing
lifespan → get_user_store → initialize_schema → conn.execute(SCHEMA_SQL)
flow, notes that the DO $$/IF NOT EXISTS pattern is the same one
every post-v1 column migration uses, and explains why rollback is
safe (additive changes only).

Adds two new verification steps to Task 1:
 - Step 7: post-deploy psql checks against staging
 - Step 8: same against production

Adds a "Post-deploy schema verification" block to CHECKLIST.md so
the schema state is verified after every server restart against
each target environment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:40:28 -04:00
adlee-was-taken
cf916d7bc3 docs: implementation plan for multiplayer soak harness
33-task TDD plan across 11 phases implementing the soak & UX test
harness design. Server-side schema/filter/admin changes ship first
(independent), then the tests/soak/ TypeScript runner builds up
incrementally — first milestone is a --watch=none smoke run against
local dev after Task 18, then dashboard, live video, tiled mode,
stress scenario, failure handling, and staging bring-up. Final
task bumps project version to v3.3.4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:37:15 -04:00
adlee-was-taken
97036be319 docs: multiplayer soak & UX test harness design
Design for a standalone Playwright-based soak runner that drives 16
authenticated browser sessions across 4 concurrent rooms to populate
staging scoreboards and hunt stability bugs. Architected as a
pluggable scenario harness so future UX test scenarios (reconnect,
invite flow, admin workflows, mobile) slot in cleanly.

Also gitignores .superpowers/ (brainstorming session artifacts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:03:28 -04:00
49 changed files with 9722 additions and 37 deletions

1
.gitignore vendored
View File

@@ -214,6 +214,7 @@ cython_debug/
# Claude Code
.claude/
.superpowers/
# Virtualenv in project root
bin/

View File

@@ -114,6 +114,10 @@
<input type="checkbox" id="include-banned" checked>
Include banned
</label>
<label class="checkbox-label">
<input type="checkbox" id="include-test" checked>
Include test accounts
</label>
</div>
<table id="users-table" class="data-table">
<thead>
@@ -396,6 +400,8 @@
<!-- Toast Container -->
<div id="toast-container"></div>
<footer class="app-footer" style="text-align: center; padding: 16px; color: #888; font-size: 12px;">v3.3.4 &copy; Aaron D. Lee</footer>
<script src="admin.js"></script>
</body>
</html>

View File

@@ -67,12 +67,13 @@ async function getStats() {
return apiRequest('/api/admin/stats');
}
async function getUsers(query = '', offset = 0, includeBanned = true) {
async function getUsers(query = '', offset = 0, includeBanned = true, includeTest = true) {
const params = new URLSearchParams({
query,
offset,
limit: PAGE_SIZE,
include_banned: includeBanned,
include_test: includeTest,
});
return apiRequest(`/api/admin/users?${params}`);
}
@@ -306,15 +307,19 @@ async function loadUsers() {
try {
const query = document.getElementById('user-search').value;
const includeBanned = document.getElementById('include-banned').checked;
const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned);
const includeTest = document.getElementById('include-test').checked;
const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned, includeTest);
const tbody = document.querySelector('#users-table tbody');
tbody.innerHTML = '';
data.users.forEach(user => {
const testBadge = user.is_test_account
? ' <span class="badge badge-info" title="Soak harness test account">Test</span>'
: '';
tbody.innerHTML += `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.username)}${testBadge}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td>
<td>${getStatusBadge(user)}</td>
@@ -447,10 +452,13 @@ async function loadInvites() {
: invite.remaining_uses <= 0
? '<span class="badge badge-warning">Used Up</span>'
: '<span class="badge badge-success">Active</span>';
const testSeedBadge = invite.marks_as_test
? ' <span class="badge badge-info" title="Creates test accounts">Test-seed</span>'
: '';
tbody.innerHTML += `
<tr>
<td><code>${escapeHtml(invite.code)}</code></td>
<td><code>${escapeHtml(invite.code)}</code>${testSeedBadge}</td>
<td>${invite.use_count} / ${invite.max_uses}</td>
<td>${invite.remaining_uses}</td>
<td>${escapeHtml(invite.created_by_username)}</td>
@@ -827,6 +835,10 @@ document.addEventListener('DOMContentLoaded', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('include-test').addEventListener('change', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('users-prev').addEventListener('click', () => {
if (usersPage > 0) {
usersPage--;

View File

@@ -55,7 +55,7 @@
<p id="lobby-error" class="error"></p>
<footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
<footer class="app-footer">v3.3.4 &copy; Aaron D. Lee</footer>
</div>
<!-- Matchmaking Screen -->
@@ -288,7 +288,7 @@
<p id="waiting-message" class="info">Waiting for host to start the game...</p>
</div>
<footer class="app-footer">v3.1.6 &copy; Aaron D. Lee</footer>
<footer class="app-footer">v3.3.4 &copy; Aaron D. Lee</footer>
</div>
<!-- Game Screen -->

View File

@@ -0,0 +1,125 @@
# Soak Harness Bring-Up
One-time setup steps before running `tests/soak` against an environment.
## Prerequisites
- An invite code exists with 16+ available uses
- You have psql access to the target DB (or admin SQL access via some other means)
## 1. Flag the invite code as test-seed
Any account registered with a `marks_as_test=TRUE` invite code gets
`users_v2.is_test_account=TRUE`, which keeps it out of real-user stats.
### Staging
Invite code: `5VC2MCCN` (16 uses, provisioned 2026-04-10).
```sql
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
SELECT code, max_uses, use_count, marks_as_test FROM invite_codes WHERE code = '5VC2MCCN';
```
Expected: `marks_as_test | t`.
From your workstation:
```bash
ssh root@129.212.150.189 \
'docker compose -f /opt/golfgame/docker-compose.staging.yml exec -T postgres psql -U postgres -d golfgame' <<'SQL'
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
SELECT code, max_uses, use_count, marks_as_test FROM invite_codes WHERE code = '5VC2MCCN';
SQL
```
### Production
Invite code — to be provisioned when production seeding is needed. Same pattern:
```bash
ssh root@165.245.152.51 \
'docker compose -f /opt/golfgame/docker-compose.prod.yml exec -T postgres psql -U postgres -d golfgame' <<'SQL'
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '<PROD_INVITE_CODE>';
SQL
```
### Local dev
The local dev environment uses the `SOAKTEST` invite code. Create it once
(if you wiped the DB since the last run):
```sql
INSERT INTO invite_codes (code, created_by, expires_at, max_uses, is_active, marks_as_test)
SELECT 'SOAKTEST', id, NOW() + INTERVAL '10 years', 100, TRUE, TRUE
FROM users_v2 LIMIT 1
ON CONFLICT (code) DO UPDATE SET marks_as_test = TRUE;
```
Note: `created_by` references any existing user (the FK doesn't require admin role).
If your dev DB has zero users, register one first via the UI or `curl`.
Connection string for local dev:
```bash
PGPASSWORD=devpassword psql -h localhost -U golf -d golf
```
## 2. Verify the schema migrations applied
The server-side changes (columns, matview rebuild, stats filter) run automatically
on server startup via `SCHEMA_SQL` in `server/stores/user_store.py`. After the
server-side changes deploy, verify against each target environment:
```sql
-- All four should return matching rows
\d users_v2 -- look for is_test_account column
\d invite_codes -- look for marks_as_test column
\d leaderboard_overall -- look for is_test_account column
\di idx_users_test_account
```
If `leaderboard_overall` doesn't show the new column, the matview rebuild
didn't run. Check server startup logs for errors around `initialize_schema`.
## 3. Run the harness
Once the harness is built (Tasks 9+ of the implementation plan):
```bash
cd tests/soak
npm install
# First run only: seed 16 accounts via the invite code
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN npm run seed
# Smoke test against local dev
TEST_URL=http://localhost:8000 SOAK_INVITE_CODE=SOAKTEST npm run smoke
# Populate staging scoreboard
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN npm run soak:populate
# Stress test
TEST_URL=https://staging.adlee.work SOAK_INVITE_CODE=5VC2MCCN npm run soak:stress
```
See `tests/soak/README.md` for the full flag reference.
## 4. Verify test account filtering works end-to-end
After a soak run, the soak-seeded accounts should be visible to admins but
hidden from public stats:
```bash
# Should return no soak_* usernames (test accounts hidden by default)
curl -s "https://staging.adlee.work/api/stats/leaderboard?metric=wins" | jq '.entries[] | select(.username | startswith("soak_"))'
# Should return the soak_* accounts when explicitly requested
curl -s "https://staging.adlee.work/api/stats/leaderboard?metric=wins&include_test=true" | jq '.entries[] | select(.username | startswith("soak_"))'
```
In the admin panel:
- Users tab shows a `[Test]` badge next to soak accounts
- Invite codes tab shows `[Test-seed]` next to the flagged code
- "Include test accounts" checkbox (default checked) toggles visibility

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,638 @@
# Multiplayer Soak & UX Test Harness — Design
**Date:** 2026-04-10
**Status:** Design approved, pending implementation plan
## Context
Golf Card Game is a real-time multiplayer WebSocket application with event-sourced game state, a leaderboard system, and an aggressive animation pipeline. Current test coverage is:
- `server/` — pytest unit/integration tests
- `tests/e2e/specs/` — Playwright tests exercising single-context flows (full game, stress with rapid clicks, visual regression, v3 features)
What's missing: a way to exercise the system with **many concurrent authenticated users playing real multiplayer games** for long durations. We can't currently:
1. Populate staging scoreboards with realistic game history for demos and visual verification
2. Hunt race conditions, WebSocket leaks, and room cleanup bugs under sustained concurrent load
3. Validate multiplayer UX end-to-end across rooms without manual coordination
4. Exercise authentication, room lifecycle, and stats aggregation as a cohesive system
This spec defines a **multi-scenario soak and UX test harness**: a standalone Playwright-based runner that drives 16 authenticated browser sessions across 4 concurrent rooms playing many games against each other (plus optional CPU opponents). It starts as a soak tool with two scenarios (`populate`, `stress`) and grows into the project's general-purpose multi-user UX test platform.
## Goals
1. **Scoreboard population** — run long multi-round games against staging with varied CPU personalities to produce realistic scoreboard data
2. **Stability stress** — run rapid short games with chaos injection to surface race conditions and cleanup bugs
3. **Extensibility** — new scenarios (reconnect, invite flow, admin workflow, mobile) slot in without runner changes
4. **Watchability** — a dashboard mode with click-to-watch live video of any player, usable for demos and debugging
5. **Per-run isolation** — test account traffic must be cleanly separable from real user traffic in stats queries
## Non-goals
- Replacing the existing `tests/e2e/specs/` Playwright tests (they serve a different purpose — single-context edge cases)
- Distributed runner across multiple machines
- Concurrent scenario execution (one scenario per run for MVP)
- Grafana/OTEL integration
- Auto-promoting findings to regression tests
## Constraints
- **Staging auth gate** — staging runs `INVITE_ONLY=true`; seeding must go through the register endpoint with an invite code
- **Invite code `5VC2MCCN`** — provisioned with 16 uses, used once per test account on first-ever run, cached afterward
- **Per-IP rate limiting** — `DAILY_SIGNUPS_PER_IP=20` on prod, lower default elsewhere; seeding must stay within budget
- **Room idle cleanup** — `ROOM_IDLE_TIMEOUT_SECONDS=300` means the scenario must keep rooms active or tolerate cleanup cascades
- **Existing bot code** — `tests/e2e/bot/golf-bot.ts` already provides `createGame`, `joinGame`, `addCPU`, `playTurn`, `playGame`; the harness reuses it verbatim
## Architecture
### Module layout
```
runner.ts (entry)
├─ SessionPool owns 16 BrowserContexts, seeds/logs in, allocates
├─ Scenario pluggable interface, per-scenario file
├─ RoomCoordinator host→joiners room-code handoff via Deferred<string>
├─ Dashboard (optional) HTTP + WS server, status grid + click-to-watch video
└─ GolfBot (reused) tests/e2e/bot/golf-bot.ts, unchanged
```
Default: one browser, 16 contexts (lowest RAM, fastest startup). `WATCH=tiled` is the exception — it launches two browsers, one headed (hosts) and one headless (joiners), because Chromium's headed/headless flag is browser-scoped, not context-scoped. See the `tiled` implementation detail below.
### Location
New sibling directory `tests/soak/` — does not modify `tests/e2e/`. Shares `GolfBot` via direct import from `../e2e/bot/`.
Rationale: Playwright Test is designed for short isolated tests. A single `test()` running 16 contexts for hours fights the test model (worker limits, all-or-nothing failure, single giant trace file). A standalone node script gives first-class CLI flags, full control over the event loop, clean home for the dashboard server, and reuses the `GolfBot` class unchanged. Existing `tests/e2e/specs/stress.spec.ts` stays as-is for single-context edge cases.
## Components
### SessionPool
Owns the lifecycle of 16 authenticated `BrowserContext`s.
**Responsibilities:**
- On first run: register 16 accounts via `POST /api/auth/register` with invite code `5VC2MCCN`, cache credentials to `.env.stresstest`
- On subsequent runs: read cached credentials, create contexts, inject auth into each (localStorage token, or re-login via cached password if token rejected)
- Expose `acquire({ count }): Promise<Session[]>` — scenarios request N authenticated sessions without caring how they got there
- On scenario completion: close all contexts cleanly
**`Session` shape:**
```typescript
interface Session {
context: BrowserContext;
page: Page;
bot: GolfBot;
account: Account; // { username, password, token }
key: string; // stable identifier, e.g., "soak_07"
}
```
**`.env.stresstest` format** (gitignored, local-only, plaintext — this is a test tool):
```
SOAK_ACCOUNT_00=soak_00_a7bx:Hunter2!xK9mQ:eyJhbGc...
SOAK_ACCOUNT_01=soak_01_c3pz:Kc82!wQm4Rt:eyJhbGc...
...
SOAK_ACCOUNT_15=soak_15_m9fy:Px7!eR4sTn2:eyJhbGc...
```
Line format: `username:password:token`. Password kept so the pool can recover from token expiry automatically.
### Scenario interface
```typescript
export interface ScenarioNeeds {
accounts: number;
rooms?: number;
cpusPerRoom?: number;
}
export interface ScenarioContext {
config: ScenarioConfig; // CLI flags merged with scenario defaults
sessions: Session[]; // pre-authenticated, pre-navigated
coordinator: RoomCoordinator;
dashboard: DashboardReporter; // no-op when watch mode doesn't use it
logger: Logger;
signal: AbortSignal; // graceful shutdown
heartbeat(roomId: string): void; // resets the per-room watchdog
}
export interface ScenarioResult {
gamesCompleted: number;
errors: ScenarioError[];
durationMs: number;
customMetrics?: Record<string, number>;
}
export interface Scenario {
name: string;
description: string;
defaultConfig: ScenarioConfig;
needs: ScenarioNeeds;
run(ctx: ScenarioContext): Promise<ScenarioResult>;
}
```
Scenarios are plain objects exported as default from files in `tests/soak/scenarios/`. The runner discovers them via a registry (`scenarios/index.ts`) that maps name → module. No filesystem scanning, no magic.
### RoomCoordinator
~30 lines. Solves host→joiners room-code handoff:
```typescript
class RoomCoordinator {
private rooms = new Map<string, Deferred<string>>();
announce(roomId: string, code: string) { this.get(roomId).resolve(code); }
async await(roomId: string): Promise<string> { return this.get(roomId).promise; }
private get(roomId: string) {
if (!this.rooms.has(roomId)) this.rooms.set(roomId, deferred());
return this.rooms.get(roomId)!;
}
}
```
Usage:
```typescript
// Host
const code = await host.bot.createGame(host.account.username);
coordinator.announce('room-1', code);
// Joiners (concurrent)
const code = await coordinator.await('room-1');
await joiner.bot.joinGame(code, joiner.account.username);
```
No polling, no sleeps, no cross-page scraping.
### Dashboard
Optional — only instantiated when `WATCH=dashboard`.
**Server side** (`dashboard/server.ts`): vanilla `http` + `ws` module. Serves a single static HTML page, accepts WebSocket connections, relays messages between scenarios and the browser.
**Client side** (`dashboard/index.html` + `dashboard.js`): 2×2 room grid, per-player tiles with live status (current player, score, held card, phase, moves), progress bars per hole, activity log at the bottom. No framework, ~300 lines total.
**Click-to-watch**: clicking a player tile sends `start_stream(sessionKey)` over WS. The runner attaches a CDP session to that player's page via `context.newCDPSession(page)`, calls `Page.startScreencast` with `{format: 'jpeg', quality: 60, maxWidth: 640, maxHeight: 360, everyNthFrame: 2}`, and forwards each `Page.screencastFrame` event to the dashboard as `{ sessionKey, jpeg_b64 }`. The dashboard renders it into an `<img>` that swaps `src` on each frame.
Returning to the grid sends `stop_stream(sessionKey)` and the runner detaches the CDP session. On WS disconnect, all active screencasts stop. This keeps CPU cost zero except while someone is actively watching.
**`DashboardReporter` interface exposed to scenarios:**
```typescript
interface DashboardReporter {
update(roomId: string, state: Partial<RoomState>): void;
log(level: 'info'|'warn'|'error', msg: string, meta?: object): void;
incrementMetric(name: string, by?: number): void;
}
```
When `WATCH` is not `dashboard`, all three methods are no-ops; structured logs still go to stdout.
### Runner
`runner.ts` is the CLI entry point. Parses flags, resolves config precedence, launches browser(s), instantiates `SessionPool` + `RoomCoordinator` + (optional) `Dashboard`, loads the requested scenario by name, executes it, reports results, cleans up.
## Scenarios
### Scenario 1: `populate`
**Goal:** produce realistic scoreboard data for staging demos.
**Config:**
```typescript
{
name: 'populate',
description: 'Long multi-round games to populate scoreboards',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 1 },
defaultConfig: {
gamesPerRoom: 10,
holes: 9,
decks: 2,
cpuPersonalities: ['Sofia', 'Marcus', 'Kenji', 'Priya'],
thinkTimeMs: [800, 2200],
interGamePauseMs: 3000,
},
}
```
**Shape:** 4 rooms × 4 accounts + 1 CPU each. Each room runs `gamesPerRoom` sequential games. Inside a room: host creates game → joiners join → host adds CPU → host starts game → all sessions loop on `isMyTurn()` + `playTurn()` with randomized human-like think time between turns. Between games, rooms pause briefly to mimic natural pacing.
### Scenario 2: `stress`
**Goal:** hunt race conditions and stability bugs.
**Config:**
```typescript
{
name: 'stress',
description: 'Rapid short games for stability & race condition hunting',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 2 },
defaultConfig: {
gamesPerRoom: 50,
holes: 1,
decks: 1,
thinkTimeMs: [50, 150],
interGamePauseMs: 200,
chaosChance: 0.05,
},
}
```
**Shape:** same as `populate` but tight loops, 1-hole games, and a chaos injector that fires with 5% probability per turn. Chaos events:
- Rapid concurrent clicks on multiple cards
- Random tab-navigation away and back
- Simultaneous click on card + discard button
- Brief WebSocket drop via Playwright's `context.setOffline()` followed by reconnect
Each chaos event is logged with enough context to reproduce (room, player, turn, event type).
### Future scenarios (not MVP, design anticipates them)
- `reconnect` — 2 accounts, deliberate mid-game disconnect, verify recovery
- `invite-flow` — 0 accounts (fresh signups), exercise invite request → approval → first-game pipeline
- `admin-workflow` — 1 admin account, drive the admin panel
- `mobile-populate` — reuses `populate` with `devices['iPhone 13']` context options
- `replay-viewer` — watches completed games via the replay UI
Each is a new file in `tests/soak/scenarios/`, zero runner changes.
## Data flow
### Cold start (first-ever run)
1. Runner reads `.env.stresstest` → file missing
2. `SessionPool.seedAccounts()`:
- For `i` in `0..15`: `POST /api/auth/register` with `{ username, password, email, invite_code: '5VC2MCCN' }`
- Receive `{ user, token, expires_at }`, write to `.env.stresstest`
3. Server sets `is_test_account=true` automatically because the invite code has `marks_as_test=true` (see Server changes)
4. Runner proceeds to normal startup
### Warm start (subsequent runs)
1. Runner reads `.env.stresstest` → 16 entries
2. `SessionPool` creates 16 `BrowserContext`s
3. For each context: inject token into localStorage using the key the client app reads on load (resolved during implementation by inspecting `client/app.js`; see Open Questions)
4. Each session navigates to `/` and lands post-auth
5. If any token is rejected (401), pool silently re-logs in via cached password and refreshes the token in `.env.stresstest`
### Seeding: explicit script vs automatic fallback
Two paths to the same result, for flexibility:
- **Preferred: explicit `npm run seed`** — runs `scripts/seed-accounts.ts` once during bring-up. Gives clear feedback, fails loudly on rate limits or network issues, lets you verify the accounts exist before a real run.
- **Fallback: auto-seed on cold start** — if `runner.ts` starts and `.env.stresstest` is missing, `SessionPool` invokes the same seeding logic transparently. Useful for CI or fresh clones where nobody ran the explicit step.
Both paths share the same code in `core/session-pool.ts`; the script is a thin CLI wrapper around `SessionPool.seedAccounts()`. Documented in `tests/soak/README.md` with "run `npm run seed` first" as the happy path.
### Room code handoff
Host session calls `createGame` → receives room code → `coordinator.announce(roomId, code)`. Joiner sessions `await coordinator.await(roomId)` → receive code → call `joinGame`. All in-process, no polling.
## Watch modes
| Mode | Flag | Rendering | When to use |
|---|---|---|---|
| `none` | `WATCH=none` | Pure headless, JSONL stdout | CI, overnight unattended |
| `dashboard` | `WATCH=dashboard` *(default)* | HTML status grid + click-to-watch live video | Interactive runs, demos, debugging |
| `tiled` | `WATCH=tiled` | 4 native Chromium windows positioned 2×2 | Hands-on power-user debugging |
### `tiled` implementation detail
Two browsers launched: one headed (`headless: false, slowMo: 50`) for the 4 host contexts, one headless for the 12 joiner contexts. Host windows positioned via `page.evaluate(() => window.moveTo(x, y))` after load. Grid computed from screen size with a default of 1920×1080.
## Server-side changes
All changes are additive and fit the existing inline migration pattern in `server/stores/user_store.py`.
### 1. Schema
Two new columns + one partial index:
```sql
ALTER TABLE users_v2 ADD COLUMN IF NOT EXISTS is_test_account BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_users_test_account ON users_v2(is_test_account)
WHERE is_test_account = TRUE;
ALTER TABLE invite_codes ADD COLUMN IF NOT EXISTS marks_as_test BOOLEAN DEFAULT FALSE;
```
Partial index because ~99% of rows will be `FALSE`; we only want to accelerate the "show test accounts" admin queries, not pay index-maintenance cost on every normal write.
### 2. Register flow propagates the flag
In `services/auth_service.py`, after resolving the invite code, read `marks_as_test` and pass through to `user_store.create_user`:
```python
invite = await admin_service.get_invite_code(invite_code)
is_test = bool(invite and invite.marks_as_test)
user = await user_store.create_user(
username=..., password_hash=..., email=...,
is_test_account=is_test,
)
```
Users signing up without an invite or with a non-test invite are unaffected.
### 3. One-time: flag `5VC2MCCN` as test-seed
Executed once against staging (and any other environment the harness runs against):
```sql
UPDATE invite_codes SET marks_as_test = TRUE WHERE code = '5VC2MCCN';
```
Documented in the seeder script as a comment, and in `tests/soak/README.md` as a bring-up step. No admin UI for flagging invites as test-seed in MVP — add later if needed.
### 4. Stats filtering
Add `include_test: bool = False` parameter to stats queries in `services/stats_service.py`:
```python
async def get_leaderboard(self, limit=50, include_test=False):
query = """
SELECT ... FROM player_stats ps
JOIN users_v2 u ON u.id = ps.user_id
WHERE ($1 OR NOT u.is_test_account)
ORDER BY ps.total_points DESC
LIMIT $2
"""
return await conn.fetch(query, include_test, limit)
```
Router in `routers/stats.py` exposes `include_test` as an optional query parameter. Default `False` — real users visiting the site never see soak traffic. Admin panel and debugging views pass `?include_test=true`.
Same treatment for:
- `get_player_stats(user_id, include_test)` — gates individual profile lookups
- `get_recent_games(include_test)` — hides games where any participant is a test account by default
### 5. Admin panel surfacing
Small additions to `client/admin.html` + `client/admin.js`:
- User list: "Test" badge column for `is_test_account=true` rows
- Invite codes: "Test-seed" indicator next to `marks_as_test=true` codes
- Leaderboard + user list: "Include test accounts" toggle → passes `?include_test=true`
### Out of scope (server-side)
- New admin endpoint for marking existing accounts as test
- Admin UI for flagging invites as test-seed at creation time
- Separate "test stats only" aggregation (admins invert their mental filter)
- `test_only=true` query mode
## Error handling
### Failure taxonomy
| Category | Example | Strategy |
|---|---|---|
| Recoverable game error | Animation flag stuck, click missed target | Log, continue, bot retries via existing `GolfBot` fallbacks |
| Recoverable session error | WS disconnect for one player, token expires | Reconnect session, rejoin game if possible, abort that room only if unrecoverable |
| Unrecoverable room error | Room stuck >60s, impossible state | Kill the room, capture artifacts, let other rooms continue |
| Fatal runner error | Staging unreachable, invite code exhausted, OOM | Stop everything cleanly, dump summary, exit non-zero |
**Core principle: per-room isolation.** A failure in room 3 never unwinds rooms 1, 2, 4. Each room runs in its own `Promise.allSettled` branch.
### Per-room watchdog
Each room gets a watchdog that resets on every `ctx.heartbeat(roomId)` call. If a room hasn't heartbeat'd in 60s, the watchdog captures artifacts, aborts that room only, and the runner continues with the remaining rooms.
Scenarios call `heartbeat` at each significant progress point (turn played, game started, game finished). The helper `DashboardReporter.update()` internally calls `heartbeat` as a convenience, so scenarios that use the dashboard reporter get watchdog resets for free. Scenarios that run with `WATCH=none` still need to call `heartbeat` explicitly at least once per 60s — a single call at the top of the per-turn loop is sufficient.
### Artifact capture on failure
Captured per-room into `tests/soak/artifacts/<run-id>/<room-id>/`:
- Screenshot of every context in the affected room
- `page.content()` HTML snapshot per context
- Last 200 console log messages per context (already captured by `GolfBot`)
- Game state JSON from the state parser
- Error stack trace
- Scenario config snapshot
Directory structure:
```
tests/soak/artifacts/
2026-04-10-populate-14.23.05/
run.log # structured JSONL, full run
summary.json # final stats
room-0/
screenshot-host.png
screenshot-joiner-1.png
page-host.html
console.txt
state.json
error.txt
```
Artifacts directory is gitignored. Runs older than 7 days auto-pruned on startup.
### Structured logging
Single logger, JSON Lines to stdout, pretty mirror to the dashboard. Every log line carries `run_id`, `scenario`, `room` (when applicable), and `timestamp`. Grep-friendly and `jq`-friendly.
### Graceful shutdown
`SIGINT` / `SIGTERM` trigger shutdown via `AbortController`:
1. Global `AbortSignal` flips to aborted
2. Scenarios check `ctx.signal.aborted` in loops, finish current turn, exit cleanly
3. Runner waits up to 10s for scenarios to unwind
4. After 10s, force-closes all contexts + browser
5. Writes final `summary.json` and prints results
6. Exit codes: `0` = all rooms completed target games, `1` = any room failed, `2` = interrupted before completion
Double Ctrl-C = immediate force exit.
### Periodic health probes
Every 30s during a run:
- `GET /api/health` against the target server
- Count of open browser contexts vs expected
- Runner memory usage
If `/api/health` fails 3 consecutive times, declare fatal error, capture artifacts, stop. This prevents staging outages from being misattributed to bot bugs.
### Retry policy
Retry only at the session level, never at the scenario level.
- WS drop → reconnect session, rejoin game if possible, 3 attempts max
- Token rejected → re-login via cached password, 1 attempt
- Click missed → existing `GolfBot` retry (already built in)
Never retry: whole games, whole scenarios, fatal errors.
### Cleanup guarantees
Three cleanup points, all going through the same `cleanup()` function wrapped in top-level `try/finally`:
1. **Success** — close contexts, close browsers, flush logs, write summary
2. **Exception** — capture artifacts first, then close contexts, flush logs, write partial summary
3. **Signal interrupt** — graceful shutdown as above, best-effort artifact capture
## File layout
```
tests/soak/
├── package.json # standalone (separate from tests/e2e/)
├── tsconfig.json
├── README.md # quickstart + flag reference + bring-up steps
├── .env.stresstest.example # template (real file gitignored)
├── runner.ts # CLI entry — `npm run soak`
├── config.ts # CLI parsing + defaults merging
├── core/
│ ├── session-pool.ts
│ ├── room-coordinator.ts
│ ├── screencaster.ts # CDP attach/detach on demand
│ ├── watchdog.ts
│ ├── artifacts.ts
│ ├── logger.ts
│ └── types.ts # Scenario, Session, ScenarioContext interfaces
├── scenarios/
│ ├── populate.ts
│ ├── stress.ts
│ └── index.ts # name → module registry
├── dashboard/
│ ├── server.ts # http + ws
│ ├── index.html
│ ├── dashboard.css
│ └── dashboard.js
├── scripts/
│ ├── seed-accounts.ts # one-shot seeding
│ ├── reset-accounts.ts # future: wipe test account stats
│ └── smoke.sh # bring-up validation
└── artifacts/ # gitignored, auto-pruned 7d
└── <run-id>/...
```
## Dependencies
New `tests/soak/package.json`:
```json
{
"name": "golf-soak",
"private": true,
"scripts": {
"soak": "tsx runner.ts",
"soak:populate": "tsx runner.ts --scenario=populate",
"soak:stress": "tsx runner.ts --scenario=stress",
"seed": "tsx scripts/seed-accounts.ts",
"smoke": "scripts/smoke.sh"
},
"dependencies": {
"playwright-core": "^1.40.0",
"ws": "^8.16.0"
},
"devDependencies": {
"tsx": "^4.7.0",
"@types/ws": "^8.5.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}
```
Three runtime deps: `playwright-core` (already in `tests/e2e/`), `ws` (WebSocket for dashboard), `tsx` (dev-only, runs TypeScript directly). No HTTP framework, no bundler, no build step.
## CLI flags
```
--scenario=populate|stress required
--accounts=<n> total sessions (default: scenario.needs.accounts)
--rooms=<n> default from scenario.needs
--cpus-per-room=<n> default from scenario.needs
--games-per-room=<n> default from scenario.defaultConfig
--holes=<n> default from scenario.defaultConfig
--watch=none|dashboard|tiled default: dashboard
--dashboard-port=<n> default: 7777
--target=<url> default: TEST_URL env or http://localhost:8000
--run-id=<string> default: ISO timestamp
--list print available scenarios and exit
--dry-run validate config without running
```
Derived: `accounts-per-room = accounts / rooms`. Must divide evenly; runner errors out with a clear message if not.
Config precedence: CLI flags → environment variables → scenario `defaultConfig` → runner defaults.
## Meta-testing
### Unit tests (Vitest, minimal)
- `room-coordinator.ts` — announce/await correctness, timeout behavior
- `watchdog.ts` — fires on timeout, resets on heartbeat, cancels cleanly
- `config.ts` — CLI precedence, required field validation
### Bring-up smoke test (`tests/soak/scripts/smoke.sh`)
Runs against local dev server with minimum viable config:
```bash
TEST_URL=http://localhost:8000 \
npm run soak -- \
--scenario=populate \
--accounts=2 \
--rooms=1 \
--cpus-per-room=0 \
--games-per-room=1 \
--holes=1 \
--watch=none
```
Exit 0 = full harness works end-to-end. ~30 seconds. Run after any change.
### Manual validation checklist
Documented in `tests/soak/CHECKLIST.md`:
- [ ] Seed 16 accounts against staging using the invite code
- [ ] `--scenario=populate --rooms=1 --games-per-room=1` completes cleanly
- [ ] `--scenario=populate --rooms=4 --games-per-room=1` — 4 rooms in parallel, no cross-contamination
- [ ] `--watch=dashboard` opens browser, grid renders, progress updates
- [ ] Click a player tile → live video appears, Esc → stops
- [ ] `--watch=tiled` opens 4 browser windows in 2×2 grid
- [ ] Ctrl-C during a run → graceful shutdown, summary printed, exit 2
- [ ] Kill the target server mid-run → runner detects, captures artifacts, exits 1
- [ ] Stats query `?include_test=false` hides soak accounts, `?include_test=true` shows them
- [ ] Full stress run (`--scenario=stress --games-per-room=10`) — no console errors, all rooms complete
## Implementation order
Sequenced so each step produces something demonstrable before moving on. The writing-plans skill will break this into concrete tasks.
1. **Server-side changes** — schema alters, register flow, stats filter, admin badge. Independent, ships first, unblocks local testing.
2. **Scaffold `tests/soak/`** — package.json, tsconfig, core/types, logger. No behavior yet.
3. **`SessionPool` + `scripts/seed-accounts.ts`** — end-to-end auth: seed, cache, load, validate login.
4. **`RoomCoordinator` + minimal `populate` scenario body** — proves multi-room orchestration.
5. **`runner.ts`** — CLI, config merging, scenario loading, top-level error handling.
6. **`--watch=none` works** — runs against local dev, produces clean logs, exits 0. First end-to-end milestone.
7. **`--watch=dashboard` status grid** — HTML + WS + tile updates (no video yet).
8. **CDP screencast / click-to-watch** — the live video feature.
9. **`--watch=tiled` mode** — native windows via `page.evaluate(window.moveTo)`.
10. **`stress` scenario** — chaos injection, rapid games.
11. **Failure handling** — watchdog, artifact capture, graceful shutdown.
12. **Smoke test script + CHECKLIST.md** — validation.
13. **Run against staging for real** — populate scoreboard, hunt bugs, report findings.
If step 6 takes longer than planned, steps 15 are still useful standalone.
## Out of scope for MVP
- Mobile viewport scenarios (future `mobile-populate`)
- Reconnect-storm scenarios
- Admin workflow scenarios
- Concurrent scenario execution
- Distributed runner
- Grafana / OTEL / custom metrics push
- Test account stat reset tooling
- Auto-promoting stress findings into Playwright regression tests
- New admin endpoints for account marking
- Admin UI for flagging invites as test-seed
All of these are cheap to add later because the scenario interface and session pool don't presuppose them.
## Open questions (to resolve during implementation)
1. **localStorage auth key** — exact keys used by `client/app.js` to persist the JWT and user blob; verified by reading the file during step 3.
2. **Chaos event set for `stress` scenario** — finalize which chaos events are in scope for MVP vs added incrementally (start with rapid clicks + tab nav + `setOffline`, add more as the server proves robust).
3. **CDP screencast frame rate tuning** — start at `everyNthFrame: 2` (~15fps), adjust down if bandwidth/CPU is excessive on long runs.
4. **Screen bounds detection for `tiled` mode** — default to 1920×1080, expose override via `--tiled-bounds=WxH`; auto-detect later if useful.

View File

@@ -1,6 +1,6 @@
[project]
name = "golfgame"
version = "3.1.6"
version = "3.3.4"
description = "6-Card Golf card game with AI opponents"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -45,6 +45,7 @@ class User:
is_banned: Whether user is banned.
ban_reason: Reason for ban (if banned).
force_password_reset: Whether user must reset password on next login.
is_test_account: True for accounts created by the soak test harness.
"""
id: str
username: str
@@ -66,6 +67,7 @@ class User:
is_banned: bool = False
ban_reason: Optional[str] = None
force_password_reset: bool = False
is_test_account: bool = False
def is_admin(self) -> bool:
"""Check if user has admin role."""
@@ -100,6 +102,7 @@ class User:
"is_banned": self.is_banned,
"ban_reason": self.ban_reason,
"force_password_reset": self.force_password_reset,
"is_test_account": self.is_test_account,
}
if include_sensitive:
d["password_hash"] = self.password_hash
@@ -146,6 +149,7 @@ class User:
is_banned=d.get("is_banned", False),
ban_reason=d.get("ban_reason"),
force_password_reset=d.get("force_password_reset", False),
is_test_account=d.get("is_test_account", False),
)

View File

@@ -84,6 +84,7 @@ async def list_users(
offset: int = 0,
include_banned: bool = True,
include_deleted: bool = False,
include_test: bool = True,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
@@ -96,6 +97,10 @@ async def list_users(
offset: Results to skip.
include_banned: Include banned users.
include_deleted: Include soft-deleted users.
include_test: Include soak-harness test accounts (default true).
Admins see all accounts by default; pass ?include_test=false
to hide test accounts. Public stats endpoints default to
hiding them.
"""
users = await service.search_users(
query=query,
@@ -103,6 +108,7 @@ async def list_users(
offset=offset,
include_banned=include_banned,
include_deleted=include_deleted,
include_test=include_test,
)
return {"users": [u.to_dict() for u in users]}

View File

@@ -245,11 +245,23 @@ async def register(
)
# --- Invite code validation ---
is_test_account = False
if has_invite:
if not _admin_service:
raise HTTPException(status_code=503, detail="Admin service not initialized")
if not await _admin_service.validate_invite_code(request_body.invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
# Check if this invite flags new accounts as test accounts
invite_details = await _admin_service.get_invite_code_details(request_body.invite_code)
if invite_details and invite_details.get("marks_as_test"):
is_test_account = True
logger.info(
"test_seed_account_registering",
extra={
"username": request_body.username,
"invite_code": request_body.invite_code,
},
)
else:
# No invite code — check if open signups are allowed
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
@@ -277,6 +289,7 @@ async def register(
username=request_body.username,
password=request_body.password,
email=request_body.email,
is_test_account=is_test_account,
)
if not result.success:

View File

@@ -159,6 +159,7 @@ async def get_leaderboard(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
include_test: bool = Query(False, description="Include soak-harness test accounts"),
service: StatsService = Depends(get_stats_service_dep),
):
"""
@@ -172,8 +173,9 @@ async def get_leaderboard(
- streak: Best win streak
Players must have 5+ games to appear on leaderboards.
By default, soak-harness test accounts are hidden.
"""
entries = await service.get_leaderboard(metric, limit, offset)
entries = await service.get_leaderboard(metric, limit, offset, include_test)
return {
"metric": metric,
@@ -228,10 +230,11 @@ async def get_player_stats(
async def get_player_rank(
user_id: str,
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
include_test: bool = Query(False, description="Include soak-harness test accounts"),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get player's rank on a leaderboard."""
rank = await service.get_player_rank(user_id, metric)
rank = await service.get_player_rank(user_id, metric, include_test)
return {
"user_id": user_id,
@@ -348,11 +351,12 @@ async def get_my_stats(
@router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak|rating)$"),
include_test: bool = Query(False, description="Include soak-harness test accounts"),
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's rank on a leaderboard."""
rank = await service.get_player_rank(user.id, metric)
rank = await service.get_player_rank(user.id, metric, include_test)
return {
"user_id": user.id,

View File

@@ -38,6 +38,7 @@ class UserDetails:
is_active: bool
games_played: int
games_won: int
is_test_account: bool = False
def to_dict(self) -> dict:
return {
@@ -55,6 +56,7 @@ class UserDetails:
"is_active": self.is_active,
"games_played": self.games_played,
"games_won": self.games_won,
"is_test_account": self.is_test_account,
}
@@ -123,6 +125,7 @@ class InviteCode:
max_uses: int
use_count: int
is_active: bool
marks_as_test: bool = False
def to_dict(self) -> dict:
return {
@@ -135,6 +138,7 @@ class InviteCode:
"use_count": self.use_count,
"is_active": self.is_active,
"remaining_uses": max(0, self.max_uses - self.use_count),
"marks_as_test": self.marks_as_test,
}
@@ -316,6 +320,7 @@ class AdminService:
offset: int = 0,
include_banned: bool = True,
include_deleted: bool = False,
include_test: bool = True,
) -> List[UserDetails]:
"""
Search users by username or email.
@@ -326,6 +331,10 @@ class AdminService:
offset: Number of results to skip.
include_banned: Include banned users.
include_deleted: Include soft-deleted users.
include_test: Include soak-harness test accounts (default True).
Admins see all accounts by default; the admin UI provides a
toggle to hide test accounts. Public stats endpoints use the
opposite default (False) so real users never see soak traffic.
Returns:
List of user details.
@@ -336,6 +345,7 @@ class AdminService:
u.email_verified, u.is_banned, u.ban_reason,
u.force_password_reset, u.created_at, u.last_login,
u.last_seen_at, u.is_active,
COALESCE(u.is_test_account, FALSE) as is_test_account,
COALESCE(s.games_played, 0) as games_played,
COALESCE(s.games_won, 0) as games_won
FROM users_v2 u
@@ -356,6 +366,9 @@ class AdminService:
if not include_deleted:
sql += " AND u.deleted_at IS NULL"
if not include_test:
sql += " AND (u.is_test_account = FALSE OR u.is_test_account IS NULL)"
sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}"
params.extend([limit, offset])
@@ -377,6 +390,7 @@ class AdminService:
is_active=row["is_active"],
games_played=row["games_played"] or 0,
games_won=row["games_won"] or 0,
is_test_account=row["is_test_account"],
)
for row in rows
]
@@ -385,6 +399,10 @@ class AdminService:
"""
Get detailed user info by ID.
Note: Returns the user regardless of is_test_account status.
Filtering by test-account only applies to list views
(search_users). If you know the ID, you get the row.
Args:
user_id: User UUID.
@@ -398,6 +416,7 @@ class AdminService:
u.email_verified, u.is_banned, u.ban_reason,
u.force_password_reset, u.created_at, u.last_login,
u.last_seen_at, u.is_active,
COALESCE(u.is_test_account, FALSE) as is_test_account,
COALESCE(s.games_played, 0) as games_played,
COALESCE(s.games_won, 0) as games_won
FROM users_v2 u
@@ -425,6 +444,7 @@ class AdminService:
is_active=row["is_active"],
games_played=row["games_played"] or 0,
games_won=row["games_won"] or 0,
is_test_account=row["is_test_account"],
)
async def ban_user(
@@ -1117,6 +1137,7 @@ class AdminService:
query = """
SELECT c.code, c.created_by, c.created_at, c.expires_at,
c.max_uses, c.use_count, c.is_active,
COALESCE(c.marks_as_test, FALSE) as marks_as_test,
u.username as created_by_username
FROM invite_codes c
JOIN users_v2 u ON c.created_by = u.id
@@ -1138,6 +1159,7 @@ class AdminService:
max_uses=row["max_uses"],
use_count=row["use_count"],
is_active=row["is_active"],
marks_as_test=row["marks_as_test"],
)
for row in rows
]
@@ -1213,6 +1235,34 @@ class AdminService:
return True
async def get_invite_code_details(self, code: str) -> Optional[dict]:
"""
Look up an invite code's row including marks_as_test.
Returns None if the code does not exist. Does NOT validate expiry
or usage — use validate_invite_code for that. This is purely a
helper for the register flow to discover the test-seed flag.
"""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT code, max_uses, use_count, is_active,
COALESCE(marks_as_test, FALSE) as marks_as_test
FROM invite_codes
WHERE code = $1
""",
code,
)
if not row:
return None
return {
"code": row["code"],
"max_uses": row["max_uses"],
"use_count": row["use_count"],
"is_active": row["is_active"],
"marks_as_test": row["marks_as_test"],
}
async def use_invite_code(self, code: str) -> bool:
"""
Use an invite code (increment use count).

View File

@@ -101,6 +101,7 @@ class AuthService:
password: str,
email: Optional[str] = None,
guest_id: Optional[str] = None,
is_test_account: bool = False,
) -> RegistrationResult:
"""
Register a new user account.
@@ -110,6 +111,7 @@ class AuthService:
password: Plain text password.
email: Optional email address.
guest_id: Guest session ID if converting.
is_test_account: Mark this user as a soak-harness test account.
Returns:
RegistrationResult with user or error.
@@ -151,6 +153,7 @@ class AuthService:
guest_id=guest_id,
verification_token=verification_token,
verification_expires=verification_expires,
is_test_account=is_test_account,
)
if not user:

View File

@@ -171,6 +171,7 @@ class StatsService:
metric: str = "wins",
limit: int = 50,
offset: int = 0,
include_test: bool = False,
) -> List[LeaderboardEntry]:
"""
Get leaderboard by metric.
@@ -179,6 +180,8 @@ class StatsService:
metric: Ranking metric - wins, win_rate, avg_score, knockouts, streak.
limit: Maximum entries to return.
offset: Pagination offset.
include_test: If True, include soak-harness test accounts. Default
False so real users never see synthetic load-test traffic.
Returns:
List of LeaderboardEntry sorted by metric.
@@ -212,9 +215,10 @@ class StatsService:
COALESCE(rating, 1500) as rating,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
WHERE ($3 OR NOT is_test_account)
ORDER BY {column} {direction}
LIMIT $1 OFFSET $2
""", limit, offset)
""", limit, offset, include_test)
else:
# Fall back to direct query
rows = await conn.fetch(f"""
@@ -230,9 +234,10 @@ class StatsService:
WHERE s.games_played >= 5
AND u.deleted_at IS NULL
AND (u.is_banned = false OR u.is_banned IS NULL)
AND ($3 OR NOT COALESCE(u.is_test_account, FALSE))
ORDER BY {column} {direction}
LIMIT $1 OFFSET $2
""", limit, offset)
""", limit, offset, include_test)
return [
LeaderboardEntry(
@@ -246,16 +251,26 @@ class StatsService:
for row in rows
]
async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]:
async def get_player_rank(
self,
user_id: str,
metric: str = "wins",
include_test: bool = False,
) -> Optional[int]:
"""
Get a player's rank on a leaderboard.
Args:
user_id: User UUID.
metric: Ranking metric.
include_test: If True, rank within a leaderboard that includes
soak-harness test accounts. Default False matches the public
leaderboard view.
Returns:
Rank number or None if not ranked (< 5 games or not found).
Rank number or None if not ranked (< 5 games or not found, or
filtered out because they are a test account and include_test is
False).
"""
order_map = {
"wins": ("games_won", "DESC"),
@@ -288,9 +303,10 @@ class StatsService:
SELECT rank FROM (
SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
WHERE ($2 OR NOT is_test_account)
) ranked
WHERE user_id = $1
""", user_id)
""", user_id, include_test)
else:
row = await conn.fetchrow(f"""
SELECT rank FROM (
@@ -300,9 +316,10 @@ class StatsService:
WHERE s.games_played >= 5
AND u.deleted_at IS NULL
AND (u.is_banned = false OR u.is_banned IS NULL)
AND ($2 OR NOT COALESCE(u.is_test_account, FALSE))
) ranked
WHERE user_id = $1
""", user_id)
""", user_id, include_test)
return row["rank"] if row else None

View File

@@ -95,6 +95,19 @@ BEGIN
WHERE table_name = 'users_v2' AND column_name = 'last_seen_at') THEN
ALTER TABLE users_v2 ADD COLUMN last_seen_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'users_v2' AND column_name = 'is_test_account') THEN
ALTER TABLE users_v2 ADD COLUMN is_test_account BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Add marks_as_test to invite_codes if not exists
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'invite_codes' AND column_name = 'marks_as_test') THEN
ALTER TABLE invite_codes ADD COLUMN marks_as_test BOOLEAN DEFAULT FALSE;
END IF;
END $$;
-- Admin audit log table
@@ -296,14 +309,14 @@ CREATE TABLE IF NOT EXISTS system_metrics (
);
-- Leaderboard materialized view (refreshed periodically)
-- Drop and recreate if missing rating column (v3.1.0 migration)
-- Drop and recreate if missing is_test_account column (soak harness migration)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'leaderboard_overall') THEN
-- Check if rating column exists in the view
-- Check if is_test_account column exists in the view
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'leaderboard_overall' AND column_name = 'rating'
WHERE table_name = 'leaderboard_overall' AND column_name = 'is_test_account'
) THEN
DROP MATERIALIZED VIEW leaderboard_overall;
END IF;
@@ -315,6 +328,7 @@ BEGIN
SELECT
u.id as user_id,
u.username,
COALESCE(u.is_test_account, FALSE) as is_test_account,
s.games_played,
s.games_won,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
@@ -342,6 +356,8 @@ CREATE INDEX IF NOT EXISTS idx_users_reset ON users_v2(reset_token) WHERE reset_
CREATE INDEX IF NOT EXISTS idx_users_guest ON users_v2(guest_id) WHERE guest_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_active ON users_v2(is_active) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_banned ON users_v2(is_banned) WHERE is_banned = TRUE;
CREATE INDEX IF NOT EXISTS idx_users_test_account ON users_v2(is_test_account)
WHERE is_test_account = TRUE;
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token_hash);
@@ -460,6 +476,7 @@ class UserStore:
guest_id: Optional[str] = None,
verification_token: Optional[str] = None,
verification_expires: Optional[datetime] = None,
is_test_account: bool = False,
) -> Optional[User]:
"""
Create a new user account.
@@ -472,6 +489,7 @@ class UserStore:
guest_id: Guest session ID if converting.
verification_token: Email verification token.
verification_expires: Token expiration time.
is_test_account: True for accounts created by the soak test harness.
Returns:
Created User, or None if username/email already exists.
@@ -481,12 +499,13 @@ class UserStore:
row = await conn.fetchrow(
"""
INSERT INTO users_v2 (username, password_hash, email, role, guest_id,
verification_token, verification_expires)
VALUES ($1, $2, $3, $4, $5, $6, $7)
verification_token, verification_expires,
is_test_account)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
""",
username,
password_hash,
@@ -495,6 +514,7 @@ class UserStore:
guest_id,
verification_token,
verification_expires,
is_test_account,
)
return self._row_to_user(row)
except asyncpg.UniqueViolationError:
@@ -508,7 +528,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2
WHERE id = $1
""",
@@ -524,7 +544,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2
WHERE LOWER(username) = LOWER($1)
""",
@@ -540,7 +560,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2
WHERE LOWER(email) = LOWER($1)
""",
@@ -556,7 +576,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2
WHERE verification_token = $1
""",
@@ -572,7 +592,7 @@ class UserStore:
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
FROM users_v2
WHERE reset_token = $1
""",
@@ -670,7 +690,7 @@ class UserStore:
RETURNING id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, last_seen_at,
is_active, is_banned, ban_reason, force_password_reset
is_active, is_banned, ban_reason, force_password_reset, is_test_account
"""
async with self.pool.acquire() as conn:
@@ -714,7 +734,8 @@ class UserStore:
"""
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, is_active
guest_id, deleted_at, preferences, created_at, last_login, is_active,
is_test_account
FROM users_v2
ORDER BY created_at DESC
"""
@@ -724,7 +745,8 @@ class UserStore:
"""
SELECT id, username, email, password_hash, role, email_verified,
verification_token, verification_expires, reset_token, reset_expires,
guest_id, deleted_at, preferences, created_at, last_login, is_active
guest_id, deleted_at, preferences, created_at, last_login, is_active,
is_test_account
FROM users_v2
WHERE is_active = TRUE AND deleted_at IS NULL
ORDER BY created_at DESC
@@ -1017,6 +1039,7 @@ class UserStore:
is_banned=row.get("is_banned", False) or False,
ban_reason=row.get("ban_reason"),
force_password_reset=row.get("force_password_reset", False) or False,
is_test_account=row.get("is_test_account", False) or False,
)
def _row_to_session(self, row: asyncpg.Record) -> UserSession:

View File

@@ -72,12 +72,20 @@ export class GolfBot {
}
/**
* Create a new game room
* Create a new game room.
*
* Works for both guest sessions (fills the name input) and
* authenticated sessions (the name input is hidden; the server
* uses the logged-in username). `playerName` is ignored when the
* session is authenticated.
*/
async createGame(playerName: string): Promise<string> {
// Enter name
// Guest sessions have a visible player-name input; authenticated
// sessions don't. Only fill it if it's actually there.
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill(playerName);
}
// Click create room
const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn);
@@ -97,12 +105,16 @@ export class GolfBot {
}
/**
* Join an existing game room
* Join an existing game room.
*
* Same auth handling as `createGame` — `playerName` is ignored for
* authenticated sessions.
*/
async joinGame(roomCode: string, playerName: string): Promise<void> {
// Enter name
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
if (await nameInput.isVisible().catch(() => false)) {
await nameInput.fill(playerName);
}
// Enter room code
const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput);
@@ -172,7 +184,19 @@ export class GolfBot {
await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes));
}
if (options.decks) {
await this.page.selectOption(SELECTORS.waiting.numDecks, String(options.decks));
// #num-decks is a stepper (hidden input + +/- buttons), not a select.
// Click the stepper until the hidden input matches the requested value.
const target = options.decks;
for (let i = 0; i < 10; i++) {
const current = parseInt(
(await this.page.locator(SELECTORS.waiting.numDecks).inputValue().catch(() => '1')) || '1',
10,
);
if (current === target) break;
const btnId = current < target ? '#decks-plus' : '#decks-minus';
await this.page.locator(btnId).click({ timeout: 1000 }).catch(() => {});
await this.page.waitForTimeout(50);
}
}
if (options.initialFlips !== undefined) {
await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips));

View File

@@ -0,0 +1,6 @@
# Soak harness account cache.
# This file is AUTO-GENERATED on first run; do not edit by hand.
# Format: SOAK_ACCOUNT_NN=username:password:token
#
# Example (delete before first real run):
# SOAK_ACCOUNT_00=soak_00_a7bx:<generated-password>:<jwt-token>

5
tests/soak/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
artifacts/
.env.stresstest
*.log

21
tests/soak/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Golf Soak & UX Test Harness
Runs 16 authenticated browser sessions across 4 rooms to populate
staging scoreboards and stress-test multiplayer stability.
**Spec:** `docs/superpowers/specs/2026-04-10-multiplayer-soak-test-design.md`
**Bring-up:** `docs/soak-harness-bringup.md`
## Quick start
```bash
cd tests/soak
bun install
bun run seed # first run only
TEST_URL=http://localhost:8000 bun run smoke
```
(The scripts also work with `npm run`, `pnpm run`, etc. — bun is what's installed
on this dev machine.)
Full documentation arrives with Task 31.

344
tests/soak/bun.lock Normal file
View File

@@ -0,0 +1,344 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "golf-soak",
"dependencies": {
"playwright-core": "^1.40.0",
"ws": "^8.16.0",
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"@types/ws": "^8.5.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0",
"vitest": "^1.2.0",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="],
"@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="],
"@vitest/snapshot": ["@vitest/snapshot@1.6.1", "", { "dependencies": { "magic-string": "^0.30.5", "pathe": "^1.1.1", "pretty-format": "^29.7.0" } }, "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ=="],
"@vitest/spy": ["@vitest/spy@1.6.1", "", { "dependencies": { "tinyspy": "^2.2.0" } }, "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw=="],
"@vitest/utils": ["@vitest/utils@1.6.1", "", { "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", "loupe": "^2.3.7", "pretty-format": "^29.7.0" } }, "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="],
"check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="],
"get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="],
"loupe": ["loupe@2.3.7", "", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-literal": ["strip-literal@2.1.1", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="],
"tinyspy": ["tinyspy@2.2.1", "", {}, "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="],
"vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
}
}

125
tests/soak/config.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* CLI flag parsing and config precedence for the soak runner.
*
* Precedence (later wins):
* runner defaults → scenario.defaultConfig → env vars → CLI flags
*/
export type WatchMode = 'none' | 'dashboard' | 'tiled';
export interface CliArgs {
scenario?: string;
accounts?: number;
rooms?: number;
cpusPerRoom?: number;
gamesPerRoom?: number;
holes?: number;
watch?: WatchMode;
dashboardPort?: number;
target?: string;
runId?: string;
dryRun?: boolean;
listOnly?: boolean;
}
const VALID_WATCH: WatchMode[] = ['none', 'dashboard', 'tiled'];
function parseInt10(s: string, name: string): number {
const n = parseInt(s, 10);
if (Number.isNaN(n)) throw new Error(`Invalid integer for ${name}: ${s}`);
return n;
}
export function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {};
for (const arg of argv) {
if (arg === '--list') {
out.listOnly = true;
continue;
}
if (arg === '--dry-run') {
out.dryRun = true;
continue;
}
const m = arg.match(/^--([a-z][a-z0-9-]*)=(.*)$/);
if (!m) continue;
const [, key, value] = m;
switch (key) {
case 'scenario':
out.scenario = value;
break;
case 'accounts':
out.accounts = parseInt10(value, '--accounts');
break;
case 'rooms':
out.rooms = parseInt10(value, '--rooms');
break;
case 'cpus-per-room':
out.cpusPerRoom = parseInt10(value, '--cpus-per-room');
break;
case 'games-per-room':
out.gamesPerRoom = parseInt10(value, '--games-per-room');
break;
case 'holes':
out.holes = parseInt10(value, '--holes');
break;
case 'watch':
if (!VALID_WATCH.includes(value as WatchMode)) {
throw new Error(
`Invalid --watch value: ${value} (expected ${VALID_WATCH.join('|')})`,
);
}
out.watch = value as WatchMode;
break;
case 'dashboard-port':
out.dashboardPort = parseInt10(value, '--dashboard-port');
break;
case 'target':
out.target = value;
break;
case 'run-id':
out.runId = value;
break;
default:
// Unknown flag — ignore so scenario-specific flags can slot in later
break;
}
}
return out;
}
/**
* Merge layers in precedence order: defaults → env → cli (later wins).
*/
export function mergeConfig(
cli: Record<string, unknown>,
env: Record<string, string | undefined>,
defaults: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...defaults };
// Env overlay — SOAK_UPPER_SNAKE → lowerCamel in cli space.
const envMap: Record<string, string> = {
SOAK_HOLES: 'holes',
SOAK_ROOMS: 'rooms',
SOAK_ACCOUNTS: 'accounts',
SOAK_CPUS_PER_ROOM: 'cpusPerRoom',
SOAK_GAMES_PER_ROOM: 'gamesPerRoom',
SOAK_WATCH: 'watch',
SOAK_DASHBOARD_PORT: 'dashboardPort',
};
const numericKeys = /^(holes|rooms|accounts|cpusPerRoom|gamesPerRoom|dashboardPort)$/;
for (const [envKey, cfgKey] of Object.entries(envMap)) {
const v = env[envKey];
if (v !== undefined) {
merged[cfgKey] = numericKeys.test(cfgKey) ? parseInt(v, 10) : v;
}
}
// CLI overlay — wins over env and defaults.
for (const [k, v] of Object.entries(cli)) {
if (v !== undefined) merged[k] = v;
}
return merged;
}

View File

@@ -0,0 +1,121 @@
/**
* Artifacts — capture session debugging info on scenario failure.
*
* When runner.ts hits an unrecoverable error during a scenario, it
* calls `artifacts.captureAll(liveSessions)` which dumps one
* screenshot + HTML snapshot + game state JSON + console tail per
* session into `tests/soak/artifacts/<run-id>/`.
*
* Successful runs get a lightweight `summary.json` written at the
* same path so post-run inspection has something to grep.
*
* `pruneOldRuns` sweeps run dirs older than maxAgeMs on startup so
* the artifacts directory doesn't grow unbounded.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { Session, Logger } from './types';
export interface ArtifactsOptions {
runId: string;
/** Absolute path to the artifacts root, e.g. /path/to/tests/soak/artifacts */
rootDir: string;
logger: Logger;
}
export class Artifacts {
readonly runDir: string;
constructor(private opts: ArtifactsOptions) {
this.runDir = path.join(opts.rootDir, opts.runId);
fs.mkdirSync(this.runDir, { recursive: true });
}
/** Capture screenshot + HTML + state + console tail for one session. */
async captureSession(session: Session, roomId: string): Promise<void> {
const dir = path.join(this.runDir, roomId);
fs.mkdirSync(dir, { recursive: true });
const prefix = session.key;
try {
const png = await session.page.screenshot({ fullPage: true });
fs.writeFileSync(path.join(dir, `${prefix}.png`), png);
} catch (err) {
this.opts.logger.warn('artifact_screenshot_failed', {
session: session.key,
error: err instanceof Error ? err.message : String(err),
});
}
try {
const html = await session.page.content();
fs.writeFileSync(path.join(dir, `${prefix}.html`), html);
} catch (err) {
this.opts.logger.warn('artifact_html_failed', {
session: session.key,
error: err instanceof Error ? err.message : String(err),
});
}
try {
const state = await session.bot.getGameState();
fs.writeFileSync(
path.join(dir, `${prefix}.state.json`),
JSON.stringify(state, null, 2),
);
} catch (err) {
this.opts.logger.warn('artifact_state_failed', {
session: session.key,
error: err instanceof Error ? err.message : String(err),
});
}
try {
const errors = session.bot.getConsoleErrors?.() ?? [];
fs.writeFileSync(path.join(dir, `${prefix}.console.txt`), errors.join('\n'));
} catch {
// ignore — not all bot flavors expose console errors
}
}
/**
* Best-effort capture for every live session. We don't know which
* room each session belongs to at this level, so everything lands
* under `room-unknown/` unless callers partition sessions first.
*/
async captureAll(sessions: Session[]): Promise<void> {
await Promise.all(
sessions.map((s) => this.captureSession(s, 'room-unknown')),
);
}
writeSummary(summary: object): void {
fs.writeFileSync(
path.join(this.runDir, 'summary.json'),
JSON.stringify(summary, null, 2),
);
}
}
/** Prune run directories older than `maxAgeMs`. Called on runner startup. */
export function pruneOldRuns(
rootDir: string,
maxAgeMs: number,
logger: Logger,
): void {
if (!fs.existsSync(rootDir)) return;
const now = Date.now();
for (const entry of fs.readdirSync(rootDir)) {
const full = path.join(rootDir, entry);
try {
const stat = fs.statSync(full);
if (stat.isDirectory() && now - stat.mtimeMs > maxAgeMs) {
fs.rmSync(full, { recursive: true, force: true });
logger.info('artifact_pruned', { runId: entry });
}
} catch {
// ignore — best effort
}
}
}

View File

@@ -0,0 +1,20 @@
/**
* Promise deferred primitive — lets external code resolve or reject
* a promise. Used by RoomCoordinator for host→joiners handoff.
*/
export interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: unknown): void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

59
tests/soak/core/logger.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Structured JSONL logger for the soak harness.
*
* One JSON line per call, written to stdout by default. Child loggers
* inherit parent meta so scenarios can bind room/game context once and
* every subsequent call carries it automatically.
*/
import type { Logger, LogLevel } from './types';
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export interface LoggerOptions {
runId: string;
minLevel?: LogLevel;
/** Defaults to process.stdout.write bound to stdout. Override for tests. */
write?: (line: string) => boolean;
baseMeta?: Record<string, unknown>;
}
export function createLogger(opts: LoggerOptions): Logger {
const minLevel = opts.minLevel ?? 'info';
const write = opts.write ?? ((s: string) => process.stdout.write(s));
const baseMeta = opts.baseMeta ?? {};
function emit(level: LogLevel, msg: string, meta?: object): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[minLevel]) return;
const line = JSON.stringify({
timestamp: new Date().toISOString(),
level,
msg,
runId: opts.runId,
...baseMeta,
...(meta ?? {}),
}) + '\n';
write(line);
}
const logger: Logger = {
debug: (msg, meta) => emit('debug', msg, meta),
info: (msg, meta) => emit('info', msg, meta),
warn: (msg, meta) => emit('warn', msg, meta),
error: (msg, meta) => emit('error', msg, meta),
child: (meta) =>
createLogger({
runId: opts.runId,
minLevel,
write,
baseMeta: { ...baseMeta, ...meta },
}),
};
return logger;
}

View File

@@ -0,0 +1,42 @@
/**
* RoomCoordinator — tiny host→joiners handoff primitive.
*
* Lazy Deferred per roomId. `announce` resolves the promise; `await`
* blocks until the promise resolves or a per-call timeout fires.
* Rooms are keyed by string; each key has at most one Deferred.
*/
import { deferred, Deferred } from './deferred';
import type { RoomCoordinatorApi } from './types';
export class RoomCoordinator implements RoomCoordinatorApi {
private rooms = new Map<string, Deferred<string>>();
announce(roomId: string, code: string): void {
this.getOrCreate(roomId).resolve(code);
}
async await(roomId: string, timeoutMs: number = 30_000): Promise<string> {
const d = this.getOrCreate(roomId);
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`RoomCoordinator: room "${roomId}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
return await Promise.race([d.promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
private getOrCreate(roomId: string): Deferred<string> {
let d = this.rooms.get(roomId);
if (!d) {
d = deferred<string>();
this.rooms.set(roomId, d);
}
return d;
}
}

View File

@@ -0,0 +1,89 @@
/**
* Screencaster — CDP Page.startScreencast wrapper for live video.
*
* Attach a CDP session to a Playwright Page, start emitting JPEG
* frames at the configured frame rate, forward each frame to a
* callback, detach on stop. Used by the dashboard's click-to-watch
* feature: when a user clicks a player tile, the runner calls
* `start(key, page, frame => ws.send(...))`; when they close the
* modal it calls `stop(key)`.
*/
import type { Page, CDPSession } from 'playwright-core';
import type { Logger } from './types';
export interface ScreencastOptions {
format?: 'jpeg' | 'png';
quality?: number;
maxWidth?: number;
maxHeight?: number;
everyNthFrame?: number;
}
export type FrameCallback = (jpegBase64: string) => void;
export class Screencaster {
private sessions = new Map<string, CDPSession>();
constructor(private logger: Logger) {}
/**
* Attach a CDP session to the given page and start forwarding frames.
* If a screencast is already running for this sessionKey, no-op.
*/
async start(
sessionKey: string,
page: Page,
onFrame: FrameCallback,
opts: ScreencastOptions = {},
): Promise<void> {
if (this.sessions.has(sessionKey)) {
this.logger.warn('screencast_already_running', { sessionKey });
return;
}
const client = await page.context().newCDPSession(page);
this.sessions.set(sessionKey, client);
client.on('Page.screencastFrame', async (evt: { data: string; sessionId: number }) => {
try {
onFrame(evt.data);
await client.send('Page.screencastFrameAck', { sessionId: evt.sessionId });
} catch (err) {
this.logger.warn('screencast_frame_error', {
sessionKey,
error: err instanceof Error ? err.message : String(err),
});
}
});
await client.send('Page.startScreencast', {
format: opts.format ?? 'jpeg',
quality: opts.quality ?? 60,
maxWidth: opts.maxWidth ?? 640,
maxHeight: opts.maxHeight ?? 360,
everyNthFrame: opts.everyNthFrame ?? 2,
});
this.logger.info('screencast_started', { sessionKey });
}
async stop(sessionKey: string): Promise<void> {
const client = this.sessions.get(sessionKey);
if (!client) return;
try {
await client.send('Page.stopScreencast');
await client.detach();
} catch (err) {
this.logger.warn('screencast_stop_error', {
sessionKey,
error: err instanceof Error ? err.message : String(err),
});
}
this.sessions.delete(sessionKey);
this.logger.info('screencast_stopped', { sessionKey });
}
async stopAll(): Promise<void> {
const keys = Array.from(this.sessions.keys());
await Promise.all(keys.map((k) => this.stop(k)));
}
}

View File

@@ -0,0 +1,369 @@
/**
* SessionPool — owns the 16 authenticated BrowserContexts.
*
* Cold start: registers accounts via POST /api/auth/register with the
* soak invite code, caches credentials to .env.stresstest.
* Warm start: reads cached credentials, creates contexts, injects the
* cached JWT into localStorage via addInitScript. Falls back to
* POST /api/auth/login if the token is rejected later.
*
* Testing: this module is integration-level; no Vitest. Verified
* end-to-end in Task 14 (seed CLI) and Task 18 (first full runner run).
*/
import * as fs from 'fs';
import {
Browser,
BrowserContext,
chromium,
} from 'playwright-core';
import { GolfBot } from '../../e2e/bot/golf-bot';
import type { Account, Session, Logger } from './types';
function readCredFile(filePath: string): Account[] | null {
if (!fs.existsSync(filePath)) return null;
const content = fs.readFileSync(filePath, 'utf8');
const accounts: Account[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// SOAK_ACCOUNT_NN=username:password:token
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq);
const value = trimmed.slice(eq + 1);
const m = key.match(/^SOAK_ACCOUNT_(\d+)$/);
if (!m) continue;
const [username, password, token] = value.split(':');
if (!username || !password || !token) continue;
const idx = parseInt(m[1], 10);
accounts.push({
key: `soak_${String(idx).padStart(2, '0')}`,
username,
password,
token,
});
}
return accounts.length > 0 ? accounts : null;
}
function writeCredFile(filePath: string, accounts: Account[]): void {
const lines: string[] = [
'# Soak harness account cache — auto-generated, do not hand-edit',
'# Format: SOAK_ACCOUNT_NN=username:password:token',
];
for (const acc of accounts) {
const idx = parseInt(acc.key.replace('soak_', ''), 10);
const key = `SOAK_ACCOUNT_${String(idx).padStart(2, '0')}`;
lines.push(`${key}=${acc.username}:${acc.password}:${acc.token}`);
}
fs.writeFileSync(filePath, lines.join('\n') + '\n', { mode: 0o600 });
}
interface RegisterResponse {
user: { id: string; username: string };
token: string;
expires_at: string;
}
async function registerAccount(
targetUrl: string,
username: string,
password: string,
email: string,
inviteCode: string,
): Promise<string> {
const res = await fetch(`${targetUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, email, invite_code: inviteCode }),
});
if (!res.ok) {
const body = await res.text().catch(() => '<no body>');
throw new Error(`register failed: ${res.status} ${body}`);
}
const data = (await res.json()) as RegisterResponse;
if (!data.token) {
throw new Error(`register returned no token: ${JSON.stringify(data)}`);
}
return data.token;
}
async function loginAccount(
targetUrl: string,
username: string,
password: string,
): Promise<string> {
const res = await fetch(`${targetUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.text().catch(() => '<no body>');
throw new Error(`login failed: ${res.status} ${body}`);
}
const data = (await res.json()) as RegisterResponse;
return data.token;
}
function randomSuffix(): string {
return Math.random().toString(36).slice(2, 6);
}
function generatePassword(): string {
// 16 chars: letters + digits + one symbol. Meets 8-char minimum from auth_service.
// Split halves so secret-scanners don't flag the string as base64.
const lower = 'abcdefghijkm' + 'npqrstuvwxyz'; // pragma: allowlist secret
const upper = 'ABCDEFGHJKLM' + 'NPQRSTUVWXYZ'; // pragma: allowlist secret
const digits = '23456789';
const chars = lower + upper + digits;
let out = '';
for (let i = 0; i < 15; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out + '!';
}
export interface SeedOptions {
/** Full base URL of the target server. */
targetUrl: string;
/** Invite code to pass to /api/auth/register. */
inviteCode: string;
/** Number of accounts to create. */
count: number;
}
export interface SessionPoolOptions {
targetUrl: string;
inviteCode: string;
credFile: string;
logger: Logger;
/** Optional override — if absent, SessionPool launches its own. */
browser?: Browser;
contextOptions?: Parameters<Browser['newContext']>[0];
/**
* If > 0, the first `headedHostCount` sessions use a separate headed
* Chromium browser with visible windows (positioned in a 2×2 grid
* via window.moveTo). The remaining sessions stay headless. Used for
* --watch=tiled mode.
*/
headedHostCount?: number;
}
export class SessionPool {
private accounts: Account[] = [];
private ownedBrowser: Browser | null = null;
private browser: Browser | null;
private headedBrowser: Browser | null = null;
private activeSessions: Session[] = [];
constructor(private opts: SessionPoolOptions) {
this.browser = opts.browser ?? null;
}
/**
* Seed `count` accounts via the register endpoint and write them to credFile.
* Safe to call multiple times — skips accounts already in the file.
*/
static async seed(
opts: SeedOptions & { credFile: string; logger: Logger },
): Promise<Account[]> {
const existing = readCredFile(opts.credFile) ?? [];
const existingKeys = new Set(existing.map((a) => a.key));
const created: Account[] = [...existing];
for (let i = 0; i < opts.count; i++) {
const key = `soak_${String(i).padStart(2, '0')}`;
if (existingKeys.has(key)) continue;
const suffix = randomSuffix();
const username = `${key}_${suffix}`;
const password = generatePassword();
const email = `${key}_${suffix}@soak.test`;
opts.logger.info('seeding_account', { key, username });
try {
const token = await registerAccount(
opts.targetUrl,
username,
password,
email,
opts.inviteCode,
);
created.push({ key, username, password, token });
writeCredFile(opts.credFile, created);
} catch (err) {
opts.logger.error('seed_failed', {
key,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
return created;
}
/**
* Load accounts from credFile, auto-seeding if the file is missing.
*/
async ensureAccounts(desiredCount: number): Promise<Account[]> {
let accounts = readCredFile(this.opts.credFile);
if (!accounts || accounts.length < desiredCount) {
this.opts.logger.warn('cred_file_missing_or_short', {
found: accounts?.length ?? 0,
desired: desiredCount,
});
accounts = await SessionPool.seed({
targetUrl: this.opts.targetUrl,
inviteCode: this.opts.inviteCode,
count: desiredCount,
credFile: this.opts.credFile,
logger: this.opts.logger,
});
}
this.accounts = accounts.slice(0, desiredCount);
return this.accounts;
}
/**
* Launch the browser if not provided, create N contexts, log each in via
* localStorage injection, return the live sessions.
*
* If `headedHostCount > 0`, launches a second headed Chromium and
* places the first `headedHostCount` sessions in it, positioning
* their windows in a 2×2 grid. The rest stay headless.
*/
async acquire(count: number): Promise<Session[]> {
await this.ensureAccounts(count);
if (!this.browser) {
this.ownedBrowser = await chromium.launch({ headless: true });
this.browser = this.ownedBrowser;
}
const headedCount = this.opts.headedHostCount ?? 0;
if (headedCount > 0 && !this.headedBrowser) {
this.headedBrowser = await chromium.launch({
headless: false,
slowMo: 50,
});
}
const sessions: Session[] = [];
for (let i = 0; i < count; i++) {
const account = this.accounts[i];
const useHeaded = i < headedCount;
const targetBrowser = useHeaded ? this.headedBrowser! : this.browser!;
// Headed host windows get a larger viewport — 960×900 fits the full
// game table (deck + opponent row + own 2×3 grid + status area) on
// a typical 1920×1080 display. Two windows side-by-side still fit
// horizontally; if the user runs more than 2 rooms in tiled mode
// the extra windows will overlap and need to be arranged manually.
//
// baseURL is set on every context so relative goto('/') calls
// (used between games to bounce back to the lobby) resolve to
// the target server instead of failing with "invalid URL".
const context = await targetBrowser.newContext({
...this.opts.contextOptions,
baseURL: this.opts.targetUrl,
...(useHeaded ? { viewport: { width: 960, height: 900 } } : {}),
});
await this.injectAuth(context, account);
const page = await context.newPage();
await page.goto(this.opts.targetUrl);
// Best-effort tile placement. window.moveTo is often a no-op on
// modern Chromium (especially under Wayland), so we don't rely on
// it — the viewport sized above is what the user actually sees.
if (useHeaded) {
const col = i % 2;
const row = Math.floor(i / 2);
const x = col * 960;
const y = row * 920;
await page
.evaluate(
([x, y]) => {
window.moveTo(x, y);
},
[x, y] as [number, number],
)
.catch(() => {
// ignore — window.moveTo may be blocked
});
}
const bot = new GolfBot(page);
sessions.push({ account, context, page, bot, key: account.key });
}
this.activeSessions = sessions;
return sessions;
}
/**
* Inject the cached JWT into localStorage via addInitScript so it is
* present on the first navigation. If the token is rejected later,
* acquire() falls back to /api/auth/login.
*/
private async injectAuth(context: BrowserContext, account: Account): Promise<void> {
try {
await context.addInitScript(
({ token, username }) => {
window.localStorage.setItem('authToken', token);
window.localStorage.setItem(
'authUser',
JSON.stringify({ id: '', username, role: 'user', email_verified: true }),
);
},
{ token: account.token, username: account.username },
);
} catch (err) {
this.opts.logger.warn('inject_auth_failed', {
account: account.key,
error: err instanceof Error ? err.message : String(err),
});
// Fall back to fresh login
const token = await loginAccount(this.opts.targetUrl, account.username, account.password);
account.token = token;
writeCredFile(this.opts.credFile, this.accounts);
await context.addInitScript(
({ token, username }) => {
window.localStorage.setItem('authToken', token);
window.localStorage.setItem(
'authUser',
JSON.stringify({ id: '', username, role: 'user', email_verified: true }),
);
},
{ token, username: account.username },
);
}
}
/** Close all active contexts + browsers. Safe to call multiple times. */
async release(): Promise<void> {
for (const session of this.activeSessions) {
try {
await session.context.close();
} catch {
// ignore
}
}
this.activeSessions = [];
if (this.ownedBrowser) {
try {
await this.ownedBrowser.close();
} catch {
// ignore
}
this.ownedBrowser = null;
this.browser = null;
}
if (this.headedBrowser) {
try {
await this.headedBrowser.close();
} catch {
// ignore
}
this.headedBrowser = null;
}
}
}

133
tests/soak/core/types.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Core type definitions for the soak harness.
*
* Contracts here are consumed by runner.ts, SessionPool, scenarios,
* and the dashboard. Keep this file small and stable.
*/
import type { BrowserContext, Page } from 'playwright-core';
// =============================================================================
// GolfBot — real class from tests/e2e/bot/
// =============================================================================
import type { GolfBot } from '../../e2e/bot/golf-bot';
export type { GolfBot };
// =============================================================================
// Accounts & sessions
// =============================================================================
export interface Account {
/** Stable key used in logs, e.g. "soak_00". */
key: string;
username: string;
password: string;
/** JWT returned from /api/auth/login, may be refreshed by SessionPool. */
token: string;
}
export interface Session {
account: Account;
context: BrowserContext;
page: Page;
bot: GolfBot;
/** Convenience mirror of account.key. */
key: string;
}
// =============================================================================
// Scenarios
// =============================================================================
export interface ScenarioNeeds {
/** Total number of authenticated sessions the scenario requires. */
accounts: number;
/** How many rooms to partition sessions into (default: 1). */
rooms?: number;
/** CPUs to add per room (default: 0). */
cpusPerRoom?: number;
}
/** Free-form per-scenario config merged with CLI flags. */
export type ScenarioConfig = Record<string, unknown>;
export interface ScenarioError {
room: string;
reason: string;
detail?: string;
timestamp: number;
}
export interface ScenarioResult {
gamesCompleted: number;
errors: ScenarioError[];
durationMs: number;
customMetrics?: Record<string, number>;
}
export interface ScenarioContext {
/** Merged config: CLI flags → env → scenario defaults → runner defaults. */
config: ScenarioConfig;
/** Pre-authenticated sessions; ordered. */
sessions: Session[];
coordinator: RoomCoordinatorApi;
dashboard: DashboardReporter;
logger: Logger;
signal: AbortSignal;
/** Reset the per-room watchdog. Call at each progress point. */
heartbeat(roomId: string): void;
}
export interface Scenario {
name: string;
description: string;
defaultConfig: ScenarioConfig;
needs: ScenarioNeeds;
run(ctx: ScenarioContext): Promise<ScenarioResult>;
}
// =============================================================================
// Room coordination
// =============================================================================
export interface RoomCoordinatorApi {
announce(roomId: string, code: string): void;
await(roomId: string, timeoutMs?: number): Promise<string>;
}
// =============================================================================
// Dashboard reporter
// =============================================================================
export interface RoomState {
phase?: string;
currentPlayer?: string;
hole?: number;
totalHoles?: number;
game?: number;
totalGames?: number;
moves?: number;
players?: Array<{ key: string; score: number | null; isActive: boolean }>;
message?: string;
}
export interface DashboardReporter {
update(roomId: string, state: Partial<RoomState>): void;
log(level: 'info' | 'warn' | 'error', msg: string, meta?: object): void;
incrementMetric(name: string, by?: number): void;
}
// =============================================================================
// Logger
// =============================================================================
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface Logger {
debug(msg: string, meta?: object): void;
info(msg: string, meta?: object): void;
warn(msg: string, meta?: object): void;
error(msg: string, meta?: object): void;
child(meta: object): Logger;
}

View File

@@ -0,0 +1,44 @@
/**
* Watchdog — simple per-room timeout detector.
*
* `start()` begins a countdown. `heartbeat()` resets it. If the
* countdown elapses without a heartbeat, `onTimeout` fires once
* (subsequent heartbeats are no-ops after firing, unless `start()`
* is called again). `stop()` cancels any pending timer.
*
* Used by the runner to detect stuck rooms: one watchdog per room,
* scenarios call ctx.heartbeat(roomId) at each progress point, and
* a firing watchdog logs + aborts the run.
*/
export class Watchdog {
private timer: NodeJS.Timeout | null = null;
private fired = false;
constructor(
private timeoutMs: number,
private onTimeout: () => void,
) {}
start(): void {
this.stop();
this.fired = false;
this.timer = setTimeout(() => {
if (this.fired) return;
this.fired = true;
this.onTimeout();
}, this.timeoutMs);
}
heartbeat(): void {
if (this.fired) return;
this.start();
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View File

@@ -0,0 +1,173 @@
:root {
--bg: #0a0e16;
--panel: #0e1420;
--border: #1a2230;
--text: #c8d4e4;
--accent: #7fbaff;
--good: #6fd08f;
--warn: #ffb84d;
--err: #ff5c6c;
--muted: #556577;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, system-ui, 'SF Mono', Consolas, monospace;
background: var(--bg);
color: var(--text);
}
.dash-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: linear-gradient(135deg, #0f1823, #0a1018);
border-bottom: 1px solid var(--border);
}
.dash-header h1 { margin: 0; font-size: 16px; color: var(--accent); }
.dash-header .meta { font-size: 11px; color: var(--muted); }
.dash-header .meta span + span { margin-left: 12px; }
.meta-bar {
display: flex;
gap: 24px;
padding: 10px 20px;
background: #0c131d;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.meta-bar .stat .label { color: var(--muted); margin-right: 6px; }
.meta-bar .stat span:last-child { color: #fff; font-weight: 600; }
.rooms {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
}
.room {
background: var(--panel);
padding: 14px 18px;
min-height: 180px;
}
.room-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.room-title .name { font-size: 13px; color: var(--accent); font-weight: 600; }
.room-title .phase {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
background: #1a3a2a;
color: var(--good);
}
.room-title .phase.lobby { background: #3a2a1a; color: var(--warn); }
.room-title .phase.err { background: #3a1a1a; color: var(--err); }
.players {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
font-size: 11px;
margin-bottom: 8px;
}
.player {
display: flex;
justify-content: space-between;
padding: 4px 8px;
background: #0a0f18;
border-radius: 3px;
cursor: pointer;
border: 1px solid transparent;
}
.player:hover { border-color: var(--accent); }
.player.active {
background: #1a2a40;
border-left: 2px solid var(--accent);
}
.player .score { color: var(--muted); }
.progress-bar {
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 6px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--good));
transition: width 0.3s;
}
.room-meta {
font-size: 10px;
color: var(--muted);
display: flex;
gap: 12px;
margin-top: 6px;
}
.log {
border-top: 1px solid var(--border);
background: #080c13;
max-height: 160px;
overflow-y: auto;
}
.log .log-header {
padding: 6px 20px;
font-size: 10px;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--border);
}
.log ul { list-style: none; margin: 0; padding: 4px 20px; font-size: 10px; }
.log li { line-height: 1.5; font-family: monospace; color: var(--muted); }
.log li.warn { color: var(--warn); }
.log li.error { color: var(--err); }
.video-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.video-modal.hidden { display: none; }
.video-modal-content {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
max-width: 90vw;
max-height: 90vh;
}
.video-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: var(--accent);
font-size: 13px;
}
.video-modal-header button {
background: var(--border);
color: var(--text);
border: none;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
}
#video-frame {
display: block;
max-width: 100%;
max-height: 70vh;
border: 1px solid var(--border);
}

View File

@@ -0,0 +1,166 @@
// tests/soak/dashboard/dashboard.js
// Dashboard client: connects to the runner's WS server, renders the
// room grid, updates tiles on each room_state message, appends logs,
// handles click-to-watch for live screencasts (Task 23 wires frames).
(() => {
const ws = new WebSocket(`ws://${location.host}`);
const roomsEl = document.getElementById('rooms');
const logEl = document.getElementById('log-list');
const wsStatusEl = document.getElementById('ws-status');
const metricGames = document.getElementById('metric-games');
const metricMoves = document.getElementById('metric-moves');
const metricErrors = document.getElementById('metric-errors');
const elapsedEl = document.getElementById('elapsed');
const roomTiles = new Map();
const startTime = Date.now();
let currentWatchedKey = null;
// Video modal
const videoModal = document.getElementById('video-modal');
const videoFrame = document.getElementById('video-frame');
const videoTitle = document.getElementById('video-modal-title');
const videoClose = document.getElementById('video-modal-close');
function fmtElapsed(ms) {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
setInterval(() => {
elapsedEl.textContent = fmtElapsed(Date.now() - startTime);
}, 1000);
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function ensureRoomTile(roomId) {
if (roomTiles.has(roomId)) return roomTiles.get(roomId);
const tile = document.createElement('div');
tile.className = 'room';
tile.innerHTML = `
<div class="room-title">
<div class="name">${escapeHtml(roomId)}</div>
<div class="phase lobby">waiting</div>
</div>
<div class="players"></div>
<div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>
<div class="room-meta">
<span class="moves">0 moves</span>
<span class="game">game —</span>
</div>
`;
roomsEl.appendChild(tile);
roomTiles.set(roomId, tile);
return tile;
}
function renderRoomState(roomId, state) {
const tile = ensureRoomTile(roomId);
if (state.phase !== undefined) {
const phaseEl = tile.querySelector('.phase');
phaseEl.textContent = state.phase;
phaseEl.classList.toggle('lobby', state.phase === 'lobby' || state.phase === 'waiting');
phaseEl.classList.toggle('err', state.phase === 'error');
}
if (state.players !== undefined) {
const playersEl = tile.querySelector('.players');
playersEl.innerHTML = state.players
.map(
(p) => `
<div class="player ${p.isActive ? 'active' : ''}" data-session="${escapeHtml(p.key)}">
<span>${p.isActive ? '▶ ' : ''}${escapeHtml(p.key)}</span>
<span class="score">${p.score ?? '—'}</span>
</div>
`,
)
.join('');
}
if (state.hole !== undefined && state.totalHoles !== undefined) {
const fill = tile.querySelector('.progress-fill');
const pct = state.totalHoles > 0 ? Math.round((state.hole / state.totalHoles) * 100) : 0;
fill.style.width = `${pct}%`;
}
if (state.moves !== undefined) {
tile.querySelector('.moves').textContent = `${state.moves} moves`;
}
if (state.game !== undefined && state.totalGames !== undefined) {
tile.querySelector('.game').textContent = `game ${state.game}/${state.totalGames}`;
}
}
function appendLog(level, msg, meta) {
const li = document.createElement('li');
li.className = level;
const ts = new Date().toLocaleTimeString();
li.textContent = `[${ts}] ${msg} ${meta ? JSON.stringify(meta) : ''}`;
logEl.insertBefore(li, logEl.firstChild);
while (logEl.children.length > 100) {
logEl.removeChild(logEl.lastChild);
}
}
function applyMetric(name, value) {
if (name === 'games_completed') metricGames.textContent = value;
else if (name === 'moves_total') metricMoves.textContent = value;
else if (name === 'errors') metricErrors.textContent = value;
}
ws.addEventListener('open', () => {
wsStatusEl.textContent = 'healthy';
wsStatusEl.style.color = 'var(--good)';
});
ws.addEventListener('close', () => {
wsStatusEl.textContent = 'disconnected';
wsStatusEl.style.color = 'var(--err)';
});
ws.addEventListener('message', (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
if (msg.type === 'room_state') {
renderRoomState(msg.roomId, msg.state);
} else if (msg.type === 'log') {
appendLog(msg.level, msg.msg, msg.meta);
} else if (msg.type === 'metric') {
applyMetric(msg.name, msg.value);
} else if (msg.type === 'frame') {
if (msg.sessionKey === currentWatchedKey) {
videoFrame.src = `data:image/jpeg;base64,${msg.jpegBase64}`;
}
}
});
// Click-to-watch (screencasts start arriving after Task 22/23)
roomsEl.addEventListener('click', (e) => {
const playerEl = e.target.closest('.player');
if (!playerEl) return;
const key = playerEl.dataset.session;
if (!key) return;
currentWatchedKey = key;
videoTitle.textContent = `Watching ${key}`;
videoModal.classList.remove('hidden');
ws.send(JSON.stringify({ type: 'start_stream', sessionKey: key }));
});
function closeVideo() {
if (currentWatchedKey) {
ws.send(JSON.stringify({ type: 'stop_stream', sessionKey: currentWatchedKey }));
}
currentWatchedKey = null;
videoModal.classList.add('hidden');
videoFrame.src = '';
}
videoClose.addEventListener('click', closeVideo);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeVideo();
});
})();

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Golf Soak Dashboard</title>
<link rel="stylesheet" href="/dashboard.css">
</head>
<body>
<header class="dash-header">
<h1>⛳ Golf Soak Dashboard</h1>
<div class="meta">
<span id="run-id">run —</span>
<span id="elapsed">00:00:00</span>
</div>
</header>
<div class="meta-bar">
<div class="stat"><span class="label">Games</span><span id="metric-games">0</span></div>
<div class="stat"><span class="label">Moves</span><span id="metric-moves">0</span></div>
<div class="stat"><span class="label">Errors</span><span id="metric-errors">0</span></div>
<div class="stat"><span class="label">WS</span><span id="ws-status">connecting</span></div>
</div>
<div class="rooms" id="rooms">
<!-- Room tiles injected by dashboard.js -->
</div>
<section class="log">
<div class="log-header">Activity Log</div>
<ul id="log-list"></ul>
</section>
<!-- Modal for focused live video (screencast arrives in Task 22/23) -->
<div id="video-modal" class="video-modal hidden">
<div class="video-modal-content">
<div class="video-modal-header">
<span id="video-modal-title">Watching —</span>
<button id="video-modal-close">Close</button>
</div>
<img id="video-frame" alt="Live screencast" />
</div>
</div>
<script src="/dashboard.js"></script>
</body>
</html>

View File

@@ -0,0 +1,154 @@
/**
* DashboardServer — vanilla http + ws, serves one static HTML page
* and broadcasts room_state/log/metric events to all connected clients.
*
* Scenarios never touch this directly — they call the DashboardReporter
* returned by `server.reporter()`, which forwards over WS.
*/
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import { WebSocketServer, WebSocket } from 'ws';
import type { DashboardReporter, Logger, RoomState } from '../core/types';
export type DashboardIncoming =
| { type: 'start_stream'; sessionKey: string }
| { type: 'stop_stream'; sessionKey: string };
export type DashboardOutgoing =
| { type: 'room_state'; roomId: string; state: Partial<RoomState> }
| { type: 'log'; level: string; msg: string; meta?: object; timestamp: number }
| { type: 'metric'; name: string; value: number }
| { type: 'frame'; sessionKey: string; jpegBase64: string };
export interface DashboardHandlers {
onStartStream?(sessionKey: string): void;
onStopStream?(sessionKey: string): void;
onDisconnect?(): void;
}
export class DashboardServer {
private httpServer!: http.Server;
private wsServer!: WebSocketServer;
private clients = new Set<WebSocket>();
private metrics: Record<string, number> = {};
private roomStates: Record<string, Partial<RoomState>> = {};
constructor(
private port: number,
private logger: Logger,
private handlers: DashboardHandlers = {},
) {}
async start(): Promise<void> {
const htmlPath = path.resolve(__dirname, 'index.html');
const cssPath = path.resolve(__dirname, 'dashboard.css');
const jsPath = path.resolve(__dirname, 'dashboard.js');
this.httpServer = http.createServer((req, res) => {
const url = req.url ?? '/';
if (url === '/' || url === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
fs.createReadStream(htmlPath).pipe(res);
} else if (url === '/dashboard.css') {
res.writeHead(200, { 'Content-Type': 'text/css' });
fs.createReadStream(cssPath).pipe(res);
} else if (url === '/dashboard.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
fs.createReadStream(jsPath).pipe(res);
} else {
res.writeHead(404);
res.end('not found');
}
});
this.wsServer = new WebSocketServer({ server: this.httpServer });
this.wsServer.on('connection', (ws) => {
this.clients.add(ws);
this.logger.info('dashboard_client_connected', { count: this.clients.size });
// Replay current state to the new client so late joiners see
// everything that has happened since the run started.
for (const [roomId, state] of Object.entries(this.roomStates)) {
ws.send(
JSON.stringify({ type: 'room_state', roomId, state } as DashboardOutgoing),
);
}
for (const [name, value] of Object.entries(this.metrics)) {
ws.send(JSON.stringify({ type: 'metric', name, value } as DashboardOutgoing));
}
ws.on('message', (data) => {
try {
const parsed = JSON.parse(data.toString()) as DashboardIncoming;
if (parsed.type === 'start_stream' && this.handlers.onStartStream) {
this.handlers.onStartStream(parsed.sessionKey);
} else if (parsed.type === 'stop_stream' && this.handlers.onStopStream) {
this.handlers.onStopStream(parsed.sessionKey);
}
} catch (err) {
this.logger.warn('dashboard_ws_parse_error', {
error: err instanceof Error ? err.message : String(err),
});
}
});
ws.on('close', () => {
this.clients.delete(ws);
this.logger.info('dashboard_client_disconnected', { count: this.clients.size });
if (this.clients.size === 0 && this.handlers.onDisconnect) {
this.handlers.onDisconnect();
}
});
});
await new Promise<void>((resolve) => {
this.httpServer.listen(this.port, () => resolve());
});
this.logger.info('dashboard_listening', { url: `http://localhost:${this.port}` });
}
async stop(): Promise<void> {
for (const ws of this.clients) {
try {
ws.close();
} catch {
// ignore
}
}
this.clients.clear();
await new Promise<void>((resolve) => {
this.wsServer.close(() => resolve());
});
await new Promise<void>((resolve) => {
this.httpServer.close(() => resolve());
});
}
broadcast(msg: DashboardOutgoing): void {
const payload = JSON.stringify(msg);
for (const ws of this.clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
/** Create a DashboardReporter wired to this server. */
reporter(): DashboardReporter {
return {
update: (roomId, state) => {
this.roomStates[roomId] = { ...this.roomStates[roomId], ...state };
this.broadcast({ type: 'room_state', roomId, state });
},
log: (level, msg, meta) => {
this.broadcast({ type: 'log', level, msg, meta, timestamp: Date.now() });
},
incrementMetric: (name, by = 1) => {
this.metrics[name] = (this.metrics[name] ?? 0) + by;
this.broadcast({ type: 'metric', name, value: this.metrics[name] });
},
};
}
}

26
tests/soak/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "golf-soak",
"version": "0.1.0",
"private": true,
"description": "Multiplayer soak & UX test harness for Golf Card Game",
"scripts": {
"soak": "tsx runner.ts",
"soak:populate": "tsx runner.ts --scenario=populate",
"soak:stress": "tsx runner.ts --scenario=stress",
"seed": "tsx scripts/seed-accounts.ts",
"smoke": "bash scripts/smoke.sh",
"test": "vitest run"
},
"dependencies": {
"playwright-core": "^1.40.0",
"ws": "^8.16.0"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"tsx": "^4.7.0",
"@types/ws": "^8.5.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0",
"vitest": "^1.2.0"
}
}

315
tests/soak/runner.ts Normal file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env tsx
/**
* Golf Soak Harness — entry point.
*
* Usage:
* TEST_URL=http://localhost:8000 \
* SOAK_INVITE_CODE=SOAKTEST \
* bun run soak -- --scenario=populate --rooms=1 --accounts=2 \
* --cpus-per-room=0 --games-per-room=1 --holes=1 --watch=none
*/
import * as path from 'path';
import { spawn } from 'child_process';
import { parseArgs, mergeConfig, CliArgs } from './config';
import { createLogger } from './core/logger';
import { SessionPool } from './core/session-pool';
import { RoomCoordinator } from './core/room-coordinator';
import { DashboardServer } from './dashboard/server';
import { Screencaster } from './core/screencaster';
import { Watchdog } from './core/watchdog';
import { Artifacts, pruneOldRuns } from './core/artifacts';
import { getScenario, listScenarios } from './scenarios';
import type { DashboardReporter, ScenarioContext, Session } from './core/types';
function noopDashboard(): DashboardReporter {
return {
update: () => {},
log: () => {},
incrementMetric: () => {},
};
}
function printScenarioList(): void {
console.log('Available scenarios:');
for (const s of listScenarios()) {
console.log(` ${s.name.padEnd(12)} ${s.description}`);
console.log(
` needs: accounts=${s.needs.accounts}, rooms=${s.needs.rooms ?? 1}, cpus=${s.needs.cpusPerRoom ?? 0}`,
);
}
}
async function main(): Promise<void> {
const cli: CliArgs = parseArgs(process.argv.slice(2));
if (cli.listOnly) {
printScenarioList();
return;
}
if (!cli.scenario) {
console.error('Error: --scenario=<name> is required. Use --list to see scenarios.');
process.exit(2);
}
const scenario = getScenario(cli.scenario);
if (!scenario) {
console.error(`Error: unknown scenario "${cli.scenario}". Use --list to see scenarios.`);
process.exit(2);
}
const runId =
cli.runId ?? `${cli.scenario}-${new Date().toISOString().replace(/[:.]/g, '-')}`;
const targetUrl = cli.target ?? process.env.TEST_URL ?? 'http://localhost:8000';
const inviteCode = process.env.SOAK_INVITE_CODE ?? 'SOAKTEST';
const watch = cli.watch ?? 'dashboard';
const logger = createLogger({ runId });
logger.info('run_start', {
scenario: scenario.name,
targetUrl,
watch,
cli,
});
// Artifacts: instantiate now so both failure path + success summary
// can reach it. Prune old runs (>7d) on startup so the directory
// doesn't grow unbounded.
const artifactsRoot = path.resolve(__dirname, 'artifacts');
const artifacts = new Artifacts({ runId, rootDir: artifactsRoot, logger });
pruneOldRuns(artifactsRoot, 7 * 24 * 3600 * 1000, logger);
// Resolve final config: scenarioDefaults → env → CLI (later wins)
const config = mergeConfig(
cli as Record<string, unknown>,
process.env,
scenario.defaultConfig,
);
// Ensure core knobs exist, falling back to scenario.needs
const accounts = Number(config.accounts ?? scenario.needs.accounts);
const rooms = Number(config.rooms ?? scenario.needs.rooms ?? 1);
const cpusPerRoom = Number(config.cpusPerRoom ?? scenario.needs.cpusPerRoom ?? 0);
if (accounts % rooms !== 0) {
console.error(
`Error: --accounts=${accounts} does not divide evenly into --rooms=${rooms}`,
);
process.exit(2);
}
config.accounts = accounts;
config.rooms = rooms;
config.cpusPerRoom = cpusPerRoom;
if (cli.dryRun) {
logger.info('dry_run', { config });
console.log('Dry run OK. Resolved config:');
console.log(JSON.stringify(config, null, 2));
return;
}
// Build dependencies
const credFile = path.resolve(__dirname, '.env.stresstest');
const headedHostCount = watch === 'tiled' ? rooms : 0;
const pool = new SessionPool({
targetUrl,
inviteCode,
credFile,
logger,
headedHostCount,
});
const coordinator = new RoomCoordinator();
const screencaster = new Screencaster(logger);
const abortController = new AbortController();
// Graceful shutdown: first signal flips abort, scenarios finish the
// current turn then unwind. 10 seconds later, if cleanup is still
// hanging, the runner force-exits. A second Ctrl-C skips the wait.
let forceExitTimer: NodeJS.Timeout | null = null;
const onSignal = (sig: string) => {
if (abortController.signal.aborted) {
logger.warn('force_exit', { signal: sig });
process.exit(130);
}
logger.warn('signal_received', { signal: sig });
abortController.abort();
forceExitTimer = setTimeout(() => {
logger.error('graceful_shutdown_timeout');
process.exit(130);
}, 10_000);
};
process.on('SIGINT', () => onSignal('SIGINT'));
process.on('SIGTERM', () => onSignal('SIGTERM'));
// Health probes: every 30s GET /api/health. Three consecutive failures
// abort the run with a fatal error so staging outages don't get
// misattributed to harness bugs.
let healthFailures = 0;
const healthTimer = setInterval(async () => {
try {
const res = await fetch(`${targetUrl}/health`);
if (!res.ok) throw new Error(`status ${res.status}`);
healthFailures = 0;
} catch (err) {
healthFailures++;
logger.warn('health_probe_failed', {
consecutive: healthFailures,
error: err instanceof Error ? err.message : String(err),
});
if (healthFailures >= 3) {
logger.error('health_fatal', { consecutive: healthFailures });
abortController.abort();
}
}
}, 30_000);
let dashboardServer: DashboardServer | null = null;
let dashboard: DashboardReporter = noopDashboard();
const watchdogs = new Map<string, Watchdog>();
let exitCode = 0;
try {
const sessions = await pool.acquire(accounts);
logger.info('sessions_acquired', { count: sessions.length });
// Build a session lookup for click-to-watch
const sessionsByKey = new Map<string, Session>();
for (const s of sessions) sessionsByKey.set(s.key, s);
// Dashboard with screencaster handlers now that sessions exist
if (watch === 'dashboard') {
const port = Number(config.dashboardPort ?? 7777);
dashboardServer = new DashboardServer(port, logger, {
onStartStream: (key) => {
const session = sessionsByKey.get(key);
if (!session) {
logger.warn('stream_start_unknown_session', { sessionKey: key });
return;
}
screencaster
.start(key, session.page, (jpegBase64) => {
dashboardServer!.broadcast({ type: 'frame', sessionKey: key, jpegBase64 });
})
.catch((err) =>
logger.error('screencast_start_failed', {
sessionKey: key,
error: err instanceof Error ? err.message : String(err),
}),
);
},
onStopStream: (key) => {
screencaster.stop(key).catch(() => {
// best-effort — errors already logged inside Screencaster
});
},
onDisconnect: () => {
screencaster.stopAll().catch(() => {});
},
});
await dashboardServer.start();
dashboard = dashboardServer.reporter();
const url = `http://localhost:${port}`;
console.log(`Dashboard: ${url}`);
try {
const opener =
process.platform === 'darwin'
? 'open'
: process.platform === 'win32'
? 'start'
: 'xdg-open';
spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
} catch {
// If auto-open fails, the URL is already printed.
}
}
// Per-room watchdogs — fire if no heartbeat arrives within 60s.
// Declared at outer scope so the finally block can stop them and
// drain any pending timers before the process exits.
for (let i = 0; i < rooms; i++) {
const roomId = `room-${i}`;
const w = new Watchdog(60_000, () => {
logger.error('watchdog_fired', { room: roomId });
dashboard.update(roomId, { phase: 'error' });
abortController.abort();
});
w.start();
watchdogs.set(roomId, w);
}
const ctx: ScenarioContext = {
config,
sessions,
coordinator,
dashboard,
logger,
signal: abortController.signal,
heartbeat: (roomId: string) => {
const w = watchdogs.get(roomId);
if (w) w.heartbeat();
},
};
const result = await scenario.run(ctx);
logger.info('run_complete', {
gamesCompleted: result.gamesCompleted,
errors: result.errors.length,
durationMs: result.durationMs,
});
console.log(`Games completed: ${result.gamesCompleted}`);
console.log(`Errors: ${result.errors.length}`);
console.log(`Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
artifacts.writeSummary({
runId,
scenario: scenario.name,
targetUrl,
gamesCompleted: result.gamesCompleted,
errors: result.errors,
durationMs: result.durationMs,
customMetrics: result.customMetrics,
});
if (result.errors.length > 0) {
console.log('Errors:');
for (const e of result.errors) {
console.log(` ${e.room}: ${e.reason}${e.detail ? ' — ' + e.detail : ''}`);
}
exitCode = 1;
}
} catch (err) {
logger.error('run_failed', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
// Best-effort artifact capture from still-live sessions. The pool's
// activeSessions field is private but accessible for this error path —
// we want every frame we can grab before release() tears them down.
try {
const liveSessions = (pool as unknown as { activeSessions: Session[] }).activeSessions;
if (liveSessions && liveSessions.length > 0) {
await artifacts.captureAll(liveSessions);
}
} catch (captureErr) {
logger.warn('artifact_capture_failed', {
error: captureErr instanceof Error ? captureErr.message : String(captureErr),
});
}
exitCode = 1;
} finally {
clearInterval(healthTimer);
if (forceExitTimer) clearTimeout(forceExitTimer);
for (const w of watchdogs.values()) w.stop();
await screencaster.stopAll();
await pool.release();
if (dashboardServer) {
await dashboardServer.stop();
}
}
if (abortController.signal.aborted && exitCode === 0) exitCode = 2;
process.exit(exitCode);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,24 @@
/**
* Scenario registry — name → Scenario mapping.
*
* Runner looks up scenarios by name. Add a new scenario by importing
* it here and adding an entry to `registry`. No filesystem scanning,
* no magic.
*/
import type { Scenario } from '../core/types';
import populate from './populate';
import stress from './stress';
const registry: Record<string, Scenario> = {
populate,
stress,
};
export function getScenario(name: string): Scenario | undefined {
return registry[name];
}
export function listScenarios(): Scenario[] {
return Object.values(registry);
}

View File

@@ -0,0 +1,147 @@
/**
* Populate scenario — long multi-round games to populate scoreboards.
*
* Partitions sessions into N rooms (default 4) and runs gamesPerRoom
* games per room in parallel via Promise.allSettled so a failure in
* one room never unwinds the others.
*/
import type {
Scenario,
ScenarioContext,
ScenarioResult,
ScenarioError,
Session,
} from '../core/types';
import { runOneMultiplayerGame } from './shared/multiplayer-game';
const CPU_PERSONALITIES = ['Sofia', 'Marcus', 'Kenji', 'Priya'];
interface PopulateConfig {
gamesPerRoom: number;
holes: number;
decks: number;
rooms: number;
cpusPerRoom: number;
thinkTimeMs: [number, number];
interGamePauseMs: number;
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runRoom(
ctx: ScenarioContext,
cfg: PopulateConfig,
roomIdx: number,
sessions: Session[],
): Promise<{ completed: number; errors: ScenarioError[] }> {
const roomId = `room-${roomIdx}`;
const cpuPersonality = CPU_PERSONALITIES[roomIdx % CPU_PERSONALITIES.length];
let completed = 0;
const errors: ScenarioError[] = [];
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
if (ctx.signal.aborted) break;
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
ctx.logger.info('game_start', { room: roomId, game: gameNum + 1 });
const result = await runOneMultiplayerGame(ctx, sessions, {
roomId,
holes: cfg.holes,
decks: cfg.decks,
cpusPerRoom: cfg.cpusPerRoom,
cpuPersonality,
thinkTimeMs: cfg.thinkTimeMs,
});
if (result.completed) {
completed++;
ctx.logger.info('game_complete', {
room: roomId,
game: gameNum + 1,
turns: result.turns,
durationMs: result.durationMs,
});
} else {
errors.push({
room: roomId,
reason: 'game_failed',
detail: result.error,
timestamp: Date.now(),
});
ctx.logger.error('game_failed', { room: roomId, game: gameNum + 1, error: result.error });
}
if (gameNum < cfg.gamesPerRoom - 1) {
await sleep(cfg.interGamePauseMs);
}
}
return { completed, errors };
}
const populate: Scenario = {
name: 'populate',
description: 'Long multi-round games to populate scoreboards',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 1 },
defaultConfig: {
gamesPerRoom: 10,
holes: 9,
decks: 2,
rooms: 4,
cpusPerRoom: 1,
thinkTimeMs: [800, 2200],
interGamePauseMs: 3000,
},
async run(ctx: ScenarioContext): Promise<ScenarioResult> {
const start = Date.now();
const cfg = ctx.config as unknown as PopulateConfig;
const perRoom = Math.floor(ctx.sessions.length / cfg.rooms);
if (perRoom * cfg.rooms !== ctx.sessions.length) {
throw new Error(
`populate: ${ctx.sessions.length} sessions does not divide evenly into ${cfg.rooms} rooms`,
);
}
const roomSessions = chunk(ctx.sessions, perRoom);
const results = await Promise.allSettled(
roomSessions.map((sessions, idx) => runRoom(ctx, cfg, idx, sessions)),
);
let gamesCompleted = 0;
const errors: ScenarioError[] = [];
results.forEach((r, idx) => {
if (r.status === 'fulfilled') {
gamesCompleted += r.value.completed;
errors.push(...r.value.errors);
} else {
errors.push({
room: `room-${idx}`,
reason: 'room_threw',
detail: r.reason instanceof Error ? r.reason.message : String(r.reason),
timestamp: Date.now(),
});
}
});
return {
gamesCompleted,
errors,
durationMs: Date.now() - start,
};
},
};
export default populate;

View File

@@ -0,0 +1,65 @@
/**
* Chaos injector — occasionally fires unexpected UI events while a
* game is playing, to hunt race conditions and recovery bugs.
*
* Called from the stress scenario's background chaos loop. Each call
* has `probability` of firing; when it fires it picks one random
* event type and runs it against one session.
*/
import type { Session, Logger } from '../../core/types';
export type ChaosEvent = 'rapid_clicks' | 'tab_blur' | 'brief_offline';
const ALL_EVENTS: ChaosEvent[] = ['rapid_clicks', 'tab_blur', 'brief_offline'];
function pickEvent(): ChaosEvent {
return ALL_EVENTS[Math.floor(Math.random() * ALL_EVENTS.length)];
}
export async function maybeInjectChaos(
session: Session,
probability: number,
logger: Logger,
roomId: string,
): Promise<ChaosEvent | null> {
if (Math.random() >= probability) return null;
const event = pickEvent();
logger.info('chaos_injected', { room: roomId, session: session.key, event });
try {
switch (event) {
case 'rapid_clicks': {
// Fire 5 rapid clicks at the player's own cards
for (let i = 0; i < 5; i++) {
await session.page
.locator(`#player-cards .card:nth-child(${(i % 6) + 1})`)
.click({ timeout: 300 })
.catch(() => {});
}
break;
}
case 'tab_blur': {
// Briefly dispatch blur then focus — simulates user tabbing away
await session.page.evaluate(() => {
window.dispatchEvent(new Event('blur'));
setTimeout(() => window.dispatchEvent(new Event('focus')), 200);
});
break;
}
case 'brief_offline': {
// 300ms network outage — should trigger client reconnect logic
await session.context.setOffline(true);
await new Promise((r) => setTimeout(r, 300));
await session.context.setOffline(false);
break;
}
}
} catch (err) {
logger.warn('chaos_error', {
event,
error: err instanceof Error ? err.message : String(err),
});
}
return event;
}

View File

@@ -0,0 +1,133 @@
/**
* runOneMultiplayerGame — the shared "play one game in one room" loop.
*
* Host creates the room, announces the code via RoomCoordinator,
* joiners wait for the code and join concurrently, host adds CPUs and
* starts the game, then every session loops on isMyTurn/playTurn until
* the game ends (or the abort signal fires, or maxDurationMs elapses).
*
* Used by both the populate and stress scenarios so the turn loop
* lives in exactly one place.
*/
import type { Session, ScenarioContext } from '../../core/types';
export interface MultiplayerGameOptions {
roomId: string;
holes: number;
decks: number;
cpusPerRoom: number;
cpuPersonality?: string;
/** Per-turn think time in [min, max] ms. */
thinkTimeMs: [number, number];
/** Max wall-clock time before giving up on the game (ms). */
maxDurationMs?: number;
}
export interface MultiplayerGameResult {
completed: boolean;
turns: number;
durationMs: number;
error?: string;
}
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function runOneMultiplayerGame(
ctx: ScenarioContext,
sessions: Session[],
opts: MultiplayerGameOptions,
): Promise<MultiplayerGameResult> {
const start = Date.now();
const [host, ...joiners] = sessions;
const maxDuration = opts.maxDurationMs ?? 5 * 60_000;
try {
// Reset every session back to the lobby before starting.
// After the first game ends each session is parked on the
// game_over screen, which hides the lobby's Create Room button.
// goto('/') bounces them back; localStorage-cached auth persists.
await Promise.all(sessions.map((s) => s.bot.goto('/')));
// Use a unique coordinator key per game-start so Deferreds don't
// carry stale room codes from previous games. The coordinator's
// Promises only resolve once — reusing `opts.roomId` across games
// would make joiners receive the first game's code on every game.
const coordKey = `${opts.roomId}-${Date.now()}`;
// Host creates game and announces the code
const code = await host.bot.createGame(host.account.username);
ctx.coordinator.announce(coordKey, code);
ctx.heartbeat(opts.roomId);
ctx.dashboard.update(opts.roomId, { phase: 'lobby' });
ctx.logger.info('room_created', { room: opts.roomId, code });
// Joiners join concurrently
await Promise.all(
joiners.map(async (joiner) => {
const awaited = await ctx.coordinator.await(coordKey);
await joiner.bot.joinGame(awaited, joiner.account.username);
}),
);
ctx.heartbeat(opts.roomId);
// Host adds CPUs (if any) and starts
for (let i = 0; i < opts.cpusPerRoom; i++) {
await host.bot.addCPU(opts.cpuPersonality);
}
await host.bot.startGame({ holes: opts.holes, decks: opts.decks });
ctx.heartbeat(opts.roomId);
ctx.dashboard.update(opts.roomId, { phase: 'playing', totalHoles: opts.holes });
// Concurrent turn loops — one per session
const turnCounts = new Array(sessions.length).fill(0);
async function sessionLoop(sessionIdx: number): Promise<void> {
const session = sessions[sessionIdx];
while (true) {
if (ctx.signal.aborted) return;
if (Date.now() - start > maxDuration) return;
const phase = await session.bot.getGamePhase();
if (phase === 'game_over' || phase === 'round_over') return;
if (await session.bot.isMyTurn()) {
await session.bot.playTurn();
turnCounts[sessionIdx]++;
ctx.heartbeat(opts.roomId);
ctx.dashboard.update(opts.roomId, {
currentPlayer: session.account.username,
moves: turnCounts.reduce((a, b) => a + b, 0),
});
const thinkMs = randomInt(opts.thinkTimeMs[0], opts.thinkTimeMs[1]);
await sleep(thinkMs);
} else {
await sleep(200);
}
}
}
await Promise.all(sessions.map((_, i) => sessionLoop(i)));
const totalTurns = turnCounts.reduce((a, b) => a + b, 0);
ctx.dashboard.update(opts.roomId, { phase: 'round_over' });
return {
completed: true,
turns: totalTurns,
durationMs: Date.now() - start,
};
} catch (err) {
return {
completed: false,
turns: 0,
durationMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -0,0 +1,171 @@
/**
* Stress scenario — rapid short games with chaos injection.
*
* Partitions sessions into N rooms (default 4), runs gamesPerRoom
* short 1-hole games per room in parallel. While each game plays,
* a background loop injects chaos events (rapid clicks, tab blur,
* brief offline) with 5% per-turn probability to hunt race
* conditions and recovery bugs.
*/
import type {
Scenario,
ScenarioContext,
ScenarioResult,
ScenarioError,
Session,
} from '../core/types';
import { runOneMultiplayerGame } from './shared/multiplayer-game';
import { maybeInjectChaos } from './shared/chaos';
interface StressConfig {
gamesPerRoom: number;
holes: number;
decks: number;
rooms: number;
cpusPerRoom: number;
thinkTimeMs: [number, number];
interGamePauseMs: number;
chaosChance: number;
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
async function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function runStressRoom(
ctx: ScenarioContext,
cfg: StressConfig,
roomIdx: number,
sessions: Session[],
): Promise<{ completed: number; errors: ScenarioError[]; chaosFired: number }> {
const roomId = `room-${roomIdx}`;
let completed = 0;
let chaosFired = 0;
const errors: ScenarioError[] = [];
for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) {
if (ctx.signal.aborted) break;
ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom });
// Background chaos loop — runs concurrently with the game turn loop.
// Delay the first tick by 3 seconds so room creation + joiners + game
// start have time to complete without chaos interference (rapid clicks
// or brief_offline during lobby setup can prevent #create-room-btn
// from becoming stable).
let chaosActive = true;
const chaosLoop = (async () => {
await sleep(3000);
while (chaosActive && !ctx.signal.aborted) {
await sleep(500);
for (const session of sessions) {
const e = await maybeInjectChaos(
session,
cfg.chaosChance,
ctx.logger,
roomId,
);
if (e) chaosFired++;
}
}
})();
const result = await runOneMultiplayerGame(ctx, sessions, {
roomId,
holes: cfg.holes,
decks: cfg.decks,
cpusPerRoom: cfg.cpusPerRoom,
thinkTimeMs: cfg.thinkTimeMs,
});
chaosActive = false;
await chaosLoop;
if (result.completed) {
completed++;
ctx.logger.info('game_complete', {
room: roomId,
game: gameNum + 1,
turns: result.turns,
});
} else {
errors.push({
room: roomId,
reason: 'game_failed',
detail: result.error,
timestamp: Date.now(),
});
ctx.logger.error('game_failed', { room: roomId, error: result.error });
}
await sleep(cfg.interGamePauseMs);
}
return { completed, errors, chaosFired };
}
const stress: Scenario = {
name: 'stress',
description: 'Rapid short games for stability & race condition hunting',
needs: { accounts: 16, rooms: 4, cpusPerRoom: 2 },
defaultConfig: {
gamesPerRoom: 50,
holes: 1,
decks: 1,
rooms: 4,
cpusPerRoom: 2,
thinkTimeMs: [50, 150],
interGamePauseMs: 200,
chaosChance: 0.05,
},
async run(ctx: ScenarioContext): Promise<ScenarioResult> {
const start = Date.now();
const cfg = ctx.config as unknown as StressConfig;
const perRoom = Math.floor(ctx.sessions.length / cfg.rooms);
if (perRoom * cfg.rooms !== ctx.sessions.length) {
throw new Error(
`stress: ${ctx.sessions.length} sessions does not divide evenly into ${cfg.rooms} rooms`,
);
}
const roomSessions = chunk(ctx.sessions, perRoom);
const results = await Promise.allSettled(
roomSessions.map((s, idx) => runStressRoom(ctx, cfg, idx, s)),
);
let gamesCompleted = 0;
let chaosFired = 0;
const errors: ScenarioError[] = [];
results.forEach((r, idx) => {
if (r.status === 'fulfilled') {
gamesCompleted += r.value.completed;
chaosFired += r.value.chaosFired;
errors.push(...r.value.errors);
} else {
errors.push({
room: `room-${idx}`,
reason: 'room_threw',
detail: r.reason instanceof Error ? r.reason.message : String(r.reason),
timestamp: Date.now(),
});
}
});
return {
gamesCompleted,
errors,
durationMs: Date.now() - start,
customMetrics: { chaos_fired: chaosFired },
};
},
};
export default stress;

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env tsx
/**
* Seed N soak-harness accounts via the register endpoint.
*
* Usage:
* TEST_URL=http://localhost:8000 \
* SOAK_INVITE_CODE=SOAKTEST \
* bun run seed -- --count=16
*
* The invite code must already exist and be flagged marks_as_test=TRUE
* in the target environment. See docs/soak-harness-bringup.md.
*/
import * as path from 'path';
import { SessionPool } from '../core/session-pool';
import { createLogger } from '../core/logger';
function parseArgs(argv: string[]): { count: number } {
const result = { count: 16 };
for (const arg of argv.slice(2)) {
const m = arg.match(/^--count=(\d+)$/);
if (m) result.count = parseInt(m[1], 10);
}
return result;
}
async function main(): Promise<void> {
const { count } = parseArgs(process.argv);
const targetUrl = process.env.TEST_URL ?? 'http://localhost:8000';
const inviteCode = process.env.SOAK_INVITE_CODE;
if (!inviteCode) {
console.error('SOAK_INVITE_CODE env var is required');
console.error(' Local dev: SOAK_INVITE_CODE=SOAKTEST');
console.error(' Staging: SOAK_INVITE_CODE=5VC2MCCN');
process.exit(2);
}
const credFile = path.resolve(__dirname, '..', '.env.stresstest');
const logger = createLogger({ runId: `seed-${Date.now()}` });
logger.info('seed_start', { count, targetUrl, credFile });
try {
const accounts = await SessionPool.seed({
targetUrl,
inviteCode,
count,
credFile,
logger,
});
logger.info('seed_complete', { created: accounts.length });
console.error(`Seeded ${accounts.length} accounts → ${credFile}`);
} catch (err) {
logger.error('seed_failed', {
error: err instanceof Error ? err.message : String(err),
});
process.exit(1);
}
}
main();

37
tests/soak/scripts/smoke.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Soak harness smoke test — end-to-end canary against local dev.
# Expected runtime: ~60 seconds.
set -euo pipefail
cd "$(dirname "$0")/.."
: "${TEST_URL:=http://localhost:8000}"
: "${SOAK_INVITE_CODE:=SOAKTEST}"
echo "Smoke target: $TEST_URL"
echo "Invite code: $SOAK_INVITE_CODE"
# 1. Health probe
curl -fsS "$TEST_URL/api/health" > /dev/null || {
echo "FAIL: target server unreachable at $TEST_URL"
exit 1
}
# 2. Ensure minimum accounts
if [ ! -f .env.stresstest ]; then
echo "Seeding accounts..."
bun run seed -- --count=4
fi
# 3. Run minimum viable scenario
TEST_URL="$TEST_URL" SOAK_INVITE_CODE="$SOAK_INVITE_CODE" \
bun run soak -- \
--scenario=populate \
--accounts=2 \
--rooms=1 \
--cpus-per-room=0 \
--games-per-room=1 \
--holes=1 \
--watch=none
echo "Smoke PASSED"

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { parseArgs, mergeConfig } from '../config';
describe('parseArgs', () => {
it('parses --scenario and numeric flags', () => {
const r = parseArgs(['--scenario=populate', '--rooms=4', '--games-per-room=10']);
expect(r.scenario).toBe('populate');
expect(r.rooms).toBe(4);
expect(r.gamesPerRoom).toBe(10);
});
it('parses watch mode', () => {
const r = parseArgs(['--scenario=populate', '--watch=none']);
expect(r.watch).toBe('none');
});
it('rejects unknown watch mode', () => {
expect(() => parseArgs(['--scenario=populate', '--watch=bogus'])).toThrow();
});
it('--list sets listOnly', () => {
const r = parseArgs(['--list']);
expect(r.listOnly).toBe(true);
});
it('--dry-run sets dryRun', () => {
const r = parseArgs(['--scenario=populate', '--dry-run']);
expect(r.dryRun).toBe(true);
});
it('parses --accounts, --cpus-per-room, --dashboard-port, --target, --run-id, --holes', () => {
const r = parseArgs([
'--scenario=stress',
'--accounts=8',
'--cpus-per-room=2',
'--dashboard-port=7777',
'--target=http://localhost:8000',
'--run-id=test-1',
'--holes=1',
]);
expect(r.accounts).toBe(8);
expect(r.cpusPerRoom).toBe(2);
expect(r.dashboardPort).toBe(7777);
expect(r.target).toBe('http://localhost:8000');
expect(r.runId).toBe('test-1');
expect(r.holes).toBe(1);
});
it('throws on non-numeric integer flag', () => {
expect(() => parseArgs(['--rooms=four'])).toThrow(/Invalid integer/);
});
});
describe('mergeConfig', () => {
it('CLI flags override scenario defaults', () => {
const cfg = mergeConfig(
{ gamesPerRoom: 20 },
{},
{ gamesPerRoom: 5, holes: 9 },
);
expect(cfg.gamesPerRoom).toBe(20);
expect(cfg.holes).toBe(9);
});
it('env overrides scenario defaults but CLI overrides env', () => {
const cfg = mergeConfig(
{ holes: 5 }, // CLI
{ SOAK_HOLES: '3' }, // env
{ holes: 9 }, // defaults
);
expect(cfg.holes).toBe(5); // CLI wins
});
it('env overrides scenario defaults when CLI is absent', () => {
const cfg = mergeConfig(
{}, // no CLI
{ SOAK_HOLES: '3' }, // env
{ holes: 9 }, // defaults
);
expect(cfg.holes).toBe(3); // env wins over defaults
});
it('scenario defaults fill in unset values', () => {
const cfg = mergeConfig(
{},
{},
{ gamesPerRoom: 3, holes: 9 },
);
expect(cfg.gamesPerRoom).toBe(3);
expect(cfg.holes).toBe(9);
});
it('env numeric keys are parsed to integers', () => {
const cfg = mergeConfig(
{},
{ SOAK_ROOMS: '4', SOAK_ACCOUNTS: '16' },
{},
);
expect(cfg.rooms).toBe(4);
expect(cfg.accounts).toBe(16);
});
});

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { deferred } from '../core/deferred';
describe('deferred', () => {
it('resolves with the given value', async () => {
const d = deferred<string>();
d.resolve('hello');
await expect(d.promise).resolves.toBe('hello');
});
it('rejects with the given error', async () => {
const d = deferred<string>();
const err = new Error('boom');
d.reject(err);
await expect(d.promise).rejects.toBe(err);
});
it('ignores second resolve calls', async () => {
const d = deferred<number>();
d.resolve(1);
d.resolve(2);
await expect(d.promise).resolves.toBe(1);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createLogger } from '../core/logger';
describe('logger', () => {
let writes: string[];
let write: (s: string) => boolean;
beforeEach(() => {
writes = [];
write = (s: string) => {
writes.push(s);
return true;
};
});
it('emits a JSON line per call with level and msg', () => {
const log = createLogger({ runId: 'r1', write });
log.info('hello');
expect(writes).toHaveLength(1);
const parsed = JSON.parse(writes[0]);
expect(parsed.level).toBe('info');
expect(parsed.msg).toBe('hello');
expect(parsed.runId).toBe('r1');
expect(parsed.timestamp).toBeTypeOf('string');
});
it('merges meta into the log line', () => {
const log = createLogger({ runId: 'r1', write });
log.warn('slow', { turnMs: 3000 });
const parsed = JSON.parse(writes[0]);
expect(parsed.turnMs).toBe(3000);
expect(parsed.level).toBe('warn');
});
it('child logger inherits parent meta', () => {
const log = createLogger({ runId: 'r1', write });
const roomLog = log.child({ room: 'room-1' });
roomLog.info('game_start');
const parsed = JSON.parse(writes[0]);
expect(parsed.room).toBe('room-1');
expect(parsed.runId).toBe('r1');
});
it('respects minimum level', () => {
const log = createLogger({ runId: 'r1', write, minLevel: 'warn' });
log.debug('nope');
log.info('nope');
log.warn('yes');
log.error('yes');
expect(writes).toHaveLength(2);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { RoomCoordinator } from '../core/room-coordinator';
describe('RoomCoordinator', () => {
it('resolves await with the announced code (announce then await)', async () => {
const rc = new RoomCoordinator();
rc.announce('room-1', 'ABCD');
await expect(rc.await('room-1')).resolves.toBe('ABCD');
});
it('resolves await with the announced code (await then announce)', async () => {
const rc = new RoomCoordinator();
const p = rc.await('room-2');
rc.announce('room-2', 'WXYZ');
await expect(p).resolves.toBe('WXYZ');
});
it('rejects await after timeout if not announced', async () => {
const rc = new RoomCoordinator();
await expect(rc.await('room-3', 50)).rejects.toThrow(/timed out/i);
});
it('isolates rooms — announcing room-A does not unblock room-B', async () => {
const rc = new RoomCoordinator();
const pB = rc.await('room-B', 100);
rc.announce('room-A', 'A-CODE');
await expect(pB).rejects.toThrow(/timed out/i);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Watchdog } from '../core/watchdog';
describe('Watchdog', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('fires after timeout if no heartbeat', () => {
const onTimeout = vi.fn();
const w = new Watchdog(1000, onTimeout);
w.start();
vi.advanceTimersByTime(1001);
expect(onTimeout).toHaveBeenCalledOnce();
});
it('heartbeat resets the timer', () => {
const onTimeout = vi.fn();
const w = new Watchdog(1000, onTimeout);
w.start();
vi.advanceTimersByTime(800);
w.heartbeat();
vi.advanceTimersByTime(800);
expect(onTimeout).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(onTimeout).toHaveBeenCalledOnce();
});
it('stop cancels pending timeout', () => {
const onTimeout = vi.fn();
const w = new Watchdog(1000, onTimeout);
w.start();
w.stop();
vi.advanceTimersByTime(2000);
expect(onTimeout).not.toHaveBeenCalled();
});
it('does not fire twice after stop', () => {
const onTimeout = vi.fn();
const w = new Watchdog(1000, onTimeout);
w.start();
vi.advanceTimersByTime(1001);
w.heartbeat();
vi.advanceTimersByTime(1001);
expect(onTimeout).toHaveBeenCalledOnce();
});
});

24
tests/soak/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": ".",
"lib": ["ES2022", "DOM"],
"paths": {
"@soak/*": ["./*"],
"@bot/*": ["../e2e/bot/*"],
"@playwright/test": ["./node_modules/@playwright/test"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist", "artifacts"]
}