From 4e17fbed4e81b5471d2656031e13e3d6a3190ece Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:34:15 +0200 Subject: [PATCH] fix(e2e): stop the web backend from outliving Playwright runs (#1747) --- .../src/app/project-config.spec.ts | 63 +++++++++++++++++++ apps/web-e2e/playwright.config.ts | 17 ++++- apps/web-e2e/src/self-hosted.e2e.ts | 3 +- .../src/app/project-config.spec.ts | 15 +++-- docs/architecture/pwa-self-hosted.md | 17 +++++ docs/architecture/xtream-mock-server.md | 3 +- 6 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 apps/web-backend/src/app/project-config.spec.ts diff --git a/apps/web-backend/src/app/project-config.spec.ts b/apps/web-backend/src/app/project-config.spec.ts new file mode 100644 index 000000000..473153b61 --- /dev/null +++ b/apps/web-backend/src/app/project-config.spec.ts @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('Playwright web-backend launch', () => { + const appsDirectory = join(process.cwd(), 'apps'); + const launchCommand = 'node --import tsx apps/web-backend/src/main.ts'; + const configsStartingBackend = ['apps/web-e2e/playwright.config.ts']; + const readSource = (path: string) => + readFileSync(join(process.cwd(), path), 'utf8'); + const serveEnv = ( + JSON.parse(readSource('apps/web-backend/project.json')) as { + targets: { serve: { options: { env: Record } } }; + } + ).targets.serve.options.env; + + it('knows every Playwright config that starts the web backend', () => { + const configs = 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('web-backend') + ); + + expect(configs.sort()).toEqual(configsStartingBackend); + }); + + // Nx starts a run-commands child in its own detached process group, and + // Playwright stops a webServer with a process-group SIGKILL, so a backend + // launched through Nx outlived the run and kept port 3333. + it.each(configsStartingBackend)( + 'starts the backend in %s as a single node process', + (configPath) => { + const source = readSource(configPath); + const start = source.indexOf(`'${launchCommand}'`); + + expect(source).not.toMatch(/\bnx\b[^'"`\n]*web-backend/); + expect(start).toBeGreaterThan(-1); + expect( + source.indexOf(launchCommand, start + launchCommand.length) + ).toBe(-1); + + // WEB_BACKEND_PORT relocates the run; its default is the target's. + const { PORT: port, ...fixedEnv } = serveEnv; + const entry = source.slice(start, source.indexOf('url:', start)); + for (const [name, value] of Object.entries(fixedEnv)) { + expect(entry).toContain(`${name}: '${value}'`); + } + expect(source).toContain( + `process.env['WEB_BACKEND_PORT'] ?? '${port}'` + ); + expect(entry).toContain('PORT: webBackendPort,'); + expect(source).toContain( + 'url: `http://localhost:${webBackendPort}/health`' + ); + } + ); +}); diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 2531cd588..8f64d1d4d 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -15,6 +15,7 @@ const webServerCommand = ? `pnpm nx run web:serve-static --port=${staticPwaPort}` : 'pnpm nx run web:serve'; const reuseExistingWebServer = isStaticPwaE2E ? false : !process.env['CI']; +const webBackendPort = process.env['WEB_BACKEND_PORT'] ?? '3333'; /** * Read environment variables from file. @@ -57,7 +58,8 @@ export default defineConfig({ * both mock servers honour them as a fallback for PORT (their serve * targets no longer pin PORT, so an explicit shell value reaches the * process). That is what lets two worktrees run E2E side by side when one - * already holds 3210/3211. + * already holds 3210/3211. WEB_BACKEND_PORT does the same for the web + * backend and self-hosted.e2e.ts, which is the only spec that calls it. */ webServer: [ { @@ -89,9 +91,18 @@ export default defineConfig({ reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, + /* Same single-process launch for the backend; `env` mirrors the + * `web-backend:serve` target, which stays the manual entry point. */ { - command: 'pnpm nx run web-backend:serve', - url: 'http://localhost:3333/health', + command: 'node --import tsx apps/web-backend/src/main.ts', + env: { + PORT: webBackendPort, + CLIENT_URL: 'http://localhost:4200', + BACKEND_URL: '/api', + IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: '1', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + url: `http://localhost:${webBackendPort}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index e2f22da86..574b11b0b 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -4,7 +4,8 @@ import { openSourceEditor, sourceRowByTitle } from './sources-pwa.helpers'; import { postWithRetry, setInputValue } from './e2e-helpers'; import { expect, test } from './fixtures'; -const WEB_BACKEND_URL = 'http://localhost:3333'; +const WEB_BACKEND_PORT = process.env['WEB_BACKEND_PORT'] ?? '3333'; +const WEB_BACKEND_URL = `http://localhost:${WEB_BACKEND_PORT}`; const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211'; const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; 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 be5c04a67..9c1895228 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -70,17 +70,20 @@ describe('Playwright mock-server launch', () => { const source = readSource(configPath); expect(source).not.toMatch(/\bnx\b[^'"`\n]*mock-server/); + // Other single-process webServers (web-e2e's web-backend) also set + // TSX_TSCONFIG_PATH, so check it inside each mock's own entry. for (const mock of mocks) { - expect(source).toContain( - `'node --import tsx apps/${mock}-mock-server/src/main.ts'` - ); + const launch = `'node --import tsx apps/${mock}-mock-server/src/main.ts'`; + const start = source.indexOf(launch); + + expect(start).toBeGreaterThan(-1); + expect( + source.slice(start, source.indexOf('url:', start)) + ).toContain("TSX_TSCONFIG_PATH: 'tsconfig.base.json'"); } 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/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 90bd07156..c09f338a4 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -73,6 +73,23 @@ The current self-hosted PWA uses these `apps/web-backend` routes: - `GET /xtream?targetId=&username=&password=

&action=` - `GET /stalker?targetId=&macAddress=&action=` +`pnpm nx run web-backend:serve` starts the backend by hand on port 3333 with +`TSX_TSCONFIG_PATH=tsconfig.base.json`, which `tsx` needs for the +`@iptvnator/*` path aliases. `apps/web-e2e/playwright.config.ts` does not use +that target: it launches `node --import tsx apps/web-backend/src/main.ts` with +an `env` that mirrors the target's. Nx `run-commands` starts its command in a +detached process group, and Playwright stops a `webServer` with a +process-group `SIGKILL` (`taskkill /T /F` on Windows). The kill therefore +reached only `pnpm`/`nx`, and the orphaned server kept port 3333 after the run. +`apps/web-backend/src/app/project-config.spec.ts` fails if a Playwright config +launches the backend through Nx or its `env` drifts from the `serve` target. +`WEB_BACKEND_PORT` (default 3333) relocates the E2E backend and the +`self-hosted.e2e.ts` URLs, like `MOCK_PORT` does for the mocks. Use it when +another worktree holds 3333. The `web:serve` entry can stay on Nx: +`@angular/build:dev-server` runs inside the Nx process, so the group kill +stops it. The mock servers follow the same +rule; see [Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration). + The PWA continues to use `PwaService`; only the backend base URL is resolved at runtime. Electron routes remain owned by the Electron backend and preload bridge. diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index ae5f36d17..571c2f461 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -579,7 +579,8 @@ 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. +for starting a mock by hand. The web-e2e `web-backend` entry uses the same +launch form; see [PWA web backend](pwa-self-hosted.md#web-backend). ### Request Interception