mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(sources): rebuild the Sources page on the shared source row
- Side list (UI-kit side list): title with the playlist count and an Add button, a Type group, and on desktop a Status group (Any / Responding / Not responding). The two filters combine and persist; each group counts within the other's choice. The chosen row uses one selected-row token (soft tint, outlined in light theme). - Page: a 28px title with the visible count instead of the bordered header bar; Clean up and the sort menu are secondary buttons. Clean up shows how many sources do not respond. Sort adds Last updated, Most channels and (desktop) Not responding first, and keeps Name Z-A and Custom order. - List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing row keeps Retry in view. "No matches" offers Reset filters. Dragging in custom order no longer reuses positions of rows a filter hides. - Delete with Undo: no confirmation; the row leaves at once and the delete runs when the toast closes (its close button commits at once). Deleting the active source switches to the next row. A large Xtream delete shows its row as "Removing…" with progress and Cancel, as before. - The list's refresh flows moved into SourceListRefreshService; E2E and performance drivers follow the new markup and delete flow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
028a3db880
commit
401295c092
70 files changed
+3837
-2023
No files matched your search
@@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl(
|
||||
playlistUrl
|
||||
);
|
||||
if (userAgent !== undefined) {
|
||||
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
|
||||
await setInputValue(
|
||||
dialog.getByRole('textbox', { name: 'User agent', exact: true }),
|
||||
userAgent
|
||||
);
|
||||
}
|
||||
await dialog.getByRole('button', { name: /Add playlist/i }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
@@ -1187,7 +1190,10 @@ export async function createMutableTextServer(
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
|
||||
if (
|
||||
options.requiredUserAgent &&
|
||||
req.headers['user-agent'] !== options.requiredUserAgent
|
||||
) {
|
||||
res.writeHead(403);
|
||||
res.end('User-Agent required');
|
||||
return;
|
||||
@@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact(
|
||||
}
|
||||
|
||||
export function sourceRowByTitle(page: Page, title: string): Locator {
|
||||
return page.locator('app-playlist-item').filter({
|
||||
return page.locator('app-workspace-sources app-source-row').filter({
|
||||
hasText: flexibleTextPattern(title),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
|
||||
return page.locator('app-playlist-item').evaluateAll((elements) =>
|
||||
elements
|
||||
.map((element) => {
|
||||
const titleElement = element.querySelector('.playlist-title');
|
||||
return titleElement?.textContent?.trim() ?? '';
|
||||
})
|
||||
.filter((title) => title.length > 0)
|
||||
);
|
||||
return page
|
||||
.locator('app-workspace-sources app-source-row')
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.map((element) => {
|
||||
const titleElement =
|
||||
element.querySelector('.source-row__name');
|
||||
return titleElement?.textContent?.trim() ?? '';
|
||||
})
|
||||
.filter((title) => title.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export async function selectSourceTypeFilter(
|
||||
@@ -1497,8 +1506,10 @@ export async function selectSourceSort(
|
||||
sortLabel:
|
||||
| 'Date added (Newest first)'
|
||||
| 'Date added (Oldest first)'
|
||||
| 'Last updated'
|
||||
| 'Name (A-Z)'
|
||||
| 'Name (Z-A)'
|
||||
| 'Most channels'
|
||||
| 'Custom order'
|
||||
): Promise<void> {
|
||||
const sortTrigger = page.locator('app-workspace-sources .sort-trigger');
|
||||
@@ -1523,10 +1534,10 @@ export async function dragSourceBefore(
|
||||
sourceTitle: string,
|
||||
targetTitle: string
|
||||
): Promise<void> {
|
||||
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
|
||||
const target = sourceRowByTitle(page, targetTitle).locator(
|
||||
'.playlist-item'
|
||||
const source = sourceRowByTitle(page, sourceTitle).locator(
|
||||
'.source-row__handle'
|
||||
);
|
||||
const target = sourceRowByTitle(page, targetTitle).locator('.source-row');
|
||||
|
||||
await expect(source.first()).toBeVisible();
|
||||
await expect(target.first()).toBeVisible();
|
||||
@@ -1560,15 +1571,18 @@ export async function openSourceEditor(
|
||||
const row = sourceRowByTitle(page, title).first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('.edit-btn').click();
|
||||
await row.hover();
|
||||
await row.locator('.source-row__edit').click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
// Material applies its initial focus after the opening transition.
|
||||
await dialog.evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations({ subtree: true }).map((animation) =>
|
||||
animation.finished.catch(() => undefined)
|
||||
));
|
||||
await Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
);
|
||||
});
|
||||
return dialog;
|
||||
}
|
||||
@@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a source from its ⋯ menu. The row leaves at once; the delete
|
||||
* itself runs when the Undo toast closes, so this waits for the toast to go
|
||||
* before the caller checks persisted state.
|
||||
*/
|
||||
export async function deleteSource(page: Page, title: string): Promise<void> {
|
||||
const row = sourceRowByTitle(page, title).first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('.delete-btn').click();
|
||||
await confirmDialog(page, 'Remove playlist');
|
||||
await row.hover();
|
||||
await row.locator('.source-row__more').click();
|
||||
await page
|
||||
.locator('.cdk-overlay-pane [mat-menu-item]')
|
||||
.filter({ hasText: 'Remove playlist' })
|
||||
.click();
|
||||
const toast = page.locator('mat-snack-bar-container.app-toast');
|
||||
await expect(toast).toContainText('Removed');
|
||||
await expect(toast).toBeHidden({ timeout: 15000 });
|
||||
}
|
||||
|
||||
export async function refreshSource(
|
||||
@@ -1697,7 +1723,8 @@ export async function refreshSource(
|
||||
const row = sourceRowByTitle(page, title).first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('.refresh-btn').click();
|
||||
await row.hover();
|
||||
await row.locator('.source-row__refresh').click();
|
||||
|
||||
if (confirm) {
|
||||
await confirmDialog(page, 'Refresh playlist');
|
||||
@@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle(
|
||||
const row = sourceRowByTitle(page, title).first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('.busy-state__message')).toHaveCount(0, {
|
||||
await expect(row.locator('.source-row__busy')).toHaveCount(0, {
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(row.locator('.action-spinner')).toHaveCount(0, {
|
||||
await expect(row.locator('mat-spinner')).toHaveCount(0, {
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => {
|
||||
});
|
||||
await openSources(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-playlist-item')
|
||||
app.mainWindow.locator('app-workspace-sources app-source-row')
|
||||
).toHaveCount(2, { timeout: 30000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
|
||||
@@ -550,19 +550,21 @@ test.describe('Keyboard focus ring', () => {
|
||||
// Sources: the type filters, then the list's row actions.
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.locator('app-playlist-item').first()
|
||||
page.locator('app-workspace-sources app-source-row').first()
|
||||
).toBeVisible();
|
||||
// Add, the Type and Status rows, Clean up, Sort, then the first
|
||||
// row and its actions; Check again lives in the row's menu.
|
||||
await walk(
|
||||
'sources list',
|
||||
page.locator('app-workspace-shell'),
|
||||
page
|
||||
.locator('app-workspace-sources-filters-panel button')
|
||||
.first(),
|
||||
12
|
||||
16
|
||||
);
|
||||
expect(
|
||||
report['sources list (dark)'].map((stop) => stop.label)
|
||||
).toContain('Check again');
|
||||
).toContain('Playlist actions');
|
||||
|
||||
// The live EPG list: its rows are Tab stops (`role="button"`)
|
||||
// with their own inset ring, then the row's info button.
|
||||
|
||||
@@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel',
|
||||
assert.equal(options.splashId, 'initial-splash');
|
||||
assert.equal(options.routeFragment, '/workspace');
|
||||
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
|
||||
assert.match(options.cardSelector, /app-playlist-item/);
|
||||
assert.match(options.cardSelector, /app-source-row/);
|
||||
assert.deepEqual(options.settle, {
|
||||
capMs: JOURNEY_SETTLE_CAP_MS,
|
||||
quietMs: JOURNEY_SETTLE_QUIET_MS,
|
||||
@@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route',
|
||||
options.startClick?.selector ?? '',
|
||||
/dashboard-recent-sources-rail-card/
|
||||
);
|
||||
assert.match(options.startClick?.selector ?? '', /app-playlist-item/);
|
||||
assert.match(options.startClick?.selector ?? '', /app-source-row/);
|
||||
assert.equal(options.routeFragment, '/workspace/xtreams/');
|
||||
assert.match(options.cardSelector, /app-grid-list mat-card/);
|
||||
assert.match(options.cardSelector, /channel-item/);
|
||||
|
||||
@@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR =
|
||||
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
|
||||
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
|
||||
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
|
||||
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
|
||||
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row';
|
||||
/**
|
||||
* Bridge method used for the sentinels. The preload emits the trace event
|
||||
* before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
|
||||
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
|
||||
): JourneyRendererProbeOptions {
|
||||
return {
|
||||
cardSelector:
|
||||
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
|
||||
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row',
|
||||
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
|
||||
...(idleWindowMs === null
|
||||
? {}
|
||||
|
||||
@@ -262,7 +262,7 @@ async function runIteration(
|
||||
|
||||
await renderer.markOperationStart();
|
||||
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
|
||||
await row.locator('.refresh-btn').click();
|
||||
await row.locator('.source-row__refresh').click();
|
||||
await renderer.waitForCancelClick();
|
||||
await Promise.all([
|
||||
renderer.waitForTerminal(),
|
||||
|
||||
@@ -301,7 +301,7 @@ async function installRendererProbe(
|
||||
const epoch = (): number =>
|
||||
performance.timeOrigin + performance.now();
|
||||
const sourceRow = (): Element | undefined =>
|
||||
Array.from(document.querySelectorAll('app-playlist-item')).find(
|
||||
Array.from(document.querySelectorAll('app-source-row')).find(
|
||||
(element) => element.textContent?.includes(title)
|
||||
);
|
||||
const state = {
|
||||
|
||||
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
|
||||
);
|
||||
const sourceRowAbsent = (): boolean =>
|
||||
!Array.from(
|
||||
document.querySelectorAll('app-playlist-item')
|
||||
document.querySelectorAll('app-source-row')
|
||||
).some(
|
||||
(row) =>
|
||||
row
|
||||
.querySelector('.playlist-title')
|
||||
.querySelector('.source-row__name')
|
||||
?.textContent?.trim() === state.sourceTitle
|
||||
);
|
||||
const terminalDomGate = (): boolean => {
|
||||
|
||||
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
|
||||
"'#serverUrl'",
|
||||
"'#username'",
|
||||
"'#password'",
|
||||
"'app-playlist-item'",
|
||||
"'.refresh-btn'",
|
||||
"'.delete-btn'",
|
||||
"'app-workspace-sources app-source-row'",
|
||||
"'.source-row__refresh'",
|
||||
"'.source-row__more'",
|
||||
"'Refresh playlist'",
|
||||
"'Remove playlist'",
|
||||
"'.busy-state__message'",
|
||||
"'.action-spinner'",
|
||||
"'.cancel-btn'",
|
||||
"'.source-row__busy'",
|
||||
"'mat-spinner'",
|
||||
"'.source-row__cancel'",
|
||||
"'.workspace-loading-overlay'",
|
||||
]) {
|
||||
assert.ok(ui.includes(selector), `missing ${selector}`);
|
||||
|
||||
@@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => {
|
||||
});
|
||||
|
||||
it('does not require a misleading portal-status badge before refresh or delete', async () => {
|
||||
for (const [scenarioId, selector, confirmLabel] of [
|
||||
for (const [scenarioId, selector, confirmClick] of [
|
||||
[
|
||||
XTREAM_SCENARIO_ID.REFRESH_LARGE,
|
||||
'.refresh-btn',
|
||||
'Refresh playlist',
|
||||
'.source-row__refresh',
|
||||
'name=Refresh playlist;exact=true',
|
||||
],
|
||||
[
|
||||
XTREAM_SCENARIO_ID.DELETE_LARGE,
|
||||
'.source-row__more',
|
||||
'.source-removal-toast__close',
|
||||
],
|
||||
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
|
||||
] as const) {
|
||||
const confirmClick = `name=${confirmLabel};exact=true`;
|
||||
const fake = new FakePage();
|
||||
const prepared = await prepareXtreamScenario(
|
||||
fake.asPage(),
|
||||
|
||||
@@ -68,28 +68,42 @@ export async function prepareXtreamSourceActionTrigger(
|
||||
const row = xtreamSourceRow(page, sourceTitle);
|
||||
await row.waitFor({ state: 'visible' });
|
||||
for (const selector of [
|
||||
'.busy-state__message',
|
||||
'.action-spinner',
|
||||
'.cancel-btn',
|
||||
'.source-row__busy',
|
||||
'mat-spinner',
|
||||
'.source-row__cancel',
|
||||
]) {
|
||||
await row.locator(selector).waitFor({ state: 'detached' });
|
||||
}
|
||||
const actionSelector =
|
||||
action === 'refresh' ? '.refresh-btn' : '.delete-btn';
|
||||
const actionButton = row.locator(actionSelector);
|
||||
await assertTriggerReady(actionButton, `xtream-source-${action}`);
|
||||
await actionButton.click();
|
||||
if (action === 'refresh') {
|
||||
const refresh = row.locator('.source-row__refresh');
|
||||
await assertTriggerReady(refresh, 'xtream-source-refresh');
|
||||
await refresh.click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
const confirmation = dialog
|
||||
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
||||
.last();
|
||||
await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm');
|
||||
return confirmation;
|
||||
}
|
||||
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
const confirmation = dialog
|
||||
.getByRole('button', {
|
||||
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
|
||||
exact: true,
|
||||
})
|
||||
.last();
|
||||
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
|
||||
return confirmation;
|
||||
// Delete leaves the list at once and offers Undo; the toast's close
|
||||
// button commits it without waiting out the Undo window, so the
|
||||
// measurement starts when the delete does.
|
||||
const more = row.locator('.source-row__more');
|
||||
await assertTriggerReady(more, 'xtream-source-delete-menu');
|
||||
await more.click();
|
||||
const remove = page
|
||||
.locator('.cdk-overlay-pane [mat-menu-item]')
|
||||
.filter({ hasText: 'Remove playlist' })
|
||||
.first();
|
||||
await assertTriggerReady(remove, 'xtream-source-delete');
|
||||
await remove.click();
|
||||
const commit = page.locator(
|
||||
'mat-snack-bar-container.app-toast .source-removal-toast__close'
|
||||
);
|
||||
await assertTriggerReady(commit, 'xtream-source-delete-confirm');
|
||||
return commit;
|
||||
}
|
||||
|
||||
export async function waitForXtreamCatalogTerminal(page: Page): Promise<void> {
|
||||
@@ -129,7 +143,10 @@ export async function waitForXtreamDeleteTerminal(
|
||||
page: Page,
|
||||
sourceTitle: string
|
||||
): Promise<void> {
|
||||
await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' });
|
||||
const row = xtreamSourceRow(page, sourceTitle);
|
||||
// The row returns as "Removing…" once the delete starts, then leaves.
|
||||
await row.waitFor({ state: 'attached' });
|
||||
await row.waitFor({ state: 'detached' });
|
||||
}
|
||||
|
||||
async function selectXtreamMethod(dialog: Locator): Promise<void> {
|
||||
@@ -157,9 +174,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
|
||||
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
|
||||
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
|
||||
return page
|
||||
.locator('app-playlist-item')
|
||||
.locator('app-workspace-sources app-source-row')
|
||||
.filter({
|
||||
has: page.locator('.playlist-title', {
|
||||
has: page.locator('.source-row__name', {
|
||||
hasText: exactTitle,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
|
||||
}
|
||||
this.searchGateObserved =
|
||||
/searchParams\.get\((['"])q\1\)/u.test(source) &&
|
||||
source.includes('app-playlist-item') &&
|
||||
source.includes('app-source-row') &&
|
||||
source.includes('no-results-state');
|
||||
if (this.searchGateObserved) {
|
||||
this.searchResultsApplied = true;
|
||||
|
||||
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
|
||||
);
|
||||
const resultRows = Array.from(
|
||||
document.querySelectorAll(
|
||||
'app-workspace-sources app-playlist-item'
|
||||
'app-workspace-sources app-source-row'
|
||||
)
|
||||
);
|
||||
const resultPainted =
|
||||
resultRows.length > 0
|
||||
? resultRows.every((row) =>
|
||||
(
|
||||
row.querySelector('.playlist-title')
|
||||
row.querySelector('.source-row__name')
|
||||
?.textContent ?? ''
|
||||
)
|
||||
.trim()
|
||||
|
||||
@@ -31,7 +31,7 @@ type RowRing = {
|
||||
function readRowRing(page: Page): Promise<RowRing> {
|
||||
return page.evaluate(() => {
|
||||
const focused = document.activeElement as HTMLElement;
|
||||
const row = focused.closest('.playlist-item') as HTMLElement;
|
||||
const row = focused.closest('.source-row') as HTMLElement;
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = 'var(--app-focus-ring)';
|
||||
row.append(probe);
|
||||
@@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openSources(page);
|
||||
await expect(page.locator('app-playlist-item')).toHaveCount(2, {
|
||||
await expect(
|
||||
page.locator('app-workspace-sources app-source-row')
|
||||
).toHaveCount(2, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Bravo opened last, so it is the active source; open the other.
|
||||
const row = sourceRowByTitle(page, 'keyboard-alpha').first();
|
||||
await expect(row.locator('.playlist-item')).not.toHaveClass(
|
||||
/\bselected\b/
|
||||
await expect(row.locator('.source-row__body')).not.toHaveAttribute(
|
||||
'aria-current',
|
||||
'true'
|
||||
);
|
||||
const title = (
|
||||
await row.locator('.playlist-title').textContent()
|
||||
await row.locator('.source-row__name').textContent()
|
||||
)?.trim();
|
||||
expect(title).toBeTruthy();
|
||||
|
||||
@@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => {
|
||||
await expect(open).not.toBeFocused();
|
||||
expect(
|
||||
await row
|
||||
.locator('.playlist-item')
|
||||
.locator('.source-row')
|
||||
.evaluate((el) => getComputedStyle(el).outlineStyle)
|
||||
).toBe('none');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
|
||||
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
|
||||
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
|
||||
'app-source-health-indicator [data-state]'
|
||||
'.source-tile__dot[data-state]'
|
||||
)
|
||||
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
|
||||
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
|
||||
'app-source-health-indicator [data-state]'
|
||||
'.source-tile__dot[data-state]'
|
||||
)
|
||||
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
|
||||
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
|
||||
|
||||
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
|
||||
await expect(
|
||||
@@ -822,8 +822,8 @@ https://streams.example.test/original-url.m3u8
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
|
||||
.first()
|
||||
.locator('.refresh-btn')
|
||||
).toBeVisible();
|
||||
.locator('.source-row__refresh')
|
||||
).toBeAttached();
|
||||
|
||||
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
|
||||
{
|
||||
@@ -865,8 +865,8 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
|
||||
.first()
|
||||
.locator('.meta')
|
||||
).toContainText('Updated:');
|
||||
.locator('.source-row__meta')
|
||||
).toContainText('updated');
|
||||
await waitForSourceRowIdle(
|
||||
app.mainWindow,
|
||||
'refresh-url-source.m3u'
|
||||
@@ -876,9 +876,11 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
'refresh-url-source.m3u'
|
||||
).first();
|
||||
await expect(
|
||||
refreshedUrlRow.locator('.playlist-item')
|
||||
).not.toHaveClass(/is-busy/);
|
||||
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
|
||||
refreshedUrlRow.locator('.source-row')
|
||||
).not.toHaveClass(/source-row--busy/);
|
||||
await expect(
|
||||
refreshedUrlRow.locator('.source-row__refresh')
|
||||
).toBeEnabled();
|
||||
await refreshedUrlRow
|
||||
.getByText('refresh-url-source.m3u', { exact: true })
|
||||
.click();
|
||||
@@ -904,8 +906,8 @@ https://streams.example.test/refreshed-url.m3u8
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
|
||||
.first()
|
||||
.locator('.meta')
|
||||
).toContainText('Updated:');
|
||||
.locator('.source-row__meta')
|
||||
).toContainText('updated');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await urlServer.close();
|
||||
|
||||
@@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20';
|
||||
const dbWorkerRowsPerTransaction = '100';
|
||||
|
||||
function playlistRowByName(name: string) {
|
||||
return `app-playlist-item:has-text("${name}")`;
|
||||
return `app-workspace-sources app-source-row:has-text("${name}")`;
|
||||
}
|
||||
|
||||
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
|
||||
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
);
|
||||
await expect(playlistRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await playlistRow.locator('.delete-btn').click();
|
||||
|
||||
const confirmDialog = app.mainWindow.locator(
|
||||
'mat-dialog-container'
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Remove playlist', exact: true })
|
||||
// Delete from the row menu; the delete starts when the Undo
|
||||
// toast closes, and the row comes back to show its progress.
|
||||
await playlistRow.hover();
|
||||
await playlistRow.locator('.source-row__more').click();
|
||||
await app.mainWindow
|
||||
.locator('.cdk-overlay-pane [mat-menu-item]')
|
||||
.filter({ hasText: 'Remove playlist' })
|
||||
.click();
|
||||
|
||||
await waitForDbOperationEvent(app.mainWindow, {
|
||||
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
playlistRow.locator('.busy-state__message')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
const cancel = playlistRow.locator('.cancel-btn');
|
||||
await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
const cancel = playlistRow.locator('.source-row__cancel');
|
||||
await expect(cancel).toBeVisible();
|
||||
// The row's action buttons inherit its color; the cancel action
|
||||
// must still read as destructive on the selected row.
|
||||
|
||||
Reference in new issue
Block a user