test(e2e): read sidebar categories in one snapshot inside polls (#1728)

The "allows restoring live categories after every category is hidden"
Electron test failed intermittently on shard 1 across unrelated PRs and
master. The app hid every category correctly (the failure screenshot
shows "No categories available"), but readVisibleSidebarCategoryNames
looped over count() with per-row nth(index) reads. When the sidebar
removed a row between isVisible() and textContent(), textContent()
auto-waited for the missing element, so the expect.poll predicate never
returned and the poll timed out with "waiting on the predicate" instead
of retrying.

Read the visible rows with a single evaluateAll() snapshot in a shared
sidebar-categories.e2e-support.ts helper, use it for the category
picker in category-management and backup-roundtrip too, and record the
rule in the validation map.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 20:55:58 +02:00
1 parent 650da4a1d3
commit 31c2cb3bbc
4 files changed
+70 -77

No files matched your search

@@ -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;
@@ -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<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;
}
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;
@@ -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<SidebarCategorySnapshot[]> {
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<string[]> {
return (await readVisibleSidebarCategories(page))
.map((category) => category.name)
.filter(Boolean);
}
+7
View File
@@ -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`,