From 6b855eb73b9b84d159ab483d908ace76fed0797f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:57:46 +0200 Subject: [PATCH] fix(e2e): stop mock servers from outliving Playwright runs (#1710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(e2e): stop mock servers from outliving Playwright runs Playwright stops a webServer with a SIGKILL to the process group it spawned, but `nx run-commands` starts its command in a detached process group of its own. Launching the Xtream/Stalker mocks through `pnpm nx run *-mock-server:serve` therefore left the tsx server running (reparented to PID 1) and holding its port after every run, so the next run failed with "…/health is already used" or silently reused a stale server. Every Playwright config now starts the mocks as a single `node --import tsx apps//src/main.ts` process with TSX_TSCONFIG_PATH=tsconfig.base.json, which stays in Playwright's group. A project-config spec guards all playwright*.config.ts files against regressing to the Nx launch. Co-Authored-By: Claude Opus 5.5 * fix(e2e): read sidebar categories atomically; tighten mock launch guard - category-management: readVisibleSidebarCategoryNames read items one by one; when Save removed an item between isVisible() and textContent(), textContent() auto-waited for the gone label through the whole 15 s poll, so expect.poll never retried (ubuntu shard 1 failed 3/3 while the UI already showed "No categories available"). Take one snapshot with filter({ visible: true }).evaluateAll() instead. - project-config.spec: pin which Playwright configs start which mock, reject any Nx form that mentions a mock server, and fail when a new config starts a mock without being listed (the old count check passed vacuously on zero matches). - docs: state which configs start which mock instead of "every config starts both". Co-Authored-By: Claude Opus 5.5 * revert(e2e): leave the sidebar category read race to #1728 #1728 fixes the same readVisibleSidebarCategoryNames race with a shared helper; keeping a second copy here would only conflict. This PR stays about mock-server lifecycle. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../electron-backend-e2e/playwright.config.ts | 16 ++++- .../playwright.journeys.config.ts | 12 ++-- .../playwright.xtream-performance.config.ts | 5 +- apps/web-e2e/playwright.config.ts | 15 ++++- .../src/app/project-config.spec.ts | 60 ++++++++++++++++++- docs/architecture/stalker-mock-server.md | 18 +++++- docs/architecture/xtream-mock-server.md | 28 ++++++++- 7 files changed, 139 insertions(+), 15 deletions(-) diff --git a/apps/electron-backend-e2e/playwright.config.ts b/apps/electron-backend-e2e/playwright.config.ts index 9446117e2..5dd6cccd9 100644 --- a/apps/electron-backend-e2e/playwright.config.ts +++ b/apps/electron-backend-e2e/playwright.config.ts @@ -46,15 +46,27 @@ export default defineConfig({ /* Video on failure */ video: 'on-first-retry', }, + /* The mocks run as one node process, not through `nx run …:serve`: Nx + * starts its command in a detached process group, so Playwright's + * process-group kill missed it and the server kept its port after the run. + * See docs/architecture/xtream-mock-server.md#playwright-integration. */ webServer: [ { - command: 'pnpm nx run stalker-mock-server:serve', + command: 'node --import tsx apps/stalker-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, { - command: 'pnpm nx run xtream-mock-server:serve', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, diff --git a/apps/electron-backend-e2e/playwright.journeys.config.ts b/apps/electron-backend-e2e/playwright.journeys.config.ts index 02f158d43..0d04e996a 100644 --- a/apps/electron-backend-e2e/playwright.journeys.config.ts +++ b/apps/electron-backend-e2e/playwright.journeys.config.ts @@ -6,9 +6,11 @@ import { defineConfig } from '@playwright/test'; * worker, no retries: every journey spawns its own Electron processes and * writes one summary per run. The Xtream mock serves both the M3U playlist * and the portal on a dedicated loopback port so a normal E2E server on - * 3211 cannot be reused by accident. Locally a server left behind by an - * earlier run on that port is reused (its fixtures are deterministic); CI - * always starts its own. + * 3211 cannot be reused by accident. The mock runs as one node process + * rather than through `nx run …:serve`, whose detached process group outlived + * the run and kept the port. Locally a server already on the port (e.g. one + * started by hand) is reused, since its fixtures are deterministic; CI always + * starts its own. */ const xtreamMockPort = process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231'; @@ -24,11 +26,13 @@ export default defineConfig({ testIdAttribute: 'data-test-id', }, webServer: { - command: 'pnpm nx run xtream-mock-server:serve', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', cwd: workspaceRoot, env: { HOST: '127.0.0.1', + NODE_ENV: 'development', PORT: xtreamMockPort, + TSX_TSCONFIG_PATH: 'tsconfig.base.json', }, reuseExistingServer: !process.env['CI'], url: `http://127.0.0.1:${xtreamMockPort}/health`, diff --git a/apps/electron-backend-e2e/playwright.xtream-performance.config.ts b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts index fcf8086e9..57efa40a5 100644 --- a/apps/electron-backend-e2e/playwright.xtream-performance.config.ts +++ b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts @@ -19,13 +19,16 @@ export default defineConfig({ testIdAttribute: 'data-test-id', }, webServer: { - command: 'pnpm nx run xtream-mock-server:serve', + // One node process (not `nx run`) so the run's kill reaches it. + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', cwd: workspaceRoot, env: { HOST: '127.0.0.1', IPTVNATOR_XTREAM_MOCK_CONTROL: '1', IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: controlToken, + NODE_ENV: 'development', PORT: '3221', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', }, reuseExistingServer: false, url: 'http://127.0.0.1:3221/health', diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 0aa4c6a5d..2531cd588 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -66,14 +66,25 @@ export default defineConfig({ reuseExistingServer: reuseExistingWebServer, cwd: workspaceRoot, }, + /* The mocks run as one node process, not through `nx run …:serve`: + * Nx starts its command in a detached process group, so Playwright's + * process-group kill missed it and the server kept its port. */ { - command: 'pnpm nx run stalker-mock-server:serve', + command: 'node --import tsx apps/stalker-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, { - command: 'pnpm nx run xtream-mock-server:serve', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts index ace0639fb..be5c04a67 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; describe('Xtream mock Nx serve environment', () => { @@ -26,3 +26,61 @@ describe('Xtream mock Nx serve environment', () => { } ); }); + +describe('Playwright mock-server launch', () => { + const appsDirectory = join(process.cwd(), 'apps'); + const expectedMockLaunches: Record = { + 'apps/electron-backend-e2e/playwright.config.ts': ['stalker', 'xtream'], + 'apps/electron-backend-e2e/playwright.journeys.config.ts': ['xtream'], + 'apps/electron-backend-e2e/playwright.xtream-performance.config.ts': [ + 'xtream', + ], + 'apps/web-e2e/playwright.config.ts': ['stalker', 'xtream'], + }; + const readSource = (configPath: string) => + readFileSync(join(process.cwd(), configPath), 'utf8'); + + it('knows every Playwright config that starts a mock server', () => { + const configsUsingMocks = readdirSync(appsDirectory, { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => + readdirSync(join(appsDirectory, entry.name)) + .filter((name) => + /^playwright(\..+)?\.config\.ts$/.test(name) + ) + .map((name) => `apps/${entry.name}/${name}`) + ) + .filter((configPath) => + readSource(configPath).includes('-mock-server') + ); + + expect(configsUsingMocks.sort()).toEqual( + Object.keys(expectedMockLaunches).sort() + ); + }); + + // Nx starts a run-commands child in its own detached process group, and + // Playwright stops a webServer with a process-group SIGKILL, so a mock + // launched through Nx outlived the run and kept its port. + it.each(Object.entries(expectedMockLaunches))( + 'starts each mock in %s as a single node process', + (configPath, mocks) => { + const source = readSource(configPath); + + expect(source).not.toMatch(/\bnx\b[^'"`\n]*mock-server/); + for (const mock of mocks) { + expect(source).toContain( + `'node --import tsx apps/${mock}-mock-server/src/main.ts'` + ); + } + expect( + source.match(/node --import tsx apps\/[\w-]+-mock-server\//g) + ).toHaveLength(mocks.length); + expect( + source.match(/TSX_TSCONFIG_PATH: 'tsconfig\.base\.json'/g) + ).toHaveLength(mocks.length); + } + ); +}); diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index e1d6f2311..93cd022ef 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -364,7 +364,11 @@ form is the one specs should use. webServer: [ { command: webServerCommand /* web:serve */, url: baseURL }, { - command: 'pnpm nx run stalker-mock-server:serve', + command: 'node --import tsx apps/stalker-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, reuseExistingServer: !process.env['CI'], }, @@ -374,12 +378,20 @@ webServer: [ Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance. +The mock runs as a single `node` process instead of `pnpm nx run +stalker-mock-server:serve`, because Nx starts its command in a detached +process group that Playwright's process-group kill never reached, so the server +outlived the run. The +[Xtream mock's Playwright section](xtream-mock-server.md#playwright-integration) +has the details. + **`MOCK_PORT` relocates the whole run.** Playwright's health-check URL and the `MOCK_SERVER` constants in the specs read it, and `main.ts` resolves the server's port as `PORT`, then `MOCK_PORT`, then `3210` — so -`MOCK_PORT=3310 pnpm exec playwright test …` starts the Nx-managed mock on +`MOCK_PORT=3310 pnpm exec playwright test …` starts the mock on 3310 and points every spec at it, which is how two worktrees run E2E side by -side when one already holds 3210. This only works because the `serve` and +side when one already holds 3210. A hand-started Nx mock honours the same +knobs because the `serve` and `serve-with-watch` targets no longer pin `PORT` in `project.json`: an `env` entry in `nx:run-commands` overrides the shell (`{...process.env, ...env}`), so a pinned value silently discarded every override. `PORT=3310 pnpm nx run diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index ffcbd26af..ae5f36d17 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -550,13 +550,37 @@ The mock server is listed as a third `webServer` entry: ```typescript { - command: 'pnpm nx run xtream-mock-server:serve', - url: 'http://localhost:3211/health', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, } ``` +Every mock-server `webServer` entry uses this launch form, never +`pnpm nx run …:serve`. The web and Electron E2E configs +(`apps/web-e2e/playwright.config.ts`, +`apps/electron-backend-e2e/playwright.config.ts`) start both the Stalker and +the Xtream mock. The journeys and Xtream benchmark configs start only the +Xtream mock, on their own loopback ports. The packaged and other performance +configs start no mock. +Playwright stops a `webServer` by sending `SIGKILL` to the process group it +spawned (`taskkill /T /F` on Windows), and Nx `run-commands` starts its command +in a detached process group of its own. The kill therefore reached only +`pnpm`/`nx`, and the `tsx` server was reparented and kept the port, which made +the next run fail with "…/health is already used" or, with +`reuseExistingServer`, silently reuse a stale server. `node --import tsx` keeps +the server a single process in Playwright's group. `TSX_TSCONFIG_PATH` stands in +for the serve target's `--tsconfig` flag and is required for the `@iptvnator/*` +path aliases. `project-config.spec.ts` pins which configs start which mock, +and fails if any of them launches a mock through Nx or a new config starts one +without being listed there. The `serve` targets remain the entry point +for starting a mock by hand. + ### Request Interception The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these: