Files
KiraTV---App-Linux/apps/electron-backend-e2e/src/electron-test-fixtures.ts
T
2026-05-03 23:43:09 +02:00

1645 lines
46 KiB
TypeScript

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<void>;
origin: string;
resourceUrl: string;
setBody: (body: string) => void;
};
type ElectronFixtures = {
dataDir: string;
};
type LaunchElectronAppOptions = {
env?: Record<string, string | undefined>;
};
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<ElectronFixtures>({
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<LaunchedElectronApp> {
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<void> {
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(/<base\s+href="([^"]*)"/i);
const baseHref = baseHrefMatch?.[1] ?? '<missing>';
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<Page> {
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<void> {
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') ?? '<missing>',
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<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
await expect(page.locator('mat-dialog-container').last()).toBeVisible();
}
async function getActiveDialog(page: Page): Promise<Locator> {
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
return dialog;
}
export async function stubNativePlaylistFileDialog(
electronApp: ElectronApplication,
filePath: string
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await clickDialogSegmentedOption(
dialog,
'Playlist category',
label,
legacySelector
);
}
async function clickDialogSubtypeOption(
dialog: Locator,
label: RegExp,
legacySelector?: string
): Promise<void> {
await clickDialogSegmentedOption(
dialog,
'M3U source',
label,
legacySelector
);
}
async function clickDialogSegmentedOption(
dialog: Locator,
tablistLabel: string,
label: RegExp,
legacySelector?: string
): Promise<void> {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
.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<void> {
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<void> {
await page.locator('a[href$="/workspace/settings"]').click();
await expect(page.getByTestId('settings-container')).toBeVisible();
}
export async function enableRemoteControl(
page: Page,
port: number
): Promise<void> {
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<void> {
const saveButton = page.getByTestId('save-settings');
await saveButton.click();
await expect(saveButton).toBeDisabled();
await page.waitForTimeout(300);
}
export async function goToDashboard(page: Page): Promise<void> {
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<void> {
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<LaunchedElectronApp> {
await closeElectronApp(app);
return launchElectronApp(dataDir, options);
}
export async function importM3uPlaylistFromUrl(
page: Page,
playlistUrl: string
): Promise<void> {
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<MutableTextServer> {
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<void> {
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<void> {
await openGlobalFavorites(page);
await switchUnifiedCollectionScope(page, 'This playlist');
}
export async function openPlaylistRecent(page: Page): Promise<void> {
await openGlobalRecent(page);
await switchUnifiedCollectionScope(page, 'This playlist');
}
export async function openGlobalFavorites(page: Page): Promise<void> {
await openWorkspaceSection(page, 'Global favorites');
await page.waitForURL(/\/workspace\/global-favorites(?:\?.*)?$/);
}
export async function openGlobalRecent(page: Page): Promise<void> {
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<void> {
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<void> {
const toggleGroup = page.locator('.content-toggle');
await expect(toggleGroup).toBeVisible();
await clickButtonToggleOption(toggleGroup, contentLabel);
}
export async function clearCurrentUnifiedCollection(
page: Page
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
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<void> {
const card = gridListCardByTitle(page, title).first();
await expect(card).toBeVisible({ timeout: 20000 });
await card.click();
}
export async function clickCategoryById(
page: Page,
categoryId: string
): Promise<void> {
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<void> {
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<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)
);
}
export async function selectSourceTypeFilter(
page: Page,
typeLabel: 'All' | 'M3U' | 'Xtream' | 'Stalker'
): Promise<void> {
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<void> {
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<void> {
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<Locator> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await expect(page.getByTestId('channel-item').first()).toBeVisible({
timeout: 20000,
});
}
export async function waitForXtreamCatalog(page: Page): Promise<void> {
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<void> {
await waitForXtreamCatalog(page);
}
export async function expectPlaylistUpdatedToast(page: Page): Promise<void> {
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<void> {
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<Locator> {
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<void> {
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<void> {
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<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--scope')
).toHaveText(expected);
}
export async function expectWorkspaceSearchStatus(
page: Page,
expected: RegExp | string
): Promise<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--status')
).toHaveText(expected);
}
async function startPortalDebugCapture(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
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<PortalDebugEvent> {
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<DbOperationEvent> {
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<number> {
return page.evaluate(() => window.__rendererFrameCount ?? 0);
}
export async function expectRendererFramesAdvance(
page: Page,
options: {
minimumDelta?: number;
sampleMs?: number;
} = {}
): Promise<void> {
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<void> {
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<Locator> {
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<void> {
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<void> {
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<void> {
await new Promise<void>((resolvePromise, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolvePromise();
});
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((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<boolean> {
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<string[]> {
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();
}