From 3a93fef0f30af24795784d040a24ce8dbf63daf6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Aug 2026 19:55:09 +0200 Subject: [PATCH] fix(mock): scope /reset by MAC so parallel specs stop wiping each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-authentication test passed locally but failed all three CI attempts: no request carried a token, because self-hosted.e2e.ts issues a GLOBAL `POST /reset` against the same mock from a parallel Playwright worker, destroying the session mid-import. Running only stalker.e2e.ts locally never triggered it. Serializing within one file (4b31f7167) could not fix this — the interference is between files. Mock state is per-MAC, so `/reset` now accepts `?macAddress=` and clears only that MAC's data, favorites, session and watchdog counters; the unscoped form is kept for callers that own the whole server. Both spec files now reset only the MACs they own, so no worker can disturb another. Verified: a scoped reset of one MAC leaves another MAC's session intact (and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together 23/23 green — the combination that reproduced the CI failure. Co-Authored-By: Claude Fable 5 --- .../stalker-mock-server/src/app/auth-store.ts | 6 +++- .../stalker-mock-server/src/app/data-store.ts | 11 ++++++ .../src/app/handlers/get-events.handler.ts | 6 +++- apps/stalker-mock-server/src/main.ts | 36 +++++++++++++++---- apps/web-e2e/src/self-hosted.e2e.ts | 8 ++++- apps/web-e2e/src/stalker.e2e.ts | 35 ++++++++++++++++-- 6 files changed, 89 insertions(+), 13 deletions(-) diff --git a/apps/stalker-mock-server/src/app/auth-store.ts b/apps/stalker-mock-server/src/app/auth-store.ts index a69b5d7db..c32486f5f 100644 --- a/apps/stalker-mock-server/src/app/auth-store.ts +++ b/apps/stalker-mock-server/src/app/auth-store.ts @@ -201,7 +201,11 @@ export function invalidateSession(mac: string): void { session.pendingToken = undefined; } -export function resetAuthState(): void { +export function resetAuthState(mac?: string): void { + if (mac) { + sessions.delete(mac.toLowerCase()); + return; + } sessions.clear(); tokenCounter = 0; } diff --git a/apps/stalker-mock-server/src/app/data-store.ts b/apps/stalker-mock-server/src/app/data-store.ts index 52a16a756..4aca56f4f 100644 --- a/apps/stalker-mock-server/src/app/data-store.ts +++ b/apps/stalker-mock-server/src/app/data-store.ts @@ -44,6 +44,17 @@ export function resetFavorites(mac: string): void { favoritesStore.delete(mac.toLowerCase()); } +/** + * Reset everything cached for one MAC. Preferred over `resetAll()` in tests: + * mock state is per-MAC, so a scoped reset cannot disturb a spec that runs + * concurrently against a different MAC. + */ +export function resetMac(mac: string): void { + const key = mac.toLowerCase(); + portalCache.delete(key); + favoritesStore.delete(key); +} + /** Reset all cached data (exposed via /reset endpoint). */ export function resetAll(): void { portalCache.clear(); diff --git a/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts index 9132ab64a..32cc4c37a 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts @@ -35,6 +35,10 @@ export function getWatchdogPings( return watchdogPings.get(mac.toLowerCase()); } -export function resetWatchdogPings(): void { +export function resetWatchdogPings(mac?: string): void { + if (mac) { + watchdogPings.delete(mac.toLowerCase()); + return; + } watchdogPings.clear(); } diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 93b2876dc..121e26354 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -6,7 +6,7 @@ import portalRouter, { createPortalRouter } from './app/routes/portal.route.js'; import dispatchPortalAction from './app/routes/dispatch.js'; import { invalidateSession, resetAuthState } from './app/auth-store.js'; import { resetWatchdogPings } from './app/handlers/get-events.handler.js'; -import { resetAll } from './app/data-store.js'; +import { resetAll, resetMac } from './app/data-store.js'; import { SCENARIOS } from './app/scenarios.js'; import { buildRequestOrigin, @@ -173,12 +173,34 @@ app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Reset all in-memory data (useful between Playwright test runs) -app.post('/reset', (_req: Request, res: Response) => { - resetAll(); - resetAuthState(); - resetWatchdogPings(); - res.json({ status: 'reset', timestamp: new Date().toISOString() }); +/** + * Reset in-memory state between test runs. + * + * `?macAddress=` scopes the reset to that MAC and is what specs should + * use: mock state is per-MAC, so a scoped reset cannot wipe the session of a + * spec file running concurrently in another Playwright worker. Without the + * parameter everything is cleared, which is only safe when nothing else is + * talking to this server. + */ +app.post('/reset', (req: Request, res: Response) => { + const macParam = req.query['macAddress']; + const mac = typeof macParam === 'string' ? macParam : undefined; + + if (mac) { + resetMac(mac); + resetAuthState(mac); + resetWatchdogPings(mac); + } else { + resetAll(); + resetAuthState(); + resetWatchdogPings(); + } + + res.json({ + status: 'reset', + ...(mac ? { mac } : {}), + timestamp: new Date().toISOString(), + }); }); /** diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index b00a0b5f1..782ef4ce7 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -102,7 +102,13 @@ function expectRequestsUseTargetId(requests: string[], path: string): void { test.beforeEach(async ({ page, request }) => { await request.post(`${XTREAM_MOCK_SERVER}/reset`); - await request.post(`${STALKER_MOCK_SERVER}/reset`); + // Scope the Stalker reset to the MAC this file uses: a global reset would + // wipe the sessions of stalker.e2e.ts running in a parallel worker. + await request.post( + `${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent( + DEFAULT_MAC + )}` + ); await installRuntimeConfig(page); await page.goto('/'); }); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 0600813de..2fbb6196d 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -106,12 +106,38 @@ async function interceptStalkerRequests(page: Page): Promise { }); } +/** Every MAC this file touches; each is reset individually (see below). */ +const OWNED_MACS = [ + DEFAULT_MAC, + MINIMAL_MAC, + EMBEDDED_SERIES_MAC, + LEGACY_PAGINATION_MAC, + AUTH_FLOW_MAC, + AUTH_REAUTH_MAC, + AUTH_REJECTED_MAC, +]; + +/** + * Reset only the MACs this file owns. Mock state is per-MAC and other spec + * files (self-hosted.e2e.ts) talk to the same server from a parallel worker, + * so a global reset here would wipe their sessions mid-test — and theirs + * would wipe ours. + */ async function resetMockServer(request: APIRequestContext): Promise { + await Promise.all(OWNED_MACS.map((mac) => resetMockServerMac(request, mac))); +} + +async function resetMockServerMac( + request: APIRequestContext, + mac: string +): Promise { let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { - const response = await request.post(`${MOCK_SERVER}/reset`); + const response = await request.post( + `${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(mac)}` + ); if (response.ok()) { return; } @@ -630,9 +656,12 @@ test('@stalker mock server reset clears cached state', async ({ request }) => { ); expect(before.ok()).toBeTruthy(); - // Reset - const reset = await request.post(`${MOCK_SERVER}/reset`); + // Scoped reset — a global one would clear MACs owned by parallel specs. + const reset = await request.post( + `${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(DEFAULT_MAC)}` + ); expect(reset.ok()).toBeTruthy(); + expect((await reset.json()).mac).toBe(DEFAULT_MAC); // Data is regenerated identically (deterministic seed) const after = await request.get(