mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* fix(ui): restore channel and detail keyboard scrolling * test(ui): drag below the Windows scrollbar arrow
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import {
|
|
addXtreamPortal,
|
|
closeElectronApp,
|
|
expect,
|
|
expectPortalDebugSuccess,
|
|
expectRendererFramesAdvance,
|
|
launchElectronApp,
|
|
resetMockServers,
|
|
test,
|
|
waitForDbOperationEvent,
|
|
waitForXtreamCatalog,
|
|
xtreamMockServer,
|
|
} from './electron-test-fixtures';
|
|
|
|
const stressPortalName = 'Stress Xtream Portal';
|
|
const stressXtreamUsername = 'stress';
|
|
const stressXtreamPassword = 'stress';
|
|
const dbWorkerBatchDelayMs = '20';
|
|
// The stress catalog is 1,920 rows per type; at the production budget of
|
|
// 5,000 rows per commit that is one commit per type, too few checkpoints and
|
|
// progress events to observe the import mid-flight. Shrinking the budget
|
|
// keeps the batches this suite slows down and watches.
|
|
const dbWorkerRowsPerTransaction = '100';
|
|
|
|
function playlistRowByName(name: string) {
|
|
return `app-playlist-item:has-text("${name}")`;
|
|
}
|
|
|
|
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
|
|
await page.getByRole('link', { name: 'Sources', exact: true }).click();
|
|
await page.waitForURL(/\/workspace\/sources$/);
|
|
}
|
|
|
|
function xtreamBlockedStateTitle(page: Parameters<typeof addXtreamPortal>[0]) {
|
|
return page.locator('.xtream-content-gate .error-view__title');
|
|
}
|
|
|
|
test.describe('Electron Xtream Responsiveness', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
test.setTimeout(120000);
|
|
|
|
test('keeps the UI responsive during large Xtream imports', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
|
|
const app = await launchElectronApp(dataDir, {
|
|
env: {
|
|
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
|
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
|
|
dbWorkerRowsPerTransaction,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: stressPortalName,
|
|
password: stressXtreamPassword,
|
|
username: stressXtreamUsername,
|
|
});
|
|
|
|
const overlay = app.mainWindow.locator(
|
|
'.workspace-loading-overlay'
|
|
);
|
|
await expect(overlay).toBeVisible({ timeout: 15000 });
|
|
await expect(
|
|
overlay.getByRole('button', {
|
|
name: /stop sync|cancel import/i,
|
|
})
|
|
).toBeVisible({ timeout: 20000 });
|
|
|
|
await waitForDbOperationEvent(app.mainWindow, {
|
|
operation: 'save-content',
|
|
predicate: (event) =>
|
|
event.status === 'progress' &&
|
|
(event.current ?? 0) > 0 &&
|
|
(event.total ?? 0) > 0,
|
|
timeoutMs: 30000,
|
|
});
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await app.mainWindow.evaluate(
|
|
() =>
|
|
(window.__dbOperationEvents ?? []).filter(
|
|
(event) =>
|
|
event.operation === 'save-content' &&
|
|
event.status === 'progress'
|
|
).length
|
|
)) > 2,
|
|
{ timeout: 30000 }
|
|
)
|
|
.toBeTruthy();
|
|
|
|
await expectRendererFramesAdvance(app.mainWindow, {
|
|
minimumDelta: 5,
|
|
sampleMs: 400,
|
|
});
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await overlay.locator('p').allInnerTexts()).some(
|
|
(text) =>
|
|
/(Live TV|Movies|Series).*?\d+\s*\/\s*\d+/.test(
|
|
text
|
|
)
|
|
),
|
|
{ timeout: 30000 }
|
|
)
|
|
.toBeTruthy();
|
|
|
|
await waitForXtreamCatalog(app.mainWindow);
|
|
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('stops importing after cancel until the user explicitly retries', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
|
|
const app = await launchElectronApp(dataDir, {
|
|
env: {
|
|
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
|
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
|
|
dbWorkerRowsPerTransaction,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: stressPortalName,
|
|
password: stressXtreamPassword,
|
|
username: stressXtreamUsername,
|
|
});
|
|
|
|
const overlay = app.mainWindow.locator(
|
|
'.workspace-loading-overlay'
|
|
);
|
|
const cancelButton = overlay.getByRole('button', {
|
|
name: /stop sync|cancel import/i,
|
|
});
|
|
|
|
await expect(overlay).toBeVisible({ timeout: 15000 });
|
|
await expect(cancelButton).toBeVisible({ timeout: 20000 });
|
|
|
|
await waitForDbOperationEvent(app.mainWindow, {
|
|
operation: 'save-content',
|
|
predicate: (event) =>
|
|
event.status === 'progress' &&
|
|
(event.current ?? 0) > 0 &&
|
|
(event.total ?? 0) > 0,
|
|
timeoutMs: 30000,
|
|
});
|
|
|
|
await cancelButton.click();
|
|
|
|
await expect(overlay).toHaveCount(0, { timeout: 20000 });
|
|
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
|
'Import cancelled',
|
|
{ timeout: 20000 }
|
|
);
|
|
|
|
await app.mainWindow.waitForTimeout(1200);
|
|
await expect(overlay).toHaveCount(0);
|
|
|
|
const retryButton = app.mainWindow.getByRole('button', {
|
|
name: 'Retry',
|
|
exact: true,
|
|
});
|
|
await retryButton.click();
|
|
|
|
await waitForXtreamCatalog(app.mainWindow);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('blocks expired and inactive Xtream accounts before import starts', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Expired Xtream Portal',
|
|
password: 'expired',
|
|
username: 'expired',
|
|
});
|
|
|
|
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
|
'Account expired',
|
|
{ timeout: 20000 }
|
|
);
|
|
await expect(
|
|
app.mainWindow.locator('.workspace-loading-overlay')
|
|
).toHaveCount(0);
|
|
|
|
await openSources(app.mainWindow);
|
|
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Inactive Xtream Portal',
|
|
password: 'inactive',
|
|
username: 'inactive',
|
|
});
|
|
|
|
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
|
'Account inactive',
|
|
{ timeout: 20000 }
|
|
);
|
|
await expect(
|
|
app.mainWindow.locator('.workspace-loading-overlay')
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('blocks unreachable Xtream portals immediately without an import loop', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Unavailable Xtream Portal',
|
|
password: stressXtreamPassword,
|
|
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
|
|
username: stressXtreamUsername,
|
|
});
|
|
|
|
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
|
|
'Portal unavailable',
|
|
{ timeout: 20000 }
|
|
);
|
|
await expect(
|
|
app.mainWindow.locator('.workspace-loading-overlay')
|
|
).toHaveCount(0);
|
|
await app.mainWindow.waitForTimeout(1200);
|
|
await expect(
|
|
app.mainWindow.locator('.workspace-loading-overlay')
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('keeps the UI responsive during large Xtream playlist deletion', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
|
|
const app = await launchElectronApp(dataDir, {
|
|
env: {
|
|
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
|
|
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION:
|
|
dbWorkerRowsPerTransaction,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: stressPortalName,
|
|
password: stressXtreamPassword,
|
|
username: stressXtreamUsername,
|
|
});
|
|
await waitForXtreamCatalog(app.mainWindow);
|
|
await openSources(app.mainWindow);
|
|
|
|
const playlistRow = app.mainWindow.locator(
|
|
playlistRowByName(stressPortalName)
|
|
);
|
|
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: 'Yes', exact: true })
|
|
.click();
|
|
|
|
await waitForDbOperationEvent(app.mainWindow, {
|
|
operation: 'delete-playlist',
|
|
status: 'started',
|
|
timeoutMs: 20000,
|
|
});
|
|
|
|
await expect(
|
|
playlistRow.locator('.busy-state__message')
|
|
).toBeVisible({ timeout: 20000 });
|
|
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
|
|
|
|
await waitForDbOperationEvent(app.mainWindow, {
|
|
operation: 'delete-playlist',
|
|
predicate: (event) =>
|
|
event.status === 'progress' &&
|
|
(event.current ?? 0) > 0 &&
|
|
(event.total ?? 0) > 0,
|
|
timeoutMs: 30000,
|
|
});
|
|
|
|
await expectRendererFramesAdvance(app.mainWindow, {
|
|
minimumDelta: 5,
|
|
sampleMs: 400,
|
|
});
|
|
|
|
await expect(playlistRow).toHaveCount(0, { timeout: 60000 });
|
|
await expect(
|
|
app.mainWindow.locator(playlistRowByName(stressPortalName))
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('keeps Live TV scrolling, row actions and resizing independently accessible', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
try {
|
|
const page = app.mainWindow;
|
|
await addXtreamPortal(page);
|
|
await waitForXtreamCatalog(page);
|
|
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
|
const categories = page.locator('.context-panel .category-item');
|
|
const category = categories.first();
|
|
await category.click();
|
|
const viewport = page.locator('.scroll-viewport-portals');
|
|
await expect(viewport).toBeVisible();
|
|
await category.focus();
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(viewport).toBeFocused();
|
|
await expect(page.locator('app-web-player-view')).toHaveCount(0);
|
|
await page.keyboard.press('Tab');
|
|
await expect(
|
|
viewport.locator('button.channel-content').first()
|
|
).toBeFocused();
|
|
await page.keyboard.press('Shift+Tab');
|
|
await expect(viewport).toBeFocused();
|
|
await viewport.locator('.channel-name').first().click();
|
|
await expect(viewport).toBeFocused();
|
|
await page.keyboard.press('PageDown');
|
|
await expect
|
|
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
|
.toBeGreaterThan(100);
|
|
const position = await viewport.evaluate((el) => el.scrollTop);
|
|
await page.keyboard.press('PageDown');
|
|
await expect
|
|
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
|
.toBeGreaterThan(position);
|
|
await page.keyboard.press('Home');
|
|
await expect
|
|
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
|
.toBe(0);
|
|
const before = (await viewport.boundingBox())!;
|
|
const handle = page.locator(
|
|
'app-live-stream-layout .sidebar > .resize-handle'
|
|
);
|
|
const resize = (await handle.boundingBox())!;
|
|
expect(before.x + before.width).toBeLessThanOrEqual(resize.x);
|
|
expect(
|
|
await handle.evaluate((el) =>
|
|
getComputedStyle(el).getPropertyValue('app-region')
|
|
)
|
|
).toBe('no-drag');
|
|
// Start inside the thumb, below the native Windows up-arrow button.
|
|
// The 40-channel mock leaves a thumb taller than 100 px here.
|
|
await page.mouse.move(before.x + before.width - 3, before.y + 40);
|
|
await page.mouse.down();
|
|
await page.mouse.move(before.x + before.width - 3, before.y + 160, {
|
|
steps: 10,
|
|
});
|
|
await page.mouse.up();
|
|
await expect
|
|
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
|
.toBeGreaterThan(100);
|
|
expect((await viewport.boundingBox())!.width).toBe(before.width);
|
|
await page.mouse.move(resize.x + 2, resize.y + 100);
|
|
await page.mouse.down();
|
|
await page.mouse.move(resize.x + 62, resize.y + 100, { steps: 10 });
|
|
await page.mouse.up();
|
|
await expect
|
|
.poll(async () => (await viewport.boundingBox())!.width)
|
|
.toBeGreaterThan(before.width + 30);
|
|
await viewport.focus();
|
|
await page.keyboard.press('ArrowLeft');
|
|
await expect(category).toBeFocused();
|
|
await categories.nth(1).focus();
|
|
await page.keyboard.press('Enter');
|
|
await expect(categories.nth(1)).toBeFocused();
|
|
await expect(categories.nth(1)).toHaveAttribute('aria-current', 'true');
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(viewport).toBeFocused();
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|