diff --git a/client/admin.html b/client/admin.html index 3f5da71..71aeebf 100644 --- a/client/admin.html +++ b/client/admin.html @@ -114,6 +114,10 @@ Include banned + @@ -396,6 +400,8 @@
+ + diff --git a/client/admin.js b/client/admin.js index 372a7d2..ce70d5f 100644 --- a/client/admin.js +++ b/client/admin.js @@ -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 + ? ' Test' + : ''; tbody.innerHTML += ` - + @@ -447,10 +452,13 @@ async function loadInvites() { : invite.remaining_uses <= 0 ? 'Used Up' : 'Active'; + const testSeedBadge = invite.marks_as_test + ? ' Test-seed' + : ''; tbody.innerHTML += ` - + @@ -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--; diff --git a/client/index.html b/client/index.html index 8af551f..9add284 100644 --- a/client/index.html +++ b/client/index.html @@ -55,7 +55,7 @@

- + @@ -288,7 +288,7 @@

Waiting for host to start the game...

- + diff --git a/docs/soak-harness-bringup.md b/docs/soak-harness-bringup.md new file mode 100644 index 0000000..f3315b4 --- /dev/null +++ b/docs/soak-harness-bringup.md @@ -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 = ''; +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 diff --git a/docs/superpowers/plans/2026-04-10-multiplayer-soak-test.md b/docs/superpowers/plans/2026-04-10-multiplayer-soak-test.md index f8d4867..6e0619e 100644 --- a/docs/superpowers/plans/2026-04-10-multiplayer-soak-test.md +++ b/docs/superpowers/plans/2026-04-10-multiplayer-soak-test.md @@ -71,7 +71,7 @@ END $$; Find the indexes block near line 338. After the existing `idx_users_banned` index (line ~344), add: ```sql -CREATE INDEX IF NOT EXISTS idx_users_v2_is_test_account ON users_v2(is_test_account) +CREATE INDEX IF NOT EXISTS idx_users_test_account ON users_v2(is_test_account) WHERE is_test_account = TRUE; ``` @@ -142,7 +142,7 @@ Connect to the dev database and confirm: psql -d golfgame -c "\d users_v2" | grep is_test_account psql -d golfgame -c "\d invite_codes" | grep marks_as_test psql -d golfgame -c "\d leaderboard_overall" | grep is_test_account -psql -d golfgame -c "\di idx_users_v2_is_test_account" +psql -d golfgame -c "\di idx_users_test_account" ``` Expected: all four commands return matching rows. @@ -196,7 +196,7 @@ ssh root@129.212.150.189 << 'REMOTE' -- Partial index SELECT indexname, indexdef FROM pg_indexes - WHERE indexname = 'idx_users_v2_is_test_account'; + WHERE indexname = 'idx_users_test_account'; SQL REMOTE ``` @@ -205,7 +205,7 @@ Expected (all four present): - `users_v2.is_test_account` with default `false` - `invite_codes.marks_as_test` with default `false` - `leaderboard_overall` has an `is_test_account` column -- `idx_users_v2_is_test_account` exists +- `idx_users_test_account` exists If any of these are missing, the server didn't actually restart (or restarted but the container has a stale image). Check `docker compose logs golfgame` for the line `User store schema initialized` — if it's not there, the migration never ran. @@ -5383,7 +5383,7 @@ Run after the server-side changes (Tasks 1–7) ship to each environment. - [ ] `\d users_v2` on target DB shows `is_test_account` column with default `false` - [ ] `\d invite_codes` shows `marks_as_test` column with default `false` - [ ] `\d leaderboard_overall` shows `is_test_account` column -- [ ] `\di idx_users_v2_is_test_account` shows the partial index +- [ ] `\di idx_users_test_account` shows the partial index - [ ] `SELECT count(*) FROM leaderboard_overall` returns nonzero (view re-populated after rebuild) - [ ] Default leaderboard query still works: `curl .../api/stats/leaderboard` returns entries - [ ] `?include_test=true` parameter is accepted (no 422/500) diff --git a/docs/superpowers/specs/2026-04-10-multiplayer-soak-test-design.md b/docs/superpowers/specs/2026-04-10-multiplayer-soak-test-design.md index ca363e0..3c0cd6a 100644 --- a/docs/superpowers/specs/2026-04-10-multiplayer-soak-test-design.md +++ b/docs/superpowers/specs/2026-04-10-multiplayer-soak-test-design.md @@ -309,7 +309,7 @@ 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_v2_is_test_account ON users_v2(is_test_account) +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; ``` diff --git a/pyproject.toml b/pyproject.toml index 90b8d53..0a1727c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/server/models/user.py b/server/models/user.py index c2860ac..83403a7 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -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), ) diff --git a/server/routers/admin.py b/server/routers/admin.py index f0c8a17..0600feb 100644 --- a/server/routers/admin.py +++ b/server/routers/admin.py @@ -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]} diff --git a/server/routers/auth.py b/server/routers/auth.py index ccae963..3ecdcef 100644 --- a/server/routers/auth.py +++ b/server/routers/auth.py @@ -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: diff --git a/server/routers/stats.py b/server/routers/stats.py index 08468cd..d2dce68 100644 --- a/server/routers/stats.py +++ b/server/routers/stats.py @@ -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, diff --git a/server/services/admin_service.py b/server/services/admin_service.py index 5af3cc4..1bea94e 100644 --- a/server/services/admin_service.py +++ b/server/services/admin_service.py @@ -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). diff --git a/server/services/auth_service.py b/server/services/auth_service.py index 6f7b2ac..794b1ba 100644 --- a/server/services/auth_service.py +++ b/server/services/auth_service.py @@ -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: diff --git a/server/services/stats_service.py b/server/services/stats_service.py index 92234d5..cedda22 100644 --- a/server/services/stats_service.py +++ b/server/services/stats_service.py @@ -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 diff --git a/server/stores/user_store.py b/server/stores/user_store.py index 33e996b..c103762 100644 --- a/server/stores/user_store.py +++ b/server/stores/user_store.py @@ -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: diff --git a/tests/e2e/bot/golf-bot.ts b/tests/e2e/bot/golf-bot.ts index 0d7e072..c835364 100644 --- a/tests/e2e/bot/golf-bot.ts +++ b/tests/e2e/bot/golf-bot.ts @@ -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 { - // 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); - await nameInput.fill(playerName); + 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 { - // Enter name const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput); - await nameInput.fill(playerName); + 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)); diff --git a/tests/soak/.env.stresstest.example b/tests/soak/.env.stresstest.example new file mode 100644 index 0000000..fb257f9 --- /dev/null +++ b/tests/soak/.env.stresstest.example @@ -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:: diff --git a/tests/soak/.gitignore b/tests/soak/.gitignore new file mode 100644 index 0000000..dc9896d --- /dev/null +++ b/tests/soak/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +artifacts/ +.env.stresstest +*.log diff --git a/tests/soak/README.md b/tests/soak/README.md new file mode 100644 index 0000000..d1fb8f9 --- /dev/null +++ b/tests/soak/README.md @@ -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. diff --git a/tests/soak/bun.lock b/tests/soak/bun.lock new file mode 100644 index 0000000..ad1e2a3 --- /dev/null +++ b/tests/soak/bun.lock @@ -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=="], + } +} diff --git a/tests/soak/config.ts b/tests/soak/config.ts new file mode 100644 index 0000000..5c85b20 --- /dev/null +++ b/tests/soak/config.ts @@ -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, + env: Record, + defaults: Record, +): Record { + const merged: Record = { ...defaults }; + + // Env overlay — SOAK_UPPER_SNAKE → lowerCamel in cli space. + const envMap: Record = { + 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; +} diff --git a/tests/soak/core/artifacts.ts b/tests/soak/core/artifacts.ts new file mode 100644 index 0000000..eeeb947 --- /dev/null +++ b/tests/soak/core/artifacts.ts @@ -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//`. + * + * 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 { + 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 { + 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 + } + } +} diff --git a/tests/soak/core/deferred.ts b/tests/soak/core/deferred.ts new file mode 100644 index 0000000..4e235ad --- /dev/null +++ b/tests/soak/core/deferred.ts @@ -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 { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/tests/soak/core/logger.ts b/tests/soak/core/logger.ts new file mode 100644 index 0000000..21fa3d1 --- /dev/null +++ b/tests/soak/core/logger.ts @@ -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 = { + 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; +} + +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; +} diff --git a/tests/soak/core/room-coordinator.ts b/tests/soak/core/room-coordinator.ts new file mode 100644 index 0000000..0493752 --- /dev/null +++ b/tests/soak/core/room-coordinator.ts @@ -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>(); + + announce(roomId: string, code: string): void { + this.getOrCreate(roomId).resolve(code); + } + + async await(roomId: string, timeoutMs: number = 30_000): Promise { + const d = this.getOrCreate(roomId); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, 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 { + let d = this.rooms.get(roomId); + if (!d) { + d = deferred(); + this.rooms.set(roomId, d); + } + return d; + } +} diff --git a/tests/soak/core/screencaster.ts b/tests/soak/core/screencaster.ts new file mode 100644 index 0000000..2c3725d --- /dev/null +++ b/tests/soak/core/screencaster.ts @@ -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(); + + 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 { + 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 { + 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 { + const keys = Array.from(this.sessions.keys()); + await Promise.all(keys.map((k) => this.stop(k))); + } +} diff --git a/tests/soak/core/session-pool.ts b/tests/soak/core/session-pool.ts new file mode 100644 index 0000000..b4e63c1 --- /dev/null +++ b/tests/soak/core/session-pool.ts @@ -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 { + 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(() => ''); + 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 { + 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(() => ''); + 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[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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/tests/soak/core/types.ts b/tests/soak/core/types.ts new file mode 100644 index 0000000..49313df --- /dev/null +++ b/tests/soak/core/types.ts @@ -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; + +export interface ScenarioError { + room: string; + reason: string; + detail?: string; + timestamp: number; +} + +export interface ScenarioResult { + gamesCompleted: number; + errors: ScenarioError[]; + durationMs: number; + customMetrics?: Record; +} + +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; +} + +// ============================================================================= +// Room coordination +// ============================================================================= + +export interface RoomCoordinatorApi { + announce(roomId: string, code: string): void; + await(roomId: string, timeoutMs?: number): Promise; +} + +// ============================================================================= +// 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): 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; +} diff --git a/tests/soak/core/watchdog.ts b/tests/soak/core/watchdog.ts new file mode 100644 index 0000000..b79fd01 --- /dev/null +++ b/tests/soak/core/watchdog.ts @@ -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; + } + } +} diff --git a/tests/soak/dashboard/dashboard.css b/tests/soak/dashboard/dashboard.css new file mode 100644 index 0000000..8914eb8 --- /dev/null +++ b/tests/soak/dashboard/dashboard.css @@ -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); +} diff --git a/tests/soak/dashboard/dashboard.js b/tests/soak/dashboard/dashboard.js new file mode 100644 index 0000000..44000bc --- /dev/null +++ b/tests/soak/dashboard/dashboard.js @@ -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) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); + } + + function ensureRoomTile(roomId) { + if (roomTiles.has(roomId)) return roomTiles.get(roomId); + const tile = document.createElement('div'); + tile.className = 'room'; + tile.innerHTML = ` +
+
${escapeHtml(roomId)}
+
waiting
+
+
+
+
+ 0 moves + game — +
+ `; + 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) => ` +
+ ${p.isActive ? '▶ ' : ''}${escapeHtml(p.key)} + ${p.score ?? '—'} +
+ `, + ) + .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(); + }); +})(); diff --git a/tests/soak/dashboard/index.html b/tests/soak/dashboard/index.html new file mode 100644 index 0000000..7a64b8a --- /dev/null +++ b/tests/soak/dashboard/index.html @@ -0,0 +1,47 @@ + + + + + +Golf Soak Dashboard + + + +
+

⛳ Golf Soak Dashboard

+
+ run — + 00:00:00 +
+
+ +
+
Games0
+
Moves0
+
Errors0
+
WSconnecting
+
+ +
+ +
+ +
+
Activity Log
+
    +
    + + + + + + + diff --git a/tests/soak/dashboard/server.ts b/tests/soak/dashboard/server.ts new file mode 100644 index 0000000..ebc1f14 --- /dev/null +++ b/tests/soak/dashboard/server.ts @@ -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 } + | { 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(); + private metrics: Record = {}; + private roomStates: Record> = {}; + + constructor( + private port: number, + private logger: Logger, + private handlers: DashboardHandlers = {}, + ) {} + + async start(): Promise { + 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((resolve) => { + this.httpServer.listen(this.port, () => resolve()); + }); + this.logger.info('dashboard_listening', { url: `http://localhost:${this.port}` }); + } + + async stop(): Promise { + for (const ws of this.clients) { + try { + ws.close(); + } catch { + // ignore + } + } + this.clients.clear(); + await new Promise((resolve) => { + this.wsServer.close(() => resolve()); + }); + await new Promise((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] }); + }, + }; + } +} diff --git a/tests/soak/package.json b/tests/soak/package.json new file mode 100644 index 0000000..fd5e874 --- /dev/null +++ b/tests/soak/package.json @@ -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" + } +} diff --git a/tests/soak/runner.ts b/tests/soak/runner.ts new file mode 100644 index 0000000..a0ff64d --- /dev/null +++ b/tests/soak/runner.ts @@ -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 { + const cli: CliArgs = parseArgs(process.argv.slice(2)); + + if (cli.listOnly) { + printScenarioList(); + return; + } + + if (!cli.scenario) { + console.error('Error: --scenario= 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, + 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(); + 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(); + 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); +}); diff --git a/tests/soak/scenarios/index.ts b/tests/soak/scenarios/index.ts new file mode 100644 index 0000000..cca7384 --- /dev/null +++ b/tests/soak/scenarios/index.ts @@ -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 = { + populate, + stress, +}; + +export function getScenario(name: string): Scenario | undefined { + return registry[name]; +} + +export function listScenarios(): Scenario[] { + return Object.values(registry); +} diff --git a/tests/soak/scenarios/populate.ts b/tests/soak/scenarios/populate.ts new file mode 100644 index 0000000..bb6dcc4 --- /dev/null +++ b/tests/soak/scenarios/populate.ts @@ -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(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 { + 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 { + 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; diff --git a/tests/soak/scenarios/shared/chaos.ts b/tests/soak/scenarios/shared/chaos.ts new file mode 100644 index 0000000..59ed9dd --- /dev/null +++ b/tests/soak/scenarios/shared/chaos.ts @@ -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 { + 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; +} diff --git a/tests/soak/scenarios/shared/multiplayer-game.ts b/tests/soak/scenarios/shared/multiplayer-game.ts new file mode 100644 index 0000000..187a567 --- /dev/null +++ b/tests/soak/scenarios/shared/multiplayer-game.ts @@ -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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function runOneMultiplayerGame( + ctx: ScenarioContext, + sessions: Session[], + opts: MultiplayerGameOptions, +): Promise { + 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 { + 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), + }; + } +} diff --git a/tests/soak/scenarios/stress.ts b/tests/soak/scenarios/stress.ts new file mode 100644 index 0000000..c4e713d --- /dev/null +++ b/tests/soak/scenarios/stress.ts @@ -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(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 { + 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 { + 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; diff --git a/tests/soak/scripts/seed-accounts.ts b/tests/soak/scripts/seed-accounts.ts new file mode 100644 index 0000000..ff67686 --- /dev/null +++ b/tests/soak/scripts/seed-accounts.ts @@ -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 { + 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(); diff --git a/tests/soak/scripts/smoke.sh b/tests/soak/scripts/smoke.sh new file mode 100755 index 0000000..6b2125f --- /dev/null +++ b/tests/soak/scripts/smoke.sh @@ -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" diff --git a/tests/soak/tests/config.test.ts b/tests/soak/tests/config.test.ts new file mode 100644 index 0000000..bc40dcf --- /dev/null +++ b/tests/soak/tests/config.test.ts @@ -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); + }); +}); diff --git a/tests/soak/tests/deferred.test.ts b/tests/soak/tests/deferred.test.ts new file mode 100644 index 0000000..c0977fa --- /dev/null +++ b/tests/soak/tests/deferred.test.ts @@ -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(); + d.resolve('hello'); + await expect(d.promise).resolves.toBe('hello'); + }); + + it('rejects with the given error', async () => { + const d = deferred(); + const err = new Error('boom'); + d.reject(err); + await expect(d.promise).rejects.toBe(err); + }); + + it('ignores second resolve calls', async () => { + const d = deferred(); + d.resolve(1); + d.resolve(2); + await expect(d.promise).resolves.toBe(1); + }); +}); diff --git a/tests/soak/tests/logger.test.ts b/tests/soak/tests/logger.test.ts new file mode 100644 index 0000000..96a492d --- /dev/null +++ b/tests/soak/tests/logger.test.ts @@ -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); + }); +}); diff --git a/tests/soak/tests/room-coordinator.test.ts b/tests/soak/tests/room-coordinator.test.ts new file mode 100644 index 0000000..f3fd6aa --- /dev/null +++ b/tests/soak/tests/room-coordinator.test.ts @@ -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); + }); +}); diff --git a/tests/soak/tests/watchdog.test.ts b/tests/soak/tests/watchdog.test.ts new file mode 100644 index 0000000..ec4f308 --- /dev/null +++ b/tests/soak/tests/watchdog.test.ts @@ -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(); + }); +}); diff --git a/tests/soak/tsconfig.json b/tests/soak/tsconfig.json new file mode 100644 index 0000000..7059c25 --- /dev/null +++ b/tests/soak/tsconfig.json @@ -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"] +}
    ${escapeHtml(user.username)}${escapeHtml(user.username)}${testBadge} ${escapeHtml(user.email || '-')} ${user.role} ${getStatusBadge(user)}
    ${escapeHtml(invite.code)}${escapeHtml(invite.code)}${testSeedBadge} ${invite.use_count} / ${invite.max_uses} ${invite.remaining_uses} ${escapeHtml(invite.created_by_username)}