From 8b6fcf35602101b2e37e29a4bff8f7844ee2318c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:19:51 +0200 Subject: [PATCH] fix(e2e): let web-e2e:e2e run outside CI (#1779) --- .github/workflows/ci.yml | 6 + apps/web-e2e/project.json | 4 +- docs/architecture/pwa-self-hosted.md | 6 +- docs/architecture/validation-map.md | 7 + docs/architecture/xtream-mock-server.md | 21 +++ nx.json | 3 +- package.json | 3 + tools/nx/check-e2e-task-graphs.mjs | 172 ++++++++++++++++++++++++ tools/nx/check-e2e-task-graphs.test.mjs | 99 ++++++++++++++ 9 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 tools/nx/check-e2e-task-graphs.mjs create mode 100644 tools/nx/check-e2e-task-graphs.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60e1a14a8..f5944354e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,6 +375,12 @@ jobs: - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate + # Nx rejects a non-parallel task with continuous dependencies, and + # the Playwright plugin infers serve dependencies only when CI is + # unset, so this checks both the local and the CI inference. + - name: Validate Playwright task graphs + run: pnpm run e2e:task-graphs:validate + - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci diff --git a/apps/web-e2e/project.json b/apps/web-e2e/project.json index 367cf8fb8..fa2b449b3 100644 --- a/apps/web-e2e/project.json +++ b/apps/web-e2e/project.json @@ -11,7 +11,9 @@ ], "// targets": "to see all targets run: nx show project web-e2e --web", "targets": { - "e2e": {}, + "e2e": { + "dependsOn": [] + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index c09f338a4..14da8401c 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -87,8 +87,10 @@ launches the backend through Nx or its `env` drifts from the `serve` target. `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). +stops it. Playwright launches that command itself even outside CI: the +web-e2e targets carry no Nx `serve` dependency, because Nx refuses a +non-parallel task with a continuous dependency. 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 diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 46da72dd8..4d4ed2bf2 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -197,6 +197,13 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the Use atomized E2E targets when available, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`. +After changing a Playwright config's `webServer` list, `nx.json` target +defaults or an E2E project's `dependsOn`, run +`pnpm run e2e:task-graphs:validate`. It builds every Playwright target's task +graph with and without `CI` and fails on the graphs Nx refuses to run; CI runs +it in the `unit-and-typecheck` job. See +[Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration). + Inside `expect.poll`, read a changing list in one DOM snapshot (`allTextContents()` or `evaluateAll()`, as in `apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts`), not by diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index a82e1618f..a5b068544 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -586,6 +586,27 @@ continuous `serve` task. The same spec guards the `e2e*` entries of `nx.json` `targetDefaults` and every target in the `apps/*-e2e` `project.json` files. The web-e2e `web-backend` entry uses the same launch form; see [PWA web backend](pwa-self-hosted.md#web-backend). +Playwright, not Nx, starts every E2E server, including the web dev server. +`@nx/playwright/plugin` infers a continuous `serve` dependency from a +`pnpm nx run :serve` webServer only while `reuseExistingServer` is +true, which the configs set only when `CI` is unset. It also sets +`parallelism: false` on a target when a webServer has an `env` or a plain +`node` command, because no Nx task covers that server. Nx refuses to run a +non-parallel task that depends on a continuous task, so the inferred +`web-e2e:e2e` failed locally with "do not support parallelism but depend on +continuous tasks" and passed only in CI. Therefore: + +- `apps/web-e2e/project.json` sets `e2e.dependsOn` to `[]`, and the filtered + `e2e-ci--src/*.e2e.ts` target default in `nx.json` does the same for the + per-file web targets. The per-file Electron targets depend only on + `electron-backend:build-e2e`. +- The plugin runs with `waitForWebServer: false`, because no target consumes + its `e2e--wait-for-webserver` readiness task. Playwright's own URL probe + covers readiness. +- `pnpm run e2e:task-graphs:validate` (`tools/nx/check-e2e-task-graphs.mjs`) + builds each Playwright target's task graph with Nx's own validation, once + with `CI` unset and once with it set. + ### Request Interception The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these: diff --git a/nx.json b/nx.json index 2f36ffe99..85d4df44a 100644 --- a/nx.json +++ b/nx.json @@ -88,7 +88,8 @@ { "plugin": "@nx/playwright/plugin", "options": { - "targetName": "e2e" + "targetName": "e2e", + "waitForWebServer": false } }, { diff --git a/package.json b/package.json index 7e944d655..251cba78c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", + "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", + "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", + "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs tools/coverage/unit-coverage-scope.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", diff --git a/tools/nx/check-e2e-task-graphs.mjs b/tools/nx/check-e2e-task-graphs.mjs new file mode 100644 index 000000000..f88de4284 --- /dev/null +++ b/tools/nx/check-e2e-task-graphs.mjs @@ -0,0 +1,172 @@ +import { execFileSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Nx refuses to run a task with `parallelism: false` that depends on a + * continuous task. `@nx/playwright/plugin` sets `parallelism: false` whenever + * a config has a webServer no inferred Nx task covers (our mocks and the web + * backend launch as plain `node` processes), and it infers continuous serve + * dependencies only from servers with `reuseExistingServer`, which the + * configs enable only when `CI` is unset. A target graph can therefore pass + * in CI and still fail locally, so every Playwright target is validated with + * Nx's own task-graph checks under both environments. + */ +const RESULT_PREFIX = 'e2e-task-graphs-result:'; + +export const MODES = { + local: { CI: undefined }, + ci: { CI: 'true' }, +}; + +/** + * The Playwright projects whose targets must be inferred. The check only sees + * targets the plugin tags as Playwright ones, so a target that drops out of + * the inference would otherwise pass unchecked. + */ +export const E2E_PROJECTS = { + 'web-e2e': 'apps/web-e2e', + 'electron-backend-e2e': 'apps/electron-backend-e2e', +}; + +/** `e2e` plus one atomized target per spec, as the plugin names them. */ +export function expectedPlaywrightTargets(specFilesByProject) { + return Object.entries(specFilesByProject).flatMap(([project, specs]) => [ + `${project}:e2e`, + ...specs.map((spec) => `${project}:e2e-ci--${spec}`), + ]); +} + +export function findMissingTargets(projectGraph, expectedTargets) { + const inferred = new Set( + playwrightTargets(projectGraph).map( + ({ project, target }) => `${project}:${target}` + ) + ); + return expectedTargets.filter((task) => !inferred.has(task)); +} + +function readSpecFiles(workspaceRoot) { + return Object.fromEntries( + Object.entries(E2E_PROJECTS).map(([project, root]) => [ + project, + readdirSync(path.join(workspaceRoot, root, 'src'), { + recursive: true, + }) + .filter((file) => file.endsWith('.e2e.ts')) + .map((file) => `src/${file.split(path.sep).join('/')}`) + .sort(), + ]) + ); +} + +export function playwrightTargets(projectGraph) { + return Object.values(projectGraph.nodes) + .flatMap((node) => + Object.entries(node.data.targets ?? {}) + .filter(([, target]) => + target.metadata?.technologies?.includes('playwright') + ) + .map(([target]) => ({ project: node.name, target })) + ) + .sort((a, b) => + `${a.project}:${a.target}`.localeCompare(`${b.project}:${b.target}`) + ); +} + +export async function findInvalidTaskGraphs(projectGraph) { + const { createTaskGraph } = + await import('nx/src/tasks-runner/create-task-graph.js'); + const { assertTaskGraphDoesNotContainInvalidTargets } = + await import('nx/src/tasks-runner/task-graph-utils.js'); + + return playwrightTargets(projectGraph).flatMap(({ project, target }) => { + try { + assertTaskGraphDoesNotContainInvalidTargets( + createTaskGraph( + projectGraph, + {}, + [project], + [target], + undefined, + {} + ) + ); + return []; + } catch (error) { + return [{ task: `${project}:${target}`, message: error.message }]; + } + }); +} + +function childEnv(mode) { + const env = { ...process.env, NX_DAEMON: 'false' }; + for (const [name, value] of Object.entries(MODES[mode])) { + if (value === undefined) delete env[name]; + else env[name] = value; + } + return env; +} + +async function checkCurrentEnvironment() { + const { createProjectGraphAsync, workspaceRoot } = + await import('@nx/devkit'); + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const failures = await findInvalidTaskGraphs(projectGraph); + const checked = playwrightTargets(projectGraph).length; + const missing = findMissingTargets( + projectGraph, + expectedPlaywrightTargets(readSpecFiles(workspaceRoot)) + ); + console.log( + `${RESULT_PREFIX}${JSON.stringify({ checked, failures, missing })}` + ); +} + +function main() { + let failed = false; + for (const mode of Object.keys(MODES)) { + const output = execFileSync( + process.execPath, + [fileURLToPath(import.meta.url), '--current-environment'], + { + env: childEnv(mode), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + } + ); + const result = output + .split('\n') + .find((line) => line.startsWith(RESULT_PREFIX)); + const { checked, failures, missing } = JSON.parse( + result.slice(RESULT_PREFIX.length) + ); + for (const task of missing) { + failed = true; + console.error( + `[${mode}] ${task} was not inferred as a Playwright target` + ); + } + for (const { task, message } of failures) { + failed = true; + console.error( + `[${mode}] ${task}\n ${message.replace(/\n/g, '\n ')}` + ); + } + if (failures.length === 0 && missing.length === 0) { + console.log( + `[${mode}] ${checked} Playwright task graphs are valid` + ); + } + } + process.exitCode = failed ? 1 : 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--current-environment')) { + await checkCurrentEnvironment(); + } else { + main(); + } +} diff --git a/tools/nx/check-e2e-task-graphs.test.mjs b/tools/nx/check-e2e-task-graphs.test.mjs new file mode 100644 index 000000000..d31743552 --- /dev/null +++ b/tools/nx/check-e2e-task-graphs.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + expectedPlaywrightTargets, + findInvalidTaskGraphs, + findMissingTargets, + MODES, + playwrightTargets, +} from './check-e2e-task-graphs.mjs'; + +const playwright = { technologies: ['playwright'] }; + +/** + * Mirrors web-e2e as the Playwright plugin infers it without CI: an uncovered + * mock webServer makes `e2e` non-parallel while `pnpm nx run web:serve` adds a + * continuous dependency on the dev server. + */ +function graphWithE2eTarget(e2eTarget) { + const node = (name, targets) => ({ + name, + type: 'app', + data: { root: `apps/${name}`, targets }, + }); + return { + nodes: { + web: node('web', { + serve: { + executor: '@angular/build:dev-server', + continuous: true, + }, + }), + 'web-e2e': node('web-e2e', { + e2e: { + executor: 'nx:run-commands', + options: { command: 'playwright test' }, + metadata: playwright, + ...e2eTarget, + }, + lint: { + executor: '@nx/eslint:lint', + parallelism: false, + dependsOn: [{ projects: ['web'], target: 'serve' }], + }, + }), + }, + dependencies: { web: [], 'web-e2e': [] }, + }; +} + +test('checks only Playwright-inferred targets', () => { + const graph = graphWithE2eTarget({ parallelism: false }); + + assert.deepEqual(playwrightTargets(graph), [ + { project: 'web-e2e', target: 'e2e' }, + ]); +}); + +test('reports a non-parallel e2e target that depends on a continuous serve', async () => { + const graph = graphWithE2eTarget({ + parallelism: false, + dependsOn: [{ projects: ['web'], target: 'serve' }], + }); + + const failures = await findInvalidTaskGraphs(graph); + + assert.equal(failures.length, 1); + assert.equal(failures[0].task, 'web-e2e:e2e'); + assert.match(failures[0].message, /web-e2e:e2e -> web:serve/); +}); + +test('accepts a non-parallel e2e target whose servers Playwright starts', async () => { + const graph = graphWithE2eTarget({ parallelism: false, dependsOn: [] }); + + assert.deepEqual(await findInvalidTaskGraphs(graph), []); +}); + +test('validates both the local and the CI plugin inference', () => { + assert.deepEqual(MODES, { local: { CI: undefined }, ci: { CI: 'true' } }); +}); + +test('expects the e2e target and one atomized target per spec', () => { + assert.deepEqual( + expectedPlaywrightTargets({ 'web-e2e': ['src/basic.e2e.ts'] }), + ['web-e2e:e2e', 'web-e2e:e2e-ci--src/basic.e2e.ts'] + ); +}); + +test('reports expected targets the plugin no longer infers', () => { + const graph = graphWithE2eTarget({ parallelism: false, dependsOn: [] }); + + assert.deepEqual( + findMissingTargets(graph, [ + 'web-e2e:e2e', + 'web-e2e:e2e-ci--src/basic.e2e.ts', + ]), + ['web-e2e:e2e-ci--src/basic.e2e.ts'] + ); +});