mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
* fix(ui): destructive confirmations, verb labels and provider icons
Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".
The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.
Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.
The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): one provider icon per playlist row, imperative Korean remove label
A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.
HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon
Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): let the playlist row's cancel action render in the error color
The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.
The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): give the dialog service spec the now-required confirm labels
ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
683 lines
26 KiB
TypeScript
683 lines
26 KiB
TypeScript
import { Locator, Page } from '@playwright/test';
|
|
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
|
|
import { ok as assert } from 'node:assert';
|
|
import {
|
|
addXtreamPortal,
|
|
closeElectronApp,
|
|
expect,
|
|
launchElectronApp,
|
|
openSources,
|
|
openWorkspaceSection,
|
|
refreshSource,
|
|
resetMockServers,
|
|
restartElectronApp,
|
|
sourceRowByTitle,
|
|
test,
|
|
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', () => {
|
|
test('keeps the selected category in view with 800 categories, 600 hidden and A-Z sorting', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
test.slow();
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
username: 'category-scroll',
|
|
password: 'category-scroll',
|
|
});
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
const panel = app.mainWindow.locator('app-workspace-context-panel');
|
|
const rows = panel.locator('.category-item');
|
|
await expect(rows).toHaveCount(800);
|
|
const dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
await dialog
|
|
.getByRole('button', { name: 'Deselect All', exact: true })
|
|
.click();
|
|
await dialog.locator('input[type="search"]').fill('Visible');
|
|
await dialog
|
|
.getByRole('button', { name: 'Select Filtered', exact: true })
|
|
.click();
|
|
await expect(dialog.locator('.selection-info')).toHaveText(
|
|
'Total selected: 200 / 800'
|
|
);
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(rows).toHaveCount(200);
|
|
await panel
|
|
.getByRole('button', { name: 'Sort categories', exact: true })
|
|
.click();
|
|
await app.mainWindow
|
|
.getByRole('menuitem', { name: 'Name A-Z' })
|
|
.click();
|
|
|
|
const categories = await app.mainWindow.evaluate(async () => {
|
|
const playlistId = location.pathname.match(
|
|
/\/workspace\/xtreams\/([^/]+)/
|
|
)?.[1];
|
|
if (!playlistId)
|
|
throw new Error('Xtream playlist route is missing');
|
|
const api = (
|
|
window as unknown as { electron: ElectronBridgeApi }
|
|
).electron;
|
|
return api.dbGetCategories(playlistId, 'live');
|
|
});
|
|
categories.sort((left, right) =>
|
|
left.name.localeCompare(right.name)
|
|
);
|
|
await expect(rows.locator('.nav-item-label')).toHaveText(
|
|
categories.map((category) => category.name)
|
|
);
|
|
|
|
// Exercise collisions above and below the clicked row. Reading the
|
|
// imported IDs avoids relying on SQLite allocation/import order.
|
|
for (const [theme, direction] of [
|
|
['dark', -1],
|
|
['light', 1],
|
|
] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
const category = categories.find((candidate, index) => {
|
|
const wrongIndex = categories.findIndex(
|
|
(other) => other.xtream_id === candidate.id
|
|
);
|
|
return (
|
|
wrongIndex >= 0 && (wrongIndex - index) * direction > 15
|
|
);
|
|
});
|
|
assert(
|
|
category,
|
|
`Missing fixture collision in direction ${direction}`
|
|
);
|
|
const row = rows.filter({ hasText: category.name });
|
|
await row.click();
|
|
await expect(row).toHaveAttribute('aria-current', 'true');
|
|
await expectCategoryCentered(row);
|
|
}
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
for (const section of ['Live TV', 'Movies', 'Series']) {
|
|
test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: `Filtered ${section}`,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, section);
|
|
let dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
const rows = dialog.locator('.category-item');
|
|
const names = (
|
|
await rows.locator('.category-name').allTextContents()
|
|
).map((name) => name.trim());
|
|
// The default mock catalog has several matches and nonmatches in every type.
|
|
const matched = names.filter((name) =>
|
|
name.toLowerCase().includes('a')
|
|
);
|
|
const outside = names.filter(
|
|
(name) => !name.toLowerCase().includes('a')
|
|
);
|
|
expect(matched.length).toBeGreaterThan(1);
|
|
expect(outside.length).toBeGreaterThan(1);
|
|
const checkbox = (name: string) =>
|
|
dialog.getByRole('checkbox', { name, exact: true });
|
|
await checkbox(outside[0]).uncheck();
|
|
await checkbox(matched[0]).uncheck();
|
|
const search = () => dialog.locator('input[type="search"]');
|
|
await search().fill('a');
|
|
const select = () =>
|
|
dialog.getByRole('button', {
|
|
name: 'Select Filtered',
|
|
exact: true,
|
|
});
|
|
const deselect = () =>
|
|
dialog.getByRole('button', {
|
|
name: 'Deselect Filtered',
|
|
exact: true,
|
|
});
|
|
await expect(rows).toHaveCount(matched.length);
|
|
await expect(select()).toBeEnabled();
|
|
await expect(deselect()).toBeEnabled();
|
|
await select().click();
|
|
await expect(select()).toBeDisabled();
|
|
await expect(dialog.locator('.selection-info')).toHaveText(
|
|
`Total selected: ${names.length - 1} / ${names.length}`
|
|
);
|
|
await deselect().click();
|
|
await expect(deselect()).toBeDisabled();
|
|
await expect(dialog.locator('.selection-info')).toHaveText(
|
|
`Total selected: ${outside.length - 1} / ${names.length}`
|
|
);
|
|
await search().fill('no matching mock category');
|
|
await expect(select()).toBeDisabled();
|
|
await expect(deselect()).toBeDisabled();
|
|
await expect(dialog.locator('.empty-message')).toHaveText(
|
|
'No matching categories found'
|
|
);
|
|
await search().fill('');
|
|
await expect(checkbox(outside[0])).not.toBeChecked();
|
|
await expect(checkbox(outside[1])).toBeChecked();
|
|
for (const name of matched)
|
|
await expect(checkbox(name)).not.toBeChecked();
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await expect(dialog).not.toBeVisible();
|
|
dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
await expect(checkbox(outside[0])).not.toBeChecked();
|
|
await expect(checkbox(outside[1])).toBeChecked();
|
|
for (const name of matched)
|
|
await expect(checkbox(name)).not.toBeChecked();
|
|
await search().fill('A');
|
|
await select().click();
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
await expect(dialog).not.toBeVisible();
|
|
dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
for (const name of matched)
|
|
await expect(checkbox(name)).not.toBeChecked();
|
|
await search().fill('a');
|
|
await select().click();
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await expect(dialog).not.toBeVisible();
|
|
dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
for (const name of matched)
|
|
await expect(checkbox(name)).toBeChecked();
|
|
await expect(checkbox(outside[0])).not.toBeChecked();
|
|
await expect(checkbox(outside[1])).toBeChecked();
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('hides and restores categories, supports search and bulk actions, and persists hidden selections after refresh', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const portalName = 'Category Managed Xtream';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: portalName,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
// Wait for the route to settle on the live TV section so that
|
|
// the sidebar shows live categories (not VOD/series from a
|
|
// previous section) before we read from it.
|
|
await app.mainWindow.waitForURL(
|
|
/\/workspace\/xtreams\/[^/]+\/live/
|
|
);
|
|
|
|
const targetCategory = await pickSidebarCategory(app.mainWindow);
|
|
|
|
let dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Deselect All',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
dialog.locator('.category-item mat-checkbox input:checked')
|
|
).toHaveCount(0);
|
|
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Select All',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
dialog
|
|
.locator('.category-item mat-checkbox input:checked')
|
|
.first()
|
|
).toBeVisible();
|
|
|
|
await dialog
|
|
.locator('input[type="search"]')
|
|
.fill(targetCategory.name);
|
|
await toggleManagedCategory(dialog, targetCategory, false);
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
|
|
await expect(
|
|
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
|
).toHaveCount(0);
|
|
|
|
await openSources(app.mainWindow);
|
|
await refreshSource(app.mainWindow, portalName, { confirm: true });
|
|
await openSources(app.mainWindow);
|
|
await waitForSourceRowIdle(app.mainWindow, portalName);
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, portalName).first().click();
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await app.mainWindow.waitForURL(
|
|
/\/workspace\/xtreams\/[^/]+\/live/
|
|
);
|
|
await expect(
|
|
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
|
).toHaveCount(0);
|
|
|
|
dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
await dialog
|
|
.locator('input[type="search"]')
|
|
.fill(targetCategory.name);
|
|
await toggleManagedCategory(dialog, targetCategory, true);
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
|
|
dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
await dialog
|
|
.locator('input[type="search"]')
|
|
.fill(targetCategory.name);
|
|
const restoredRows = dialog.locator('.category-item');
|
|
await expect(restoredRows).toHaveCount(1, { timeout: 15000 });
|
|
await expect(
|
|
restoredRows.first().locator('mat-checkbox input')
|
|
).toBeChecked();
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('persists hidden selections after refreshing from the workspace header', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const portalName = 'Header Refresh Xtream';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: portalName,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await app.mainWindow.waitForURL(
|
|
/\/workspace\/xtreams\/[^/]+\/live/
|
|
);
|
|
|
|
const targetCategory = await pickSidebarCategory(app.mainWindow);
|
|
const dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Deselect All',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
dialog.locator('.category-item mat-checkbox input:checked')
|
|
).toHaveCount(0);
|
|
|
|
await dialog
|
|
.locator('input[type="search"]')
|
|
.fill(targetCategory.name);
|
|
await toggleManagedCategory(dialog, targetCategory, true);
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
|
|
await expect(
|
|
sidebarCategoryById(app.mainWindow, targetCategory.id)
|
|
).toBeVisible();
|
|
|
|
await refreshFromWorkspaceHeader(app.mainWindow);
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await app.mainWindow.waitForURL(
|
|
/\/workspace\/xtreams\/[^/]+\/live/
|
|
);
|
|
await expectVisibleSidebarCategoryNames(app.mainWindow, [
|
|
targetCategory.name,
|
|
]);
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, portalName).first().click();
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await app.mainWindow.waitForURL(
|
|
/\/workspace\/xtreams\/[^/]+\/live/
|
|
);
|
|
await expectVisibleSidebarCategoryNames(app.mainWindow, [
|
|
targetCategory.name,
|
|
]);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('allows restoring live categories after every category is hidden', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const portalName = 'All Hidden Categories Xtream';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: portalName,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await app.mainWindow.waitForURL(
|
|
/\/workspace\/xtreams\/[^/]+\/live/
|
|
);
|
|
|
|
await expect
|
|
.poll(() => readVisibleSidebarCategoryNames(app.mainWindow), {
|
|
timeout: 15000,
|
|
})
|
|
.not.toEqual([]);
|
|
const initialCategoryNames = await readVisibleSidebarCategoryNames(
|
|
app.mainWindow
|
|
);
|
|
|
|
let dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Deselect All',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
dialog.locator('.category-item mat-checkbox input:checked')
|
|
).toHaveCount(0);
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'app-workspace-context-panel .category-item:visible'
|
|
)
|
|
).toHaveCount(0, { timeout: 15000 });
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'app-workspace-context-panel .category-empty-state'
|
|
)
|
|
).toContainText('No categories available');
|
|
|
|
await expect(
|
|
app.mainWindow.getByRole('button', {
|
|
name: 'Manage categories',
|
|
exact: true,
|
|
})
|
|
).toBeEnabled();
|
|
|
|
dialog = await openManageCategoriesDialog(app.mainWindow);
|
|
await dialog
|
|
.getByRole('button', { name: 'Select All', exact: true })
|
|
.click();
|
|
await expect(
|
|
dialog.locator('.category-item mat-checkbox input:checked')
|
|
).toHaveCount(initialCategoryNames.length);
|
|
await dialog
|
|
.getByRole('button', { name: 'Save', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
|
|
await expectVisibleSidebarCategoryNames(
|
|
app.mainWindow,
|
|
initialCategoryNames
|
|
);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
});
|
|
|
|
async function openManageCategoriesDialog(page: Page) {
|
|
await page.getByRole('button', { name: 'Manage categories' }).click();
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
|
|
await expect(dialog).toBeVisible();
|
|
await expect(
|
|
dialog.getByRole('button', { name: 'Select All', exact: true })
|
|
).toBeVisible();
|
|
// Wait for the category list to render — Angular needs a CD cycle after
|
|
// isLoading() flips to false before the @for items are painted.
|
|
await expect(dialog.locator('.category-item').first()).toBeVisible({
|
|
timeout: 15000,
|
|
});
|
|
return dialog;
|
|
}
|
|
|
|
/** Wait for the real smooth scroll to finish with the selected row centered. */
|
|
async function expectCategoryCentered(row: Locator): Promise<void> {
|
|
let previousTop = -1;
|
|
let stableSamples = 0;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const position = await row.evaluate((element) => {
|
|
const container = element.closest(
|
|
'app-workspace-context-category-view'
|
|
) as HTMLElement;
|
|
const bounds = container.getBoundingClientRect();
|
|
const rowBounds = element.getBoundingClientRect();
|
|
const target =
|
|
container.scrollTop +
|
|
rowBounds.top -
|
|
bounds.top -
|
|
container.clientHeight / 2 +
|
|
rowBounds.height / 2;
|
|
const clamped = Math.min(
|
|
container.scrollHeight - container.clientHeight,
|
|
Math.max(0, target)
|
|
);
|
|
return {
|
|
top: container.scrollTop,
|
|
centered: Math.abs(container.scrollTop - clamped) < 2,
|
|
};
|
|
});
|
|
stableSamples =
|
|
position.top === previousTop ? stableSamples + 1 : 0;
|
|
previousTop = position.top;
|
|
return position.centered && stableSamples >= 3;
|
|
},
|
|
{ intervals: [100] }
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
|
|
await page
|
|
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
|
.click();
|
|
|
|
const dialog = page.locator('mat-dialog-container');
|
|
await expect(dialog).toBeVisible();
|
|
await dialog
|
|
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
|
.click();
|
|
|
|
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
|
|
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
|
|
await expect(
|
|
refreshOverlay.getByRole('heading', {
|
|
name: 'Refreshing playlist',
|
|
exact: true,
|
|
})
|
|
).toBeVisible();
|
|
await expect(refreshOverlay).toContainText(/Local library/);
|
|
await expect(refreshOverlay).toContainText(
|
|
/Preserving your library data|Removing cached streams|Removing cached categories/
|
|
);
|
|
|
|
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
|
}
|
|
|
|
function sidebarCategoryById(page: Page, categoryId: string): Locator {
|
|
return page.locator(
|
|
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
|
|
);
|
|
}
|
|
|
|
async function expectVisibleSidebarCategoryNames(
|
|
page: Page,
|
|
expectedNames: string[]
|
|
): Promise<void> {
|
|
try {
|
|
await expect
|
|
.poll(() => readVisibleSidebarCategoryNames(page), {
|
|
timeout: 30000,
|
|
})
|
|
.toEqual(expectedNames);
|
|
} catch (error) {
|
|
const actualNames = await readVisibleSidebarCategoryNames(page);
|
|
if (stringArraysEqual(actualNames, expectedNames)) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
`Expected visible sidebar categories ${JSON.stringify(
|
|
expectedNames
|
|
)}, received ${JSON.stringify(actualNames)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
}
|
|
|
|
function stringArraysEqual(left: string[], right: string[]): boolean {
|
|
return (
|
|
left.length === right.length &&
|
|
left.every((value, index) => value === right[index])
|
|
);
|
|
}
|
|
|
|
async function pickSidebarCategory(
|
|
page: Page
|
|
): Promise<{ id: string; itemCount: number; name: string }> {
|
|
let preferredCandidate: {
|
|
id: string;
|
|
itemCount: number;
|
|
name: string;
|
|
} | null = null;
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const candidates = (
|
|
await readVisibleSidebarCategories(page)
|
|
).filter(
|
|
(candidate) =>
|
|
candidate.id && candidate.name && candidate.itemCount > 0
|
|
);
|
|
|
|
if (candidates.length === 0) {
|
|
preferredCandidate = null;
|
|
return false;
|
|
}
|
|
|
|
const nameCounts = new Map<string, number>();
|
|
for (const candidate of candidates) {
|
|
nameCounts.set(
|
|
candidate.name,
|
|
(nameCounts.get(candidate.name) ?? 0) + 1
|
|
);
|
|
}
|
|
|
|
preferredCandidate =
|
|
candidates.find(
|
|
(candidate) => nameCounts.get(candidate.name) === 1
|
|
) ?? candidates[0];
|
|
|
|
return preferredCandidate !== null;
|
|
})
|
|
.toBe(true, {
|
|
message: 'No visible Xtream category with content was found.',
|
|
timeout: 15000,
|
|
});
|
|
|
|
return preferredCandidate!;
|
|
}
|
|
|
|
async function toggleManagedCategory(
|
|
dialog: Locator,
|
|
targetCategory: {
|
|
id: string;
|
|
itemCount: number;
|
|
name: string;
|
|
},
|
|
shouldBeSelected: boolean
|
|
): Promise<void> {
|
|
void targetCategory.id;
|
|
const categoryRows = dialog.locator('.category-item');
|
|
await expect(categoryRows).toHaveCount(1, { timeout: 15000 });
|
|
const categoryRow = categoryRows.first();
|
|
const checkbox = categoryRow.locator('mat-checkbox input');
|
|
|
|
await expect(categoryRow).toBeVisible({ timeout: 15000 });
|
|
if (shouldBeSelected) {
|
|
await checkbox.check();
|
|
await expect(checkbox).toBeChecked();
|
|
return;
|
|
}
|
|
|
|
await checkbox.uncheck();
|
|
await expect(checkbox).not.toBeChecked();
|
|
}
|