test(e2e): adapt to v0.22 dialog + theme picker redesigns

The redesign rollup in this branch replaced the Add Playlist dialog's
2-level type × subtype tabs with a single flat 5-card radiogroup, and
replaced the Settings theme list with a compact segmented control whose
options are just "Light"/"Dark"/"System" (no "theme" suffix). E2E tests
that pinned to the old roles/labels failed in CI:

  - basic / xtream / stalker / self-hosted: getByRole('tab', ...) on
    "Add via file upload" / "Xtream" / "Stalker" — there are no tabs in
    the new dialog. Switched to getByRole('radio', { name: /M3U file/i })
    and friends, matched against the new radio labels.
  - settings: getByRole('radio', { name: 'System theme' }) — the new
    picker uses just "System". Scoped to the [data-test-id="select-theme"]
    radiogroup so it doesn't collide with the identically-labelled
    cover-size options below.

The Electron `clickDialogSegmentedOption` helper grew a radio-role lookup
as its primary path and keeps the old tab/button/legacy-selector
fallbacks so a future redesign won't break every fixture again. The
"M3U" parent category becomes a no-op (the new picker has no standalone
M3U tile — callers always specialise to M3U URL/file/text immediately
after, which is what we want anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 22:07:26 +02:00
1 parent 8091dec074
commit a8253c5801
6 files changed
+86 -52

No files matched your search

@@ -273,7 +273,7 @@ export async function importM3uPlaylistFromNativeDialog(
await clickDialogCategoryOption(dialog, /^m3u$/i);
await clickDialogSubtypeOption(
dialog,
/add\s+via\s+file\s+upload/i,
/m3u\s*file|add\s+via\s+file\s+upload/i,
'mat-button-toggle[value="file"]'
);
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
@@ -364,7 +364,7 @@ export async function addXtreamPortal(
const dialog = await getActiveDialog(page);
await clickDialogCategoryOption(
dialog,
/^xtream$/i,
/xtream(\s+credentials)?/i,
'mat-button-toggle[value="xtream"]'
);
@@ -409,57 +409,46 @@ async function setInputValue(input: Locator, value: string): Promise<void> {
await expect(input).toHaveValue(value);
}
async function clickDialogCategoryOption(
/**
* Pick a source method on the Add Playlist dialog. Since v0.22 the dialog
* exposes a single flat 5-card radiogroup ("M3U URL", "M3U file", "Xtream
* credentials", "Stalker portal", "Raw m3u text") instead of the legacy
* 2-level category × subtype tabs/toggles. The helper still falls through
* to the old tab/button/legacy-selector lookups so we don't have to rewrite
* every call-site on each redesign — but the radio-based picker is the
* primary path now.
*/
async function clickDialogMethodOption(
dialog: Locator,
label: RegExp,
legacySelector?: string
): Promise<void> {
await clickDialogSegmentedOption(
dialog,
'Playlist category',
label,
legacySelector
);
}
async function clickDialogSubtypeOption(
dialog: Locator,
label: RegExp,
legacySelector?: string
): Promise<void> {
await clickDialogSegmentedOption(
dialog,
'M3U source',
label,
legacySelector
);
}
async function clickDialogSegmentedOption(
dialog: Locator,
tablistLabel: string,
label: RegExp,
legacySelector?: string
): Promise<void> {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
const optionByRadio = dialog
.getByRole('radio', { name: label })
.first();
if ((await optionByRadio.count()) > 0) {
await optionByRadio.click();
return;
}
if ((await tablist.count()) > 0) {
const optionByTabRole = tablist
.getByRole('tab', { name: label })
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
.first();
if ((await optionByTabRole.count()) > 0) {
await optionByTabRole.click();
return;
if ((await tablist.count()) > 0) {
const optionByTabRole = tablist
.getByRole('tab', { name: label })
.first();
if ((await optionByTabRole.count()) > 0) {
await optionByTabRole.click();
return;
}
}
}
const optionByGlobalTabRole = dialog
.getByRole('tab', { name: label })
.first();
if ((await optionByGlobalTabRole.count()) > 0) {
await optionByGlobalTabRole.click();
return;
@@ -468,7 +457,6 @@ async function clickDialogSegmentedOption(
const optionByButtonRole = dialog
.getByRole('button', { name: label })
.first();
if ((await optionByButtonRole.count()) > 0) {
await optionByButtonRole.click();
return;
@@ -476,13 +464,41 @@ async function clickDialogSegmentedOption(
if (!legacySelector) {
throw new Error(
`Could not find dialog option matching ${label} in "${tablistLabel}".`
`Could not find dialog option matching ${label}.`
);
}
await dialog.locator(legacySelector).click();
}
// Backwards-compat shims for the legacy two-step flow. Both helpers now route
// through `clickDialogMethodOption` and use the patterns of the new flat
// picker. `clickDialogCategoryOption` is a no-op for "M3U" since the new
// picker has no parent "M3U" tile — callers immediately follow up with a
// `clickDialogSubtypeOption` which picks the concrete M3U URL/file/text card.
async function clickDialogCategoryOption(
dialog: Locator,
label: RegExp,
legacySelector?: string
): Promise<void> {
// The legacy "M3U" category is now implicit — the new picker has no
// standalone "M3U" radio; callers always immediately specialise via
// `clickDialogSubtypeOption` below. Skip the click to avoid matching
// unrelated radios (e.g. "M3U URL" when caller wanted "M3U file").
if (/^\^?m3u\$?$/i.test(label.source)) {
return;
}
await clickDialogMethodOption(dialog, label, legacySelector);
}
async function clickDialogSubtypeOption(
dialog: Locator,
label: RegExp,
legacySelector?: string
): Promise<void> {
await clickDialogMethodOption(dialog, label, legacySelector);
}
export async function addStalkerPortal(
page: Page,
options: {
@@ -501,7 +517,7 @@ export async function addStalkerPortal(
const dialog = await getActiveDialog(page);
await clickDialogCategoryOption(
dialog,
/^stalker$/i,
/stalker(\s+portal)?/i,
'mat-button-toggle[value="stalker"]'
);
@@ -576,7 +592,7 @@ export async function importM3uPlaylistFromUrl(
await clickDialogCategoryOption(dialog, /^m3u$/i);
await clickDialogSubtypeOption(
dialog,
/add\s+via\s+url/i,
/m3u\s*url|add\s+via\s+url/i,
'mat-button-toggle[value="url"]'
);
+2 -1
View File
@@ -13,7 +13,8 @@ test('basic test', async ({ page }) => {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: 'Add via file upload' }).click();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /M3U file/i }).click();
await page.setInputFiles('input[type="file"]', fixturePath);
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
+6 -2
View File
@@ -36,7 +36,10 @@ async function addXtreamPortal(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.locator('#title').fill('Self-hosted Xtream');
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_SERVER);
@@ -52,7 +55,8 @@ async function addStalkerPortal(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), 'Self-hosted Stalker');
await setInputValue(dialog.locator('input#portalUrl'), STALKER_PORTAL_URL);
+12 -3
View File
@@ -49,17 +49,26 @@ test.describe('Settings', () => {
test('Change app theme', async ({ page }) => {
await openSettings(page);
// v0.22 compact theme picker exposes the segmented control as a
// radiogroup with options labelled just "Light"/"Dark"/"System".
// Scope to the theme radiogroup so we don't collide with the
// identically-labelled cover-size options below.
const themeGroup = page.locator(
'[data-test-id="select-theme"][role="radiogroup"]'
);
await expect(
page.getByRole('radio', { name: 'System theme' })
themeGroup.getByRole('radio', { name: 'System', exact: true })
).toHaveAttribute('aria-checked', 'true');
await page.getByRole('radio', { name: 'Dark theme' }).click();
await themeGroup
.getByRole('radio', { name: 'Dark', exact: true })
.click();
await saveSettings(page);
await page.reload();
await openSettings(page);
await expect(
page.getByRole('radio', { name: 'Dark theme' })
themeGroup.getByRole('radio', { name: 'Dark', exact: true })
).toHaveAttribute('aria-checked', 'true');
});
+2 -1
View File
@@ -126,7 +126,8 @@ async function addStalkerPortal(
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
+4 -1
View File
@@ -78,7 +78,10 @@ async function addXtreamPortal(
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(MOCK_SERVER);