From d16367582c46e026101bbc228f9343cd348f97b9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 10 Apr 2026 23:48:35 -0400 Subject: [PATCH 01/29] feat(server): add is_test_account + marks_as_test schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New columns support separating soak-harness test traffic from real user traffic in stats queries. Rebuilds leaderboard_overall matview to include is_test_account so the fast path stays filterable. Migration is idempotent via DO $$ / IF NOT EXISTS blocks inside SCHEMA_SQL, which runs on every server startup — same mechanism every existing post-v1 column migration uses. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/stores/user_store.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/server/stores/user_store.py b/server/stores/user_store.py index 33e996b..febf639 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); From 3817566ed56eaf56a4f3cf0ca9ec37340fb5ba65 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 10 Apr 2026 23:52:55 -0400 Subject: [PATCH 02/29] docs: rename test-account index to match users_v2 convention Post-review fix for Task 1: code reviewer flagged that idx_users_v2_is_test_account didn't match the idx_users_ convention used by every other index in user_store.py. The implementation commit (d163675) was amended to use idx_users_test_account; this commit updates the plan and spec docs so they stay in sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-10-multiplayer-soak-test.md | 10 +++++----- .../specs/2026-04-10-multiplayer-soak-test-design.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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; ``` From 8e23adee143b8c5f03694284ac4ae3fedfbea63b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 00:01:53 -0400 Subject: [PATCH 03/29] feat(server): propagate is_test_account through User model & store User dataclass, create_user, and all SELECT lists now round-trip the new column. Value is always FALSE until Task 4 wires the register flow to the invite code's marks_as_test flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/models/user.py | 4 ++++ server/stores/user_store.py | 29 ++++++++++++++++++----------- 2 files changed, 22 insertions(+), 11 deletions(-) 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/stores/user_store.py b/server/stores/user_store.py index febf639..c103762 100644 --- a/server/stores/user_store.py +++ b/server/stores/user_store.py @@ -476,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. @@ -488,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. @@ -497,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, @@ -511,6 +514,7 @@ class UserStore: guest_id, verification_token, verification_expires, + is_test_account, ) return self._row_to_user(row) except asyncpg.UniqueViolationError: @@ -524,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 """, @@ -540,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) """, @@ -556,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) """, @@ -572,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 """, @@ -588,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 """, @@ -686,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: @@ -730,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 """ @@ -740,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 @@ -1033,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: From 1f20ac953555b8d25d622335dc03927fea66f8eb Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 00:11:34 -0400 Subject: [PATCH 04/29] feat(server): expose marks_as_test on InviteCode Adds the field to the dataclass, SELECT list in get_invite_codes, and a new get_invite_code_details helper that the register flow will use to discover whether an invite should flag new accounts as test accounts. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/services/admin_service.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/server/services/admin_service.py b/server/services/admin_service.py index 5af3cc4..c8af7ff 100644 --- a/server/services/admin_service.py +++ b/server/services/admin_service.py @@ -123,6 +123,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 +136,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, } @@ -1117,6 +1119,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 +1141,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 +1217,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). From 0891e6c979dda2da1cb7d9e9a5c63d89ca95a846 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 00:16:56 -0400 Subject: [PATCH 05/29] feat(server): register flow flags accounts from test-seed invites When a user registers with an invite_code whose marks_as_test=TRUE, their users_v2.is_test_account is set to TRUE. Normal invite codes and invite-less signups are unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routers/auth.py | 13 +++++++++++++ server/services/auth_service.py | 3 +++ 2 files changed, 16 insertions(+) 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/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: From b5a25b4ae5c3c0e15023fdd41fd630fe46cf8440 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 00:33:38 -0400 Subject: [PATCH 06/29] feat(server): stats queries support include_test filter Leaderboard and rank queries take an optional include_test param (default false). Real users never see soak-harness traffic unless they explicitly opt in via ?include_test=true. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routers/stats.py | 10 +++++++--- server/services/stats_service.py | 29 +++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) 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/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 From 917ef2a239f336ad3f1bc75e722d728dfdfe234f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 01:09:37 -0400 Subject: [PATCH 07/29] feat(server): admin users list surfaces is_test_account UserDetails carries the new column, search_users selects and optionally filters on it, and the /api/admin/users route accepts ?include_test=false to hide soak-harness accounts. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routers/admin.py | 6 ++++++ server/services/admin_service.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) 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/services/admin_service.py b/server/services/admin_service.py index c8af7ff..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, } @@ -318,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. @@ -328,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. @@ -338,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 @@ -358,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]) @@ -379,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 ] @@ -387,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. @@ -400,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 @@ -427,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( From 983518e93dc3841eed7e3c4c4ac4dd8cd84104e6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 01:20:09 -0400 Subject: [PATCH 08/29] feat(admin): visible Test/Test-seed badges + filter toggle Users table shows [Test] next to soak-harness accounts, invite codes list shows [Test-seed] next to codes that flag new accounts as test, and a new "Include test accounts" checkbox lets admins hide bot traffic from the user list. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/admin.html | 4 ++++ client/admin.js | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/client/admin.html b/client/admin.html index 3f5da71..32fc9d9 100644 --- a/client/admin.html +++ b/client/admin.html @@ -114,6 +114,10 @@ Include banned + 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--; From 835a79cc0fda464ad65f45c8a4291d294f8fa1df Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 01:23:13 -0400 Subject: [PATCH 09/29] docs: soak harness bring-up steps Documents the one-time UPDATE invite_codes SET marks_as_test = TRUE step required before running tests/soak against each environment, schema verification queries, and the expected filter behavior post-run. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/soak-harness-bringup.md | 125 +++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/soak-harness-bringup.md 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 From 5478a4299e3461bb548ddb3bd994d59fd67c6704 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 08:19:09 -0400 Subject: [PATCH 10/29] feat(soak): scaffold tests/soak package Placeholder runner, tsconfig with @bot alias to tests/e2e/bot, gitignored .env.stresstest + artifacts. Real behavior follows in Task 10 onward. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/.env.stresstest.example | 6 + tests/soak/.gitignore | 5 + tests/soak/README.md | 21 ++ tests/soak/bun.lock | 337 +++++++++++++++++++++++++++++ tests/soak/package.json | 25 +++ tests/soak/runner.ts | 16 ++ tests/soak/tsconfig.json | 24 ++ 7 files changed, 434 insertions(+) create mode 100644 tests/soak/.env.stresstest.example create mode 100644 tests/soak/.gitignore create mode 100644 tests/soak/README.md create mode 100644 tests/soak/bun.lock create mode 100644 tests/soak/package.json create mode 100644 tests/soak/runner.ts create mode 100644 tests/soak/tsconfig.json 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..c56d1d7 --- /dev/null +++ b/tests/soak/bun.lock @@ -0,0 +1,337 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "golf-soak", + "dependencies": { + "playwright-core": "^1.40.0", + "ws": "^8.16.0", + }, + "devDependencies": { + "@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=="], + + "@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-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=="], + + "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/package.json b/tests/soak/package.json new file mode 100644 index 0000000..25420f8 --- /dev/null +++ b/tests/soak/package.json @@ -0,0 +1,25 @@ +{ + "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": { + "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..7017c92 --- /dev/null +++ b/tests/soak/runner.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env tsx +/** + * Golf Soak Harness — entry point. + * + * Placeholder. Full runner lands in Task 18. + */ + +async function main(): Promise { + console.log('golf-soak runner (placeholder)'); + console.log('Full implementation lands in Task 18 of the plan.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/soak/tsconfig.json b/tests/soak/tsconfig.json new file mode 100644 index 0000000..ab4af2b --- /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", + "rootDir": ".", + "baseUrl": ".", + "lib": ["ES2022", "DOM"], + "paths": { + "@soak/*": ["./*"], + "@bot/*": ["../e2e/bot/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "artifacts"] +} From 1565046ab7d674ed5073635d2f1318f1fc69a2f2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 16:58:56 -0400 Subject: [PATCH 11/29] feat(soak): core types + Deferred primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the Scenario/Session/Logger/DashboardReporter contracts the rest of the harness builds on. Deferred is the building block for RoomCoordinator's host→joiners handoff. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/core/deferred.ts | 20 ++++ tests/soak/core/types.ts | 185 ++++++++++++++++++++++++++++++ tests/soak/tests/deferred.test.ts | 24 ++++ 3 files changed, 229 insertions(+) create mode 100644 tests/soak/core/deferred.ts create mode 100644 tests/soak/core/types.ts create mode 100644 tests/soak/tests/deferred.test.ts 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/types.ts b/tests/soak/core/types.ts new file mode 100644 index 0000000..6a13b8b --- /dev/null +++ b/tests/soak/core/types.ts @@ -0,0 +1,185 @@ +/** + * 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 structural interface +// ============================================================================= + +/** + * Structural interface for the real `GolfBot` class from + * `tests/e2e/bot/golf-bot.ts`. We can't type-import the real class + * because (a) it lives outside this package's `rootDir`, and (b) it + * imports `@playwright/test` which isn't in this package's deps. + * + * Instead we declare the narrow public contract the soak harness + * actually calls. `SessionPool` constructs the real class at runtime + * via a dynamic require and casts it to this interface. When golf-bot + * gains new methods the harness wants, add them here — TypeScript will + * flag drift at the first call site. + */ + +export type GamePhase = + | 'lobby' + | 'waiting_for_flip' + | 'playing' + | 'round_over' + | 'game_over' + | 'unknown'; + +export interface StartGameOptions { + holes?: number; + decks?: number; + initialFlips?: number; + flipMode?: 'never' | 'always' | 'endgame'; + knockPenalty?: boolean; + jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye'; +} + +export interface TurnResult { + success: boolean; + action: string; + details?: Record; + error?: string; +} + +export interface GolfBot { + readonly page: Page; + goto(url?: string): Promise; + createGame(playerName: string): Promise; + joinGame(roomCode: string, playerName: string): Promise; + addCPU(profileName?: string): Promise; + startGame(options?: StartGameOptions): Promise; + isMyTurn(): Promise; + waitForMyTurn(timeout?: number): Promise; + getGamePhase(): Promise; + getGameState(): Promise>; + playTurn(): Promise; + completeInitialFlips(): Promise; + isFrozen(timeout?: number): Promise; + takeScreenshot(label: string): Promise; + getConsoleErrors?(): string[]; +} + +// ============================================================================= +// 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/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); + }); +}); From 02642840dacd26a8f0cb6881d2820282ae9a1c8d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:11:05 -0400 Subject: [PATCH 12/29] =?UTF-8?q?feat(soak):=20RoomCoordinator=20with=20ho?= =?UTF-8?q?st=E2=86=92joiners=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazy Deferred per roomId with a timeout on await. Lets concurrent joiner sessions block until their host announces the room code without polling or page scraping. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/core/room-coordinator.ts | 42 +++++++++++++++++++++++ tests/soak/tests/room-coordinator.test.ts | 29 ++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/soak/core/room-coordinator.ts create mode 100644 tests/soak/tests/room-coordinator.test.ts 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/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); + }); +}); From 066e482f06b648463a87fa74b2f5b4230cb5e7b9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:12:27 -0400 Subject: [PATCH 13/29] feat(soak): structured JSONL logger with child contexts Single file, no transport, writes one JSON line per call to stdout. Child loggers inherit parent meta so scenarios can bind room/game context once and forget about it. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/core/logger.ts | 59 +++++++++++++++++++++++++++++++++ tests/soak/tests/logger.test.ts | 52 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/soak/core/logger.ts create mode 100644 tests/soak/tests/logger.test.ts 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/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); + }); +}); From 3bc0270eb9b34b353441858b978e646775b5c073 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:19:39 -0400 Subject: [PATCH 14/29] =?UTF-8?q?feat(soak):=20SessionPool=20=E2=80=94=20s?= =?UTF-8?q?eed,=20login,=20acquire=20contexts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owns BrowserContexts, seeds via POST /api/auth/register with the invite code on cold start, warm-starts via localStorage injection of the cached JWT, falls back to POST /api/auth/login if the token is rejected. Exposes acquire(n) for scenarios. Infrastructure changes needed to import the real GolfBot class from tests/e2e/bot/ without the Task-10 structural-interface workaround: - Add @playwright/test as devDep so value-imports in e2e/bot/*.ts resolve at runtime (Page/Locator/expect are pulled even as types) - Remove rootDir from tsconfig so TS follows cross-package imports; add a paths entry so TS can resolve @playwright/test from the soak package's node_modules when compiling files under tests/e2e/bot - Drop the local GolfBot structural interface + its placeholder GamePhase/StartGameOptions/TurnResult types; re-export the real class from types.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/bun.lock | 7 + tests/soak/core/session-pool.ts | 305 ++++++++++++++++++++++++++++++++ tests/soak/core/types.ts | 58 +----- tests/soak/package.json | 1 + tests/soak/tsconfig.json | 4 +- 5 files changed, 318 insertions(+), 57 deletions(-) create mode 100644 tests/soak/core/session-pool.ts diff --git a/tests/soak/bun.lock b/tests/soak/bun.lock index c56d1d7..ad1e2a3 100644 --- a/tests/soak/bun.lock +++ b/tests/soak/bun.lock @@ -9,6 +9,7 @@ "ws": "^8.16.0", }, "devDependencies": { + "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "@types/ws": "^8.5.0", "tsx": "^4.7.0", @@ -74,6 +75,8 @@ "@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=="], @@ -220,6 +223,8 @@ "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=="], @@ -286,6 +291,8 @@ "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=="], diff --git a/tests/soak/core/session-pool.ts b/tests/soak/core/session-pool.ts new file mode 100644 index 0000000..8134970 --- /dev/null +++ b/tests/soak/core/session-pool.ts @@ -0,0 +1,305 @@ +/** + * 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]; +} + +export class SessionPool { + private accounts: Account[] = []; + private ownedBrowser: Browser | null = null; + private browser: Browser | 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. + */ + async acquire(count: number): Promise { + await this.ensureAccounts(count); + if (!this.browser) { + this.ownedBrowser = await chromium.launch({ headless: true }); + this.browser = this.ownedBrowser; + } + + const sessions: Session[] = []; + for (let i = 0; i < count; i++) { + const account = this.accounts[i]; + const context = await this.browser.newContext(this.opts.contextOptions); + await this.injectAuth(context, account); + const page = await context.newPage(); + await page.goto(this.opts.targetUrl); + 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. 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; + } + } +} diff --git a/tests/soak/core/types.ts b/tests/soak/core/types.ts index 6a13b8b..49313df 100644 --- a/tests/soak/core/types.ts +++ b/tests/soak/core/types.ts @@ -8,63 +8,11 @@ import type { BrowserContext, Page } from 'playwright-core'; // ============================================================================= -// GolfBot structural interface +// GolfBot — real class from tests/e2e/bot/ // ============================================================================= -/** - * Structural interface for the real `GolfBot` class from - * `tests/e2e/bot/golf-bot.ts`. We can't type-import the real class - * because (a) it lives outside this package's `rootDir`, and (b) it - * imports `@playwright/test` which isn't in this package's deps. - * - * Instead we declare the narrow public contract the soak harness - * actually calls. `SessionPool` constructs the real class at runtime - * via a dynamic require and casts it to this interface. When golf-bot - * gains new methods the harness wants, add them here — TypeScript will - * flag drift at the first call site. - */ - -export type GamePhase = - | 'lobby' - | 'waiting_for_flip' - | 'playing' - | 'round_over' - | 'game_over' - | 'unknown'; - -export interface StartGameOptions { - holes?: number; - decks?: number; - initialFlips?: number; - flipMode?: 'never' | 'always' | 'endgame'; - knockPenalty?: boolean; - jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye'; -} - -export interface TurnResult { - success: boolean; - action: string; - details?: Record; - error?: string; -} - -export interface GolfBot { - readonly page: Page; - goto(url?: string): Promise; - createGame(playerName: string): Promise; - joinGame(roomCode: string, playerName: string): Promise; - addCPU(profileName?: string): Promise; - startGame(options?: StartGameOptions): Promise; - isMyTurn(): Promise; - waitForMyTurn(timeout?: number): Promise; - getGamePhase(): Promise; - getGameState(): Promise>; - playTurn(): Promise; - completeInitialFlips(): Promise; - isFrozen(timeout?: number): Promise; - takeScreenshot(label: string): Promise; - getConsoleErrors?(): string[]; -} +import type { GolfBot } from '../../e2e/bot/golf-bot'; +export type { GolfBot }; // ============================================================================= // Accounts & sessions diff --git a/tests/soak/package.json b/tests/soak/package.json index 25420f8..fd5e874 100644 --- a/tests/soak/package.json +++ b/tests/soak/package.json @@ -16,6 +16,7 @@ "ws": "^8.16.0" }, "devDependencies": { + "@playwright/test": "^1.40.0", "tsx": "^4.7.0", "@types/ws": "^8.5.0", "@types/node": "^20.10.0", diff --git a/tests/soak/tsconfig.json b/tests/soak/tsconfig.json index ab4af2b..7059c25 100644 --- a/tests/soak/tsconfig.json +++ b/tests/soak/tsconfig.json @@ -11,12 +11,12 @@ "declaration": false, "sourceMap": true, "outDir": "./dist", - "rootDir": ".", "baseUrl": ".", "lib": ["ES2022", "DOM"], "paths": { "@soak/*": ["./*"], - "@bot/*": ["../e2e/bot/*"] + "@bot/*": ["../e2e/bot/*"], + "@playwright/test": ["./node_modules/@playwright/test"] } }, "include": ["**/*.ts"], From 2a86b3cc54148e7862d168c9d7ece1a6de87ae75 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:22:10 -0400 Subject: [PATCH 15/29] feat(soak): scripts/seed-accounts.ts CLI wrapper Thin standalone entry for pre-seeding N accounts before the first harness run. Wraps SessionPool.seed and writes .env.stresstest. End-to-end verified: ran against local dev with --count=4, all 4 accounts landed in the DB with is_test_account=TRUE, cred file written with correct format. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/scripts/seed-accounts.ts | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/soak/scripts/seed-accounts.ts 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(); From 722934bdf2b9e89711f8c2a6589445e6e7c508ae Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:23:00 -0400 Subject: [PATCH 16/29] feat(soak): shared runOneMultiplayerGame helper Encapsulates the host-creates/joiners-join/loop-until-done flow so populate and stress scenarios don't duplicate it. Honors abort signal and a max-duration timeout, heartbeats on every turn. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../soak/scenarios/shared/multiplayer-game.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/soak/scenarios/shared/multiplayer-game.ts diff --git a/tests/soak/scenarios/shared/multiplayer-game.ts b/tests/soak/scenarios/shared/multiplayer-game.ts new file mode 100644 index 0000000..6c4d51d --- /dev/null +++ b/tests/soak/scenarios/shared/multiplayer-game.ts @@ -0,0 +1,121 @@ +/** + * 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 { + // Host creates game and announces the code + const code = await host.bot.createGame(host.account.username); + ctx.coordinator.announce(opts.roomId, 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(opts.roomId); + 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), + }; + } +} From 2c20b6c7b523e274a9837c99d436ca4f16e8afa2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:23:56 -0400 Subject: [PATCH 17/29] feat(soak): populate scenario + scenario registry Partitions sessions into N rooms, runs gamesPerRoom games per room in parallel via Promise.allSettled so a failure in one room never unwinds the others. Errors roll up into ScenarioResult.errors. Verified via tsx: listScenarios() returns [populate], getScenario() resolves by name and returns undefined for unknown names. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/scenarios/index.ts | 22 +++++ tests/soak/scenarios/populate.ts | 147 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/soak/scenarios/index.ts create mode 100644 tests/soak/scenarios/populate.ts diff --git a/tests/soak/scenarios/index.ts b/tests/soak/scenarios/index.ts new file mode 100644 index 0000000..303fdf5 --- /dev/null +++ b/tests/soak/scenarios/index.ts @@ -0,0 +1,22 @@ +/** + * 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'; + +const registry: Record = { + populate, +}; + +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; From 6df81e6f8d8a9ac8e11cae4a218cb11a05b372d7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:25:04 -0400 Subject: [PATCH 18/29] feat(soak): CLI parsing + config precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseArgs pulls --scenario/--rooms/--watch/etc from argv, mergeConfig layers scenarioDefaults → env → CLI so CLI flags always win. 12 Vitest unit tests cover both parse happy/edge paths and the 4-way merge precedence matrix. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/config.ts | 125 ++++++++++++++++++++++++++++++++ tests/soak/tests/config.test.ts | 102 ++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 tests/soak/config.ts create mode 100644 tests/soak/tests/config.test.ts 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/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); + }); +}); From a6a276b509aee1b97dd26d7bdf988c9652225fe1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 18:52:07 -0400 Subject: [PATCH 19/29] fix(bot): GolfBot handles authenticated sessions + num-decks stepper Two small fixes to tests/e2e/bot/golf-bot.ts needed to run the bot from the soak harness with authenticated accounts: 1. createGame and joinGame now check whether #player-name is visible before filling it. Authenticated sessions hide that input (the server uses the logged-in username); guest sessions still fill it as before. Existing e2e tests behave identically since they register guests who always see the input. 2. startGame's 'decks' option was calling selectOption on #num-decks, which is a hidden input inside a stepper widget, not a
${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)}