diff --git a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts index d0e6df701..b02d42fe3 100644 --- a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts +++ b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts @@ -17,6 +17,7 @@ import { test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { readVisibleSidebarCategories } from './sidebar-categories.e2e-support'; /** * Full backup round-trip through the real UI, DB worker and IPC stack: @@ -237,32 +238,18 @@ async function pickVisibleCategoryWithContent( await expect .poll( async () => { - const categories = page.locator( - 'app-workspace-context-panel .category-item:visible' + const category = ( + await readVisibleSidebarCategories(page) + ).find( + (candidate) => + candidate.id && + candidate.name && + candidate.itemCount > 0 ); - const count = await categories.count(); - for (let index = 0; index < count; index += 1) { - const category = categories.nth(index); - const id = - ( - await category.getAttribute('data-category-id') - )?.trim() ?? ''; - const name = - ( - await category - .locator('.nav-item-label') - .textContent() - )?.trim() ?? ''; - const countText = - ( - await category.locator('.item-count').textContent() - )?.trim() ?? ''; - - if (id && name && (Number.parseInt(countText, 10) || 0) > 0) { - picked = { id, name }; - return true; - } + if (category) { + picked = { id: category.id, name: category.name }; + return true; } picked = null; diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index af7ae8137..eea9a1df4 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -16,6 +16,10 @@ import { waitForSourceRowIdle, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { + readVisibleSidebarCategories, + readVisibleSidebarCategoryNames, +} from './sidebar-categories.e2e-support'; import { applyTheme } from './theme-contrast'; test.describe('Electron Xtream Category Management', () => { @@ -591,31 +595,6 @@ async function expectVisibleSidebarCategoryNames( } } -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; -} - function stringArraysEqual(left: string[], right: string[]): boolean { return ( left.length === right.length && @@ -634,35 +613,12 @@ async function pickSidebarCategory( await expect .poll(async () => { - const categories = page.locator( - 'app-workspace-context-panel .category-item:visible' + const candidates = ( + await readVisibleSidebarCategories(page) + ).filter( + (candidate) => + candidate.id && candidate.name && candidate.itemCount > 0 ); - const count = await categories.count(); - const candidates: Array<{ - id: string; - itemCount: number; - name: string; - }> = []; - - for (let index = 0; index < count; index += 1) { - const category = categories.nth(index); - const id = - (await category.getAttribute('data-category-id'))?.trim() ?? - ''; - const name = - ( - await category.locator('.nav-item-label').textContent() - )?.trim() ?? ''; - const countText = - ( - await category.locator('.item-count').textContent() - )?.trim() ?? ''; - const itemCount = Number.parseInt(countText, 10) || 0; - - if (id && name && itemCount > 0) { - candidates.push({ id, itemCount, name }); - } - } if (candidates.length === 0) { preferredCandidate = null; diff --git a/apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts b/apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts new file mode 100644 index 000000000..9f4728465 --- /dev/null +++ b/apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts @@ -0,0 +1,43 @@ +import type { Page } from '@playwright/test'; + +export interface SidebarCategorySnapshot { + id: string; + itemCount: number; + name: string; +} + +const VISIBLE_SIDEBAR_CATEGORIES = + 'app-workspace-context-panel .category-item:visible'; + +/** + * Reads the visible sidebar categories in one DOM snapshot. + * + * Do not loop over `count()` with per-row `nth(index)` reads here: those + * locator calls auto-wait, so a row the sidebar removes between the count and + * the read (for example right after hiding categories) blocks the call until + * the surrounding `expect.poll` times out instead of letting the poll retry. + */ +export async function readVisibleSidebarCategories( + page: Page +): Promise { + return page.locator(VISIBLE_SIDEBAR_CATEGORIES).evaluateAll((rows) => + rows.map((row) => ({ + id: row.getAttribute('data-category-id')?.trim() ?? '', + itemCount: + Number.parseInt( + row.querySelector('.item-count')?.textContent?.trim() ?? '', + 10 + ) || 0, + name: + row.querySelector('.nav-item-label')?.textContent?.trim() ?? '', + })) + ); +} + +export async function readVisibleSidebarCategoryNames( + page: Page +): Promise { + return (await readVisibleSidebarCategories(page)) + .map((category) => category.name) + .filter(Boolean); +} diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 0ff903425..ff50576c9 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -181,6 +181,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`. +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 +looping over `count()` with per-row `nth(index)` reads. Those reads auto-wait, +so a row removed mid-loop hangs the predicate until the poll times out instead +of letting it retry. + Playwright coverage is measured semantically by tags and critical journeys, not by a source-line percentage. E2E reports should use tags such as `@critical`, `@electron`, `@web`, `@xtream`, `@stalker`, `@m3u`, `@search`, `@epg`,