fix(e2e): stop mock servers from outliving Playwright runs (#1710)

* 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/<mock>/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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 22:57:46 +02:00
1 parent 807b5ea259
commit 6b855eb73b
7 files changed
+139 -15

No files matched your search

+14 -2
View File
@@ -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,
@@ -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`,
@@ -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',
+13 -2
View File
@@ -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,
@@ -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<string, string[]> = {
'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);
}
);
});
+15 -3
View File
@@ -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
+26 -2
View File
@@ -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: