From 8848b088e9b0d488688c3b41a31776d0c3f7ce87 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 08:30:28 +0200 Subject: [PATCH] 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 --- .../src/category-management.e2e.ts | 37 ++++----- .../src/app/project-config.spec.ts | 78 +++++++++++-------- docs/architecture/xtream-mock-server.md | 14 +++- 3 files changed, 70 insertions(+), 59 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index af7ae8137..a97d10bc3 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 { - 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 { 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 c60fbd511..be5c04a67 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -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 = { + '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); } ); }); diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 7bdaa6c05..ae5f36d17 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -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