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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 08:30:28 +02:00
1 parent 4ad83d4781
commit 8848b088e9
3 files changed
+70 -59

No files matched your search

@@ -591,29 +591,22 @@ async function expectVisibleSidebarCategoryNames(
}
}
// One DOM snapshot: reading items one by one raced list updates, and
// `textContent()` on a removed item auto-waited through the whole poll.
async function readVisibleSidebarCategoryNames(page: Page): Promise<string[]> {
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const actualNames: string[] = [];
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
if (!(await category.isVisible())) {
continue;
}
const categoryName =
(await category.locator('.nav-item-label').textContent())?.trim() ??
'';
if (categoryName) {
actualNames.push(categoryName);
}
}
return actualNames;
return page
.locator('app-workspace-context-panel .category-item')
.filter({ visible: true })
.evaluateAll((categories) =>
categories
.map(
(category) =>
category
.querySelector('.nav-item-label')
?.textContent?.trim() ?? ''
)
.filter((name) => name.length > 0)
);
}
function stringArraysEqual(left: string[], right: string[]): boolean {
@@ -29,46 +29,58 @@ describe('Xtream mock Nx serve environment', () => {
describe('Playwright mock-server launch', () => {
const appsDirectory = join(process.cwd(), 'apps');
const playwrightConfigs = 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}`)
);
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('finds every config that starts a mock server', () => {
expect(playwrightConfigs).toEqual(
expect.arrayContaining([
'apps/electron-backend-e2e/playwright.config.ts',
'apps/electron-backend-e2e/playwright.journeys.config.ts',
'apps/electron-backend-e2e/playwright.xtream-performance.config.ts',
'apps/web-e2e/playwright.config.ts',
])
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 run …:serve` outlived the run and kept its port.
it.each(playwrightConfigs)(
'starts mock servers as a single node process in %s',
(configPath) => {
const source = readFileSync(
join(process.cwd(), configPath),
'utf8'
);
// 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(/nx run [\w-]+-mock-server:serve/);
const launches =
source.match(/node --import tsx apps\/[\w-]+-mock-server\//g) ??
[];
const tsconfigs =
source.match(/TSX_TSCONFIG_PATH: 'tsconfig\.base\.json'/g) ??
[];
expect(tsconfigs).toHaveLength(launches.length);
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);
}
);
});
+10 -4
View File
@@ -561,8 +561,13 @@ The mock server is listed as a third `webServer` entry:
}
```
Every Playwright config (web, Electron, Xtream benchmark, journeys) starts the
Xtream and Stalker mocks this way, never through `pnpm nx run …:serve`.
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
@@ -571,8 +576,9 @@ 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` guards every `playwright*.config.ts`
against regressing to the Nx launch. The `serve` targets remain the entry point
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