import { _electron as electron, APIRequestContext, ElectronApplication, expect, Locator, Page, test as base, } from '@playwright/test'; import { createServer, Server } from 'http'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( workspaceRoot, 'dist/apps/electron-backend/main.js' ); export const packagedRendererIndexPath = join( workspaceRoot, 'dist/apps/web/index.html' ); export const m3uFixturePath = join( workspaceRoot, 'apps/web-e2e/src/fixtures/test.m3u' ); export const stalkerMockPort = process.env['MOCK_PORT'] ?? '3210'; export const xtreamMockPort = process.env['XTREAM_MOCK_PORT'] ?? '3211'; export const stalkerMockServer = `http://localhost:${stalkerMockPort}`; export const xtreamMockServer = `http://localhost:${xtreamMockPort}`; export const defaultXtreamPortalName = 'Mock Xtream Portal'; export const defaultStalkerPortalName = 'Mock Stalker Portal'; export const defaultXtreamUsername = 'user1'; export const defaultXtreamPassword = 'pass1'; export const defaultStalkerMacAddress = '00:1A:79:00:00:01'; export type PortalProvider = 'stalker' | 'xtream'; export type M3uTestChannel = { groupTitle?: string; logo?: string; name: string; tvgId?: string; tvgName?: string; url: string; }; export type MutableTextServer = { close: () => Promise; origin: string; resourceUrl: string; setBody: (body: string) => void; }; type ElectronFixtures = { dataDir: string; }; type LaunchElectronAppOptions = { env?: Record; }; export type PortalDebugEvent = { durationMs: number; operation: string; provider: PortalProvider; request: unknown; requestId: string; response?: unknown; startedAt: string; status: 'success' | 'error'; transport: 'electron-main' | 'electron-renderer' | 'pwa-http'; }; export type DbOperationEvent = { operationId?: string; operation: string; playlistId?: string; status: 'started' | 'progress' | 'completed' | 'cancelled' | 'error'; phase?: string; current?: number; total?: number; increment?: number; error?: string; }; declare global { interface Window { __dbOperationEvents?: DbOperationEvent[]; __dbOperationUnsubscribe?: (() => void) | undefined; __portalDebugEvents?: PortalDebugEvent[]; __portalDebugUnsubscribe?: (() => void) | undefined; __rendererFrameCount?: number; __rendererFrameRequestId?: number; } } export type LaunchedElectronApp = { electronApp: ElectronApplication; mainWindow: Page; }; export const test = base.extend({ dataDir: async ({}, use) => { const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-electron-e2e-')); await use(dataDir); rmSync(dataDir, { force: true, recursive: true }); }, }); export { expect }; export async function launchElectronApp( dataDir: string, options: LaunchElectronAppOptions = {} ): Promise { if (!existsSync(electronMainPath)) { throw new Error( `Electron build not found at ${electronMainPath}. Run the build before executing the E2E suite.` ); } assertPackagedRendererBuildIsElectronSafe(); const args = [electronMainPath]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); } const electronApp = await electron.launch({ args, env: { ...process.env, ...options.env, ELECTRON_IS_DEV: '0', IPTVNATOR_E2E_DATA_DIR: dataDir, NODE_ENV: 'test', }, }); const mainWindow = await findMainWindow(electronApp); await waitForAppReady(mainWindow); await startPortalDebugCapture(mainWindow); await startDbOperationCapture(mainWindow); await startRendererFrameCapture(mainWindow); return { electronApp, mainWindow, }; } export async function closeElectronApp( app: LaunchedElectronApp ): Promise { try { await app.electronApp.close(); } catch (error) { console.warn('Failed to close Electron app cleanly:', error); } } function assertPackagedRendererBuildIsElectronSafe(): void { if (!existsSync(packagedRendererIndexPath)) { throw new Error( `Renderer build not found at ${packagedRendererIndexPath}. Run pnpm nx run electron-backend:build-e2e before executing the Electron E2E suite.` ); } const indexHtml = readFileSync(packagedRendererIndexPath, 'utf8'); const baseHrefMatch = indexHtml.match(/'; if (baseHref !== './') { throw new Error( `Renderer build at ${packagedRendererIndexPath} is not file-safe for packaged Electron. Found base href ${JSON.stringify(baseHref)}. Run pnpm nx run electron-backend:build-e2e or use an Electron E2E Nx target so dist/apps/web is rebuilt with the electron-e2e configuration.` ); } } async function findMainWindow(app: ElectronApplication): Promise { await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); const windows = app.windows(); for (const window of windows) { const title = await window.title(); if (!title.includes('DevTools')) { return window; } } return app.firstWindow(); } async function waitForAppReady(page: Page): Promise { await page.waitForLoadState('domcontentloaded'); await page.waitForSelector('app-root', { timeout: 30000 }); try { await page.waitForFunction( () => { const appRoot = document.querySelector('app-root'); return Boolean(appRoot && appRoot.innerHTML.trim().length > 0); }, { timeout: 30000 } ); } catch (error) { const diagnostics = await page.evaluate(() => ({ appRootLength: document.querySelector('app-root')?.innerHTML.trim().length ?? 0, baseHref: document .querySelector('base') ?.getAttribute('href') ?? '', readyState: document.readyState, title: document.title, url: location.href, })); const reason = error instanceof Error ? error.message : 'unknown startup error'; throw new Error( `Electron app did not render visible app-root within 30000ms. ${reason}. Diagnostics: ${JSON.stringify( diagnostics )}` ); } } export async function openAddPlaylistDialog(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); await expect(page.locator('mat-dialog-container').last()).toBeVisible(); } async function getActiveDialog(page: Page): Promise { const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); return dialog; } export async function stubNativePlaylistFileDialog( electronApp: ElectronApplication, filePath: string ): Promise { await electronApp.evaluate(async ({ dialog }, selectedFilePath) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedFilePath], }) as any; }, filePath); } export async function importM3uPlaylistFromNativeDialog( app: LaunchedElectronApp, filePath: string ): Promise { await openAddPlaylistDialog(app.mainWindow); const dialog = await getActiveDialog(app.mainWindow); await clickDialogCategoryOption(dialog, /^m3u$/i); await clickDialogSubtypeOption( dialog, /add\s+via\s+file\s+upload/i, 'mat-button-toggle[value="file"]' ); const fileInput = dialog.locator('input[type="file"][name="playlist"]'); await fileInput.evaluate((element, selectedFilePath) => { (element as HTMLInputElement).dataset.filePathOverride = selectedFilePath; }, filePath); await fileInput.setInputFiles(filePath); await expect( dialog.getByRole('button', { name: /add playlist/i }) ).toBeEnabled({ timeout: 10000 }); await dialog.getByRole('button', { name: /add playlist/i }).click(); await dialog.waitFor({ state: 'detached' }); } export async function addXtreamPortal( page: Page, options: { name?: string; password?: string; serverUrl?: string; username?: string; } = {} ): Promise { const { name = defaultXtreamPortalName, password = defaultXtreamPassword, serverUrl = xtreamMockServer, username = defaultXtreamUsername, } = options; await openAddPlaylistDialog(page); const dialog = await getActiveDialog(page); await clickDialogCategoryOption( dialog, /^xtream$/i, 'mat-button-toggle[value="xtream"]' ); await setInputValue(dialog.locator('#title'), name); await setInputValue(dialog.locator('#serverUrl'), serverUrl); await setInputValue(dialog.locator('#username'), username); await setInputValue(dialog.locator('#password'), password); const addButton = dialog .getByRole('button', { name: /^(add|add playlist)$/i }) .last(); await expect(addButton).toBeEnabled({ timeout: 10000 }); await addButton.click(); await dialog.waitFor({ state: 'detached' }); } async function setInputValue(input: Locator, value: string): Promise { await input.fill(''); await input.fill(value); if ((await input.inputValue()) === value) { return; } await input.click(); await input.press('Control+A'); await input.press('Backspace'); await input.type(value); if ((await input.inputValue()) === value) { return; } await input.evaluate((element, nextValue) => { const inputElement = element as HTMLInputElement; inputElement.value = nextValue; inputElement.dispatchEvent(new Event('input', { bubbles: true })); inputElement.dispatchEvent(new Event('change', { bubbles: true })); inputElement.dispatchEvent(new Event('blur', { bubbles: true })); }, value); await expect(input).toHaveValue(value); } async function clickDialogCategoryOption( dialog: Locator, label: RegExp, legacySelector?: string ): Promise { await clickDialogSegmentedOption( dialog, 'Playlist category', label, legacySelector ); } async function clickDialogSubtypeOption( dialog: Locator, label: RegExp, legacySelector?: string ): Promise { await clickDialogSegmentedOption( dialog, 'M3U source', label, legacySelector ); } async function clickDialogSegmentedOption( dialog: Locator, tablistLabel: string, label: RegExp, legacySelector?: string ): Promise { const tablist = dialog .locator(`[role="tablist"][aria-label="${tablistLabel}"]`) .first(); 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; } const optionByButtonRole = dialog .getByRole('button', { name: label }) .first(); if ((await optionByButtonRole.count()) > 0) { await optionByButtonRole.click(); return; } if (!legacySelector) { throw new Error( `Could not find dialog option matching ${label} in "${tablistLabel}".` ); } await dialog.locator(legacySelector).click(); } export async function addStalkerPortal( page: Page, options: { macAddress?: string; name?: string; portalUrl?: string; } = {} ): Promise { const { macAddress = defaultStalkerMacAddress, name = defaultStalkerPortalName, portalUrl = `${stalkerMockServer}/portal.php`, } = options; await openAddPlaylistDialog(page); const dialog = await getActiveDialog(page); await clickDialogCategoryOption( dialog, /^stalker$/i, 'mat-button-toggle[value="stalker"]' ); await setInputValue(dialog.locator('input#title'), name); await setInputValue(dialog.locator('input#portalUrl'), portalUrl); await setInputValue(dialog.locator('input#macAddress'), macAddress); const addButton = dialog .getByRole('button', { name: /^(add|add playlist)$/i }) .last(); await expect(addButton).toBeEnabled({ timeout: 10000 }); await addButton.click(); await dialog.waitFor({ state: 'detached' }); } export async function openSettings(page: Page): Promise { await page.locator('a[href$="/workspace/settings"]').click(); await expect(page.getByTestId('settings-container')).toBeVisible(); } export async function enableRemoteControl( page: Page, port: number ): Promise { const remoteControlCheckbox = page.locator( 'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]' ); await remoteControlCheckbox.scrollIntoViewIfNeeded(); await remoteControlCheckbox.check(); await page.locator('#remoteControlPort').fill(String(port)); } export async function saveSettings(page: Page): Promise { const saveButton = page.getByTestId('save-settings'); await saveButton.click(); await expect(saveButton).toBeDisabled(); await page.waitForTimeout(300); } export async function goToDashboard(page: Page): Promise { const dashboardLink = page .locator('a.brand[href$="/workspace/dashboard"]') .first(); await expect(dashboardLink).toBeVisible(); await dashboardLink.click(); await page.waitForURL(/\/workspace\/dashboard$/); } export async function openSources(page: Page): Promise { await page.getByRole('link', { name: 'Sources', exact: true }).click(); await page.waitForURL(/\/workspace\/sources(?:\?.*)?$/); } export async function restartElectronApp( app: LaunchedElectronApp, dataDir: string, options: LaunchElectronAppOptions = {} ): Promise { await closeElectronApp(app); return launchElectronApp(dataDir, options); } export async function importM3uPlaylistFromUrl( page: Page, playlistUrl: string ): Promise { await openAddPlaylistDialog(page); const dialog = await getActiveDialog(page); await clickDialogCategoryOption(dialog, /^m3u$/i); await clickDialogSubtypeOption( dialog, /add\s+via\s+url/i, 'mat-button-toggle[value="url"]' ); await setInputValue( dialog.locator('input[formcontrolname="playlistUrl"]'), playlistUrl ); await dialog.getByRole('button', { name: /Add playlist/i }).click(); await dialog.waitFor({ state: 'detached' }); } export function buildM3uContent(channels: M3uTestChannel[]): string { const lines = ['#EXTM3U']; for (const channel of channels) { const attributes = [ channel.tvgId ? `tvg-id="${channel.tvgId}"` : '', channel.tvgName ? `tvg-name="${channel.tvgName}"` : '', channel.logo ? `tvg-logo="${channel.logo}"` : '', channel.groupTitle ? `group-title="${channel.groupTitle}"` : '', ] .filter(Boolean) .join(' '); lines.push( `#EXTINF:-1${attributes ? ` ${attributes}` : ''},${channel.name}` ); lines.push(channel.url); } return `${lines.join('\n')}\n`; } export function writeTemporaryM3uFile( dataDir: string, fileName: string, channels: M3uTestChannel[] ): string { const filePath = join(dataDir, fileName); writeFileSync(filePath, buildM3uContent(channels), 'utf8'); return filePath; } export function parseM3uFixture(filePath: string): M3uTestChannel[] { const content = readFileSync(filePath, 'utf8'); const items: M3uTestChannel[] = []; const lines = content.split(/\r?\n/); let pending: | { groupTitle?: string; logo?: string; name: string; tvgId?: string; tvgName?: string; } | undefined; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { continue; } if (line.startsWith('#EXTINF:')) { pending = { groupTitle: line.match(/group-title="([^"]*)"/)?.[1]?.trim() ?? '', logo: line.match(/tvg-logo="([^"]*)"/)?.[1]?.trim() ?? '', name: line.split(',').at(-1)?.trim() ?? '', tvgId: line.match(/tvg-id="([^"]*)"/)?.[1]?.trim() ?? '', tvgName: line.match(/tvg-name="([^"]*)"/)?.[1]?.trim() ?? '', }; continue; } if (!pending || line.startsWith('#')) { continue; } items.push({ ...pending, url: line, }); pending = undefined; } return items.filter((item) => item.name.length > 0); } export async function createMutableTextServer( initialBody: string, options: { contentType?: string; resourcePath?: string; } = {} ): Promise { const { contentType = 'text/plain; charset=utf-8', resourcePath = '/resource.txt', } = options; let body = initialBody; const server = createServer((req, res) => { const pathname = (req.url ?? '').split('?')[0]; if (pathname !== resourcePath) { res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', }); res.end(JSON.stringify({ error: 'Not found' })); return; } res.writeHead(200, { 'Content-Type': contentType }); res.end(body); }); await listenOnRandomPort(server); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Failed to resolve the temporary HTTP server address.'); } const origin = `http://127.0.0.1:${address.port}`; return { close: () => closeServer(server), origin, resourceUrl: `${origin}${resourcePath}`, setBody(nextBody: string) { body = nextBody; }, }; } export async function openWorkspaceSection( page: Page, label: string ): Promise { if (label === 'Favorites') { await openPlaylistFavorites(page); return; } if (label === 'Recently viewed') { await openPlaylistRecent(page); return; } await page.getByRole('link', { name: label, exact: true }).click(); } export async function openPlaylistFavorites(page: Page): Promise { await openGlobalFavorites(page); await switchUnifiedCollectionScope(page, 'This playlist'); } export async function openPlaylistRecent(page: Page): Promise { await openGlobalRecent(page); await switchUnifiedCollectionScope(page, 'This playlist'); } export async function openGlobalFavorites(page: Page): Promise { await openWorkspaceSection(page, 'Global favorites'); await page.waitForURL(/\/workspace\/global-favorites(?:\?.*)?$/); } export async function openGlobalRecent(page: Page): Promise { const dialog = await openCommandPalette(page); await dialog.locator('input[type="search"]').fill('recent'); await dialog.getByRole('button', { name: /Open recently viewed/i }).click(); await page.waitForURL(/\/workspace\/global-recent(?:\?.*)?$/); } export async function switchUnifiedCollectionScope( page: Page, scopeLabel: 'This playlist' | 'All playlists' ): Promise { const toggleGroup = page.locator('.scope-toggle'); await expect(toggleGroup).toBeVisible(); await clickButtonToggleOption(toggleGroup, scopeLabel); } export async function switchUnifiedCollectionContent( page: Page, contentLabel: 'Live TV' | 'Movies' | 'Series' ): Promise { const toggleGroup = page.locator('.content-toggle'); await expect(toggleGroup).toBeVisible(); await clickButtonToggleOption(toggleGroup, contentLabel); } export async function clearCurrentUnifiedCollection( page: Page ): Promise { await page .getByRole('button', { name: /Clear .* (favorites|recently viewed)/i, }) .click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: /^Yes$/i }).click(); await expect(dialog).toBeHidden(); } async function clickButtonToggleOption( toggleGroup: Locator, label: string ): Promise { const toggle = toggleGroup .locator('mat-button-toggle') .filter({ hasText: flexibleTextPattern(label) }) .first(); await expect(toggle).toBeVisible(); if (!(await isButtonToggleSelected(toggle))) { await toggle.click(); } await expect .poll(() => isButtonToggleSelected(toggle), { timeout: 10000, }) .toBe(true); } export function channelItemByTitle(page: Page, title: string): Locator { return page.getByTestId('channel-item').filter({ has: page.locator('.channel-name', { hasText: flexibleTextPattern(title), }), }); } export function contentCardByTitle(page: Page, title: string): Locator { return page.locator('app-content-card').filter({ has: page.locator('h3', { hasText: flexibleTextPattern(title), }), }); } export async function expectVisibleContentCardTitle( page: Page, title: string ): Promise { await expect .poll( async () => { const titles = await visibleContentCardTitles(page); return titles.some( (visibleTitle) => normalizeVisibleText(visibleTitle) === normalizeVisibleText(title) ); }, { timeout: 20000 } ) .toBe(true); } export async function expectPathname( page: Page, pattern: RegExp ): Promise { await expect.poll(() => new URL(page.url()).pathname).toMatch(pattern); } export function playlistSwitcherTitle(page: Page): Locator { return page .locator('.playlist-switcher-trigger .playlist-info .name') .first(); } export function gridListCardByTitle(page: Page, title: string): Locator { return page.locator('.category-content-layout mat-card').filter({ has: page.locator('.title', { hasText: flexibleTextPattern(title), }), }); } /** * Waits for the first grid card to appear (skeleton loading done) and returns * its display title. Use this instead of picking from fixture order, since the * grid sorts by date-desc and may paginate items off the first page. */ export async function waitForFirstGridListCardTitle( page: Page ): Promise { const card = page.locator('.category-content-layout mat-card').first(); await expect(card).toBeVisible({ timeout: 20000 }); return ((await card.locator('.title').textContent()) ?? '').trim(); } /** * Waits for the first grid card to appear with a non-empty title, clicks it, * and returns the title. Use this instead of waitForFirstGridListCardTitle + * clickGridListCardByTitle to avoid a race condition where the grid re-renders * between the title read and the subsequent search-by-title click. */ export async function clickFirstGridListCard(page: Page): Promise { const card = page.locator('.category-content-layout mat-card').first(); await expect(card).toBeVisible({ timeout: 20000 }); const titleEl = card.locator('.title'); let title = ''; await expect(async () => { title = ((await titleEl.textContent()) ?? '').trim(); expect(title.length).toBeGreaterThan(0); }).toPass({ timeout: 10000 }); await card.click(); return title; } export async function clickGridListCardByTitle( page: Page, title: string ): Promise { const card = gridListCardByTitle(page, title).first(); await expect(card).toBeVisible({ timeout: 20000 }); await card.click(); } export async function clickCategoryById( page: Page, categoryId: string ): Promise { const category = page.locator( `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible` ); await expect(category.first()).toBeVisible({ timeout: 20000 }); await category.first().scrollIntoViewIfNeeded(); await category.first().click(); await expect .poll(async () => { const pathname = new URL(page.url()).pathname; const isSelected = (await category.first().getAttribute('aria-current')) === 'true'; return ( isSelected || pathname.endsWith(`/${categoryId}`) || pathname.includes(`/${categoryId}/`) ); }) .toBe(true); } export async function clickCategoryByNameExact( page: Page, categoryName: string ): Promise { const categories = page .locator('app-workspace-context-panel .category-item:visible') .filter({ has: page.locator('.nav-item-label', { hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`), }), }); const category = await pickPreferredCategory(categories); await expect(category).toBeVisible(); await category.scrollIntoViewIfNeeded(); const categoryId = (await category.getAttribute('data-category-id'))?.trim() ?? ''; await category.click(); await expect .poll(async () => { const pathname = new URL(page.url()).pathname; const isSelected = (await category.getAttribute('aria-current')) === 'true'; return ( isSelected || (categoryId.length > 0 && (pathname.endsWith(`/${categoryId}`) || pathname.includes(`/${categoryId}/`))) ); }) .toBe(true); } export function sourceRowByTitle(page: Page, title: string): Locator { return page.locator('app-playlist-item').filter({ hasText: flexibleTextPattern(title), }); } export async function getVisibleSourceTitles(page: Page): Promise { 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) ); } export async function selectSourceTypeFilter( page: Page, typeLabel: 'All' | 'M3U' | 'Xtream' | 'Stalker' ): Promise { await selectSourcesTypeFilterOption(page, typeLabel); } export async function selectSourceSort( page: Page, sortLabel: | 'Date added (Newest first)' | 'Date added (Oldest first)' | 'Name (A-Z)' | 'Name (Z-A)' | 'Custom order' ): Promise { const sortTrigger = page.locator('app-workspace-sources .sort-trigger'); await expect(sortTrigger).toBeVisible(); await sortTrigger.click(); const option = page .locator('.cdk-overlay-pane [role="menuitem"]') .filter({ hasText: flexibleTextPattern(sortLabel), }) .first(); await expect(option).toBeVisible(); await option.click(); await expect(option).toBeHidden(); } export async function dragSourceBefore( page: Page, sourceTitle: string, targetTitle: string ): Promise { const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon'); const target = sourceRowByTitle(page, targetTitle).locator( '.playlist-item' ); await expect(source.first()).toBeVisible(); await expect(target.first()).toBeVisible(); const sourceBox = await source.first().boundingBox(); const targetBox = await target.first().boundingBox(); if (!sourceBox || !targetBox) { throw new Error('Could not resolve source or target bounds for drag.'); } await page.mouse.move( sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2 ); await page.mouse.down(); await page.mouse.move( targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 3, { steps: 15, } ); await page.mouse.up(); } export async function openSourceEditor( page: Page, title: string ): Promise { const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); await row.locator('.edit-btn').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); return dialog; } export async function updateSourceDialog( dialog: Locator, updates: Partial< Record< | 'macAddress' | 'password' | 'portalUrl' | 'serverUrl' | 'stalkerDeviceId1' | 'stalkerDeviceId2' | 'stalkerSerialNumber' | 'stalkerSignature1' | 'stalkerSignature2' | 'title' | 'url' | 'userAgent' | 'username', string > & { autoRefresh: boolean; } > ): Promise { for (const [field, value] of Object.entries(updates)) { if (field === 'autoRefresh' || value == null) { continue; } await setInputValue( dialog.locator(`input[formcontrolname="${field}"]`), value as string ); } if (typeof updates.autoRefresh === 'boolean') { const checkbox = dialog.locator( 'mat-checkbox[formcontrolname="autoRefresh"] input[type="checkbox"]' ); if (updates.autoRefresh) { await checkbox.check(); } else { await checkbox.uncheck(); } } } export async function saveSourceDialog( page: Page, dialog: Locator ): Promise { await dialog.getByRole('button', { name: 'Save', exact: true }).click(); await page.waitForSelector('mat-dialog-container', { state: 'detached' }); await expectPlaylistUpdatedToast(page); } export async function expectSourceDialogValues( dialog: Locator, expected: Partial< Record< | 'macAddress' | 'password' | 'portalUrl' | 'serverUrl' | 'stalkerDeviceId1' | 'stalkerDeviceId2' | 'stalkerSerialNumber' | 'stalkerSignature1' | 'stalkerSignature2' | 'title' | 'url' | 'userAgent' | 'username', string > & { autoRefresh: boolean; } > ): Promise { for (const [field, value] of Object.entries(expected)) { if (field === 'autoRefresh' || value == null) { continue; } await expect( dialog.locator(`input[formcontrolname="${field}"]`) ).toHaveValue(value as string); } if (typeof expected.autoRefresh === 'boolean') { const checkbox = dialog.locator( 'mat-checkbox[formcontrolname="autoRefresh"] input[type="checkbox"]' ); if (expected.autoRefresh) { await expect(checkbox).toBeChecked(); } else { await expect(checkbox).not.toBeChecked(); } } } export async function deleteSource(page: Page, title: string): Promise { const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); await row.locator('.delete-btn').click(); await confirmDialog(page); } export async function refreshSource( page: Page, title: string, options: { confirm?: boolean; } = {} ): Promise { const { confirm = false } = options; const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); await row.locator('.refresh-btn').click(); if (confirm) { await confirmDialog(page); } } export async function waitForSourceRowIdle( page: Page, title: string ): Promise { const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); await expect(row.locator('.busy-state__message')).toHaveCount(0, { timeout: 60000, }); await expect(row.locator('.action-spinner')).toHaveCount(0, { timeout: 60000, }); } export async function waitForM3uCatalog(page: Page): Promise { await page.waitForURL(/\/workspace\/playlists\/.+\/all$/); await expect(page.getByTestId('channel-item').first()).toBeVisible({ timeout: 20000, }); } export async function waitForXtreamCatalog(page: Page): Promise { await page.waitForURL(/\/workspace\/xtreams\/.+/); await waitForXtreamImportToFinish(page); const categories = page.locator( 'app-workspace-context-panel .category-item' ); await expect(categories.first()).toBeVisible({ timeout: 20000 }); const contentItems = page.locator( '.content-card, [data-test-id="channel-item"], mat-card' ); try { await expect(contentItems.first()).toBeVisible({ timeout: 5000 }); return; } catch { const category = await pickPreferredCategory(categories); await category.scrollIntoViewIfNeeded(); await category.click(); await expect(contentItems.first()).toBeVisible({ timeout: 20000 }); } } export async function waitForXtreamWorkspaceReady(page: Page): Promise { await waitForXtreamCatalog(page); } export async function expectPlaylistUpdatedToast(page: Page): Promise { await expect( page .locator('.mat-mdc-snack-bar-label') .filter({ hasText: 'Success! The playlist was successfully updated.', }) .last() ).toBeVisible({ timeout: 20000, }); } export async function waitForXtreamImportToFinish(page: Page): Promise { const overlay = page.locator('.workspace-loading-overlay'); try { await overlay.waitFor({ state: 'visible', timeout: 5000 }); } catch { // The overlay may already be gone by the time the test reaches this point. } await expect(overlay).toHaveCount(0, { timeout: 30000 }); } async function pickPreferredCategory(categories: Locator): Promise { const count = await categories.count(); let fallback: Locator | null = null; for (let index = 0; index < count; index += 1) { const candidate = categories.nth(index); if (!(await candidate.isVisible())) { continue; } fallback ??= candidate; const countText = (await candidate.locator('.item-count').first().textContent()) ?? ''; const itemCount = Number.parseInt(countText.trim(), 10); if (Number.isFinite(itemCount) && itemCount > 0) { return candidate; } } return fallback ?? categories.first(); } export async function waitForStalkerCatalog(page: Page): Promise { await page.waitForURL(/\/workspace\/stalker\/.+/); const categories = page.locator('.category-item'); await expect(categories.first()).toBeVisible({ timeout: 20000 }); await categories.first().click(); const contentItems = page.locator( '.content-card, [data-test-id="channel-item"], mat-card' ); await expect(contentItems.first()).toBeVisible({ timeout: 20000 }); } export async function fillWorkspaceSearch( page: Page, term: string, options: { submit?: boolean; } = {} ): Promise { const input = page.locator( 'app-workspace-shell-header .search-field input[type="search"]' ); await expect(input).toBeEnabled(); await input.fill(term); if (options.submit) { await input.press('Enter'); } } export async function expectWorkspaceSearchScope( page: Page, expected: RegExp | string ): Promise { await expect( page.locator('app-workspace-shell-header .search-chip--scope') ).toHaveText(expected); } export async function expectWorkspaceSearchStatus( page: Page, expected: RegExp | string ): Promise { await expect( page.locator('app-workspace-shell-header .search-chip--status') ).toHaveText(expected); } async function startPortalDebugCapture(page: Page): Promise { await page.evaluate(() => { const electronApi = window.electron as typeof window.electron & { onPortalDebugEvent?: ( callback: (event: PortalDebugEvent) => void ) => (() => void) | undefined; }; window.__portalDebugUnsubscribe?.(); window.__portalDebugEvents = []; if (!electronApi.onPortalDebugEvent) { return; } window.__portalDebugUnsubscribe = electronApi.onPortalDebugEvent( (event: PortalDebugEvent) => { window.__portalDebugEvents?.push(event as PortalDebugEvent); } ); }); } async function startDbOperationCapture(page: Page): Promise { await page.evaluate(() => { const electronApi = window.electron as typeof window.electron & { onDbOperationEvent?: ( callback: (event: DbOperationEvent) => void ) => (() => void) | undefined; }; window.__dbOperationUnsubscribe?.(); window.__dbOperationEvents = []; if (!electronApi.onDbOperationEvent) { return; } window.__dbOperationUnsubscribe = electronApi.onDbOperationEvent( (event: DbOperationEvent) => { window.__dbOperationEvents?.push(event); } ); }); } async function startRendererFrameCapture(page: Page): Promise { await page.evaluate(() => { if (window.__rendererFrameRequestId) { window.cancelAnimationFrame(window.__rendererFrameRequestId); } window.__rendererFrameCount = 0; const tick = () => { window.__rendererFrameCount = (window.__rendererFrameCount ?? 0) + 1; window.__rendererFrameRequestId = window.requestAnimationFrame(tick); }; window.__rendererFrameRequestId = window.requestAnimationFrame(tick); }); } export async function expectPortalDebugSuccess( page: Page, provider: PortalProvider ): Promise { await expect .poll( async () => { return page.evaluate((targetProvider) => { return ( window.__portalDebugEvents?.filter( (event) => event.provider === targetProvider && event.status === 'success' && event.transport === 'electron-main' ).length ?? 0 ); }, provider); }, { timeout: 20000 } ) .toBeGreaterThan(0); } export async function waitForPortalDebugEvent( page: Page, options: { operation: string; predicate?: (event: PortalDebugEvent) => boolean; provider: PortalProvider; timeoutMs?: number; } ): Promise { const { operation, predicate, provider, timeoutMs = 20000 } = options; const startedAt = Date.now(); while (Date.now() - startedAt <= timeoutMs) { const events = await page.evaluate( () => window.__portalDebugEvents ?? [] ); const match = events.find((event) => { if ( event.provider !== provider || event.operation !== operation || event.status !== 'success' || event.transport !== 'electron-main' ) { return false; } return predicate ? predicate(event) : true; }); if (match) { return match; } await page.waitForTimeout(200); } const recentEvents = await page.evaluate(() => (window.__portalDebugEvents ?? []).slice(-10) ); throw new Error( `Timed out waiting for ${provider}:${operation}. Recent events: ${JSON.stringify( recentEvents, null, 2 )}` ); } export async function waitForDbOperationEvent( page: Page, options: { operation: string; operationId?: string; phase?: string; playlistId?: string; predicate?: (event: DbOperationEvent) => boolean; status?: DbOperationEvent['status']; timeoutMs?: number; } ): Promise { const { operation, operationId, phase, playlistId, predicate, status, timeoutMs = 20000, } = options; const startedAt = Date.now(); while (Date.now() - startedAt <= timeoutMs) { const events = await page.evaluate( () => window.__dbOperationEvents ?? [] ); const match = events.find((event) => { if (event.operation !== operation) { return false; } if (operationId && event.operationId !== operationId) { return false; } if (status && event.status !== status) { return false; } if (phase && event.phase !== phase) { return false; } if (playlistId && event.playlistId !== playlistId) { return false; } return predicate ? predicate(event) : true; }); if (match) { return match; } await page.waitForTimeout(200); } const recentEvents = await page.evaluate(() => (window.__dbOperationEvents ?? []).slice(-20) ); throw new Error( `Timed out waiting for DB event ${operation}. Recent events: ${JSON.stringify( recentEvents, null, 2 )}` ); } export async function getRendererFrameCount(page: Page): Promise { return page.evaluate(() => window.__rendererFrameCount ?? 0); } export async function expectRendererFramesAdvance( page: Page, options: { minimumDelta?: number; sampleMs?: number; } = {} ): Promise { const { minimumDelta = 4, sampleMs = 300 } = options; const startCount = await getRendererFrameCount(page); await page.waitForTimeout(sampleMs); await expect .poll(async () => (await getRendererFrameCount(page)) - startCount, { timeout: sampleMs + 1500, }) .toBeGreaterThanOrEqual(minimumDelta); } export async function resetMockServers( request: APIRequestContext, providers: PortalProvider[] ): Promise { for (const provider of providers) { const server = provider === 'stalker' ? stalkerMockServer : xtreamMockServer; const response = await request.post(`${server}/reset`); expect(response.ok()).toBeTruthy(); } } async function openCommandPalette(page: Page): Promise { const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; await page.locator('body').focus(); await page.keyboard.press(`${modifier}+K`); const dialog = page.locator( 'mat-dialog-container app-workspace-command-palette' ); await expect(dialog).toBeVisible(); return dialog; } async function confirmDialog(page: Page, buttonLabel = 'Yes'): Promise { const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); await dialog .getByRole('button', { name: buttonLabel, exact: true }) .click(); await page.waitForSelector('mat-dialog-container', { state: 'detached' }); } async function selectSourcesTypeFilterOption( page: Page, label: string ): Promise { const option = page .locator('app-workspace-sources-filters-panel .option-row') .filter({ has: page.locator('.option-label', { hasText: flexibleTextPattern(label), }), }) .first(); await expect(option).toBeVisible(); await option.click(); } async function listenOnRandomPort(server: Server): Promise { await new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolvePromise(); }); }); } async function closeServer(server: Server): Promise { await new Promise((resolvePromise, reject) => { server.close((error) => { if (error) { reject(error); return; } resolvePromise(); }); }); } function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function flexibleTextPattern(value: string): RegExp { return new RegExp( value .trim() .split(/\s+/) .map((part) => escapeRegex(part)) .join('\\s+'), 'i' ); } async function isButtonToggleSelected(toggle: Locator): Promise { try { return await toggle.evaluate((element) => { const host = element as HTMLElement; const selectedDescendant = host.querySelector( '[aria-checked="true"], [aria-pressed="true"]' ); return ( host.classList.contains('mat-button-toggle-checked') || host.getAttribute('aria-checked') === 'true' || host.getAttribute('aria-pressed') === 'true' || selectedDescendant !== null ); }); } catch { return false; } } async function visibleContentCardTitles(page: Page): Promise { return page .locator('app-content-card h3') .allInnerTexts() .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } function normalizeVisibleText(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); }