fix(e2e): let web-e2e:e2e run outside CI (#1779)

This commit is contained in:
4gray authored and GitHub committed 2026-10-01 11:19:51 +02:00
1 parent 6e229f85b6
commit 8b6fcf3560
9 files changed
+317 -4

No files matched your search

+6
View File
@@ -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
+3 -1
View File
@@ -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"
}
+4 -2
View File
@@ -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
+7
View File
@@ -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
+21
View File
@@ -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 <project>: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:
+2 -1
View File
@@ -88,7 +88,8 @@
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
"targetName": "e2e",
"waitForWebServer": false
}
},
{
+3
View File
@@ -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",
+172
View File
@@ -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();
}
}
+99
View File
@@ -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']
);
});