diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index f4fe0cb3f..bc8f0e6d1 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -33,6 +33,10 @@ import { type ElectronExitConfirmationOptions, prepareElectronApplication, } from './electron-process-lifecycle'; +import { + waitForXtreamImportCompleted, + xtreamPlaylistIdFromUrl, +} from './xtream-import-wait'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -1769,7 +1773,13 @@ export async function waitForXtreamImportToFinish(page: Page): Promise { try { await overlay.waitFor({ state: 'visible', timeout: 5000 }); } catch { - // The overlay may already be gone by the time the test reaches this point. + // The overlay may already be gone, or not shown yet: it appears only + // once the portal has answered, so the import status decides below. + } + + const playlistId = xtreamPlaylistIdFromUrl(page.url()); + if (playlistId) { + await waitForXtreamImportCompleted(page, playlistId); } await expect(overlay).toHaveCount(0, { timeout: 30000 }); diff --git a/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts b/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts new file mode 100644 index 000000000..3397e6fff --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts @@ -0,0 +1,174 @@ +import { createServer, request as forwardRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSources, + refreshSource, + resetMockServers, + test, + waitForXtreamCatalog, + xtreamMockServer, +} from './electron-test-fixtures'; +import { + readXtreamImportState, + xtreamPlaylistIdFromUrl, +} from './xtream-import-wait'; + +/** + * Longer than the 5 s the catalog wait gives the loading overlay to appear. + * The overlay appears only once the account check has answered, which a mock + * generating a large catalog on its first request can take this long to do. + */ +const ACCOUNT_INFO_DELAY_MS = 7_000; +/** Keeps series importing after the movie cards are already visible. */ +const SERIES_LIST_DELAY_MS = 3_000; + +interface DelayingProxy { + readonly origin: string; + /** Holds the account check and the series list from now on. */ + slowDown(): void; + close(): Promise; +} + +function portalDelay(url: URL): number { + if (!url.pathname.endsWith('/player_api.php')) { + return 0; + } + const action = url.searchParams.get('action'); + if (action === null || action === 'get_account_info') { + return ACCOUNT_INFO_DELAY_MS; + } + return action === 'get_series' ? SERIES_LIST_DELAY_MS : 0; +} + +/** Forwards GET requests to `target`, delayed by `portalDelay` once slowed. */ +async function startDelayingProxy( + target: string, + slow: boolean +): Promise { + const upstream = new URL(target); + let delaying = slow; + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://proxy.invalid'); + setTimeout( + () => { + const forwarded = forwardRequest( + { + headers: { ...request.headers, host: upstream.host }, + hostname: upstream.hostname, + method: request.method, + path: request.url, + port: upstream.port, + }, + (answer) => { + response.writeHead( + answer.statusCode ?? 502, + answer.headers + ); + answer.pipe(response); + } + ); + forwarded.on('error', () => response.destroy()); + forwarded.end(); + }, + delaying ? portalDelay(url) : 0 + ); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + slowDown: () => { + delaying = true; + }, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} + +/** + * Closing the app right after the wait must not interrupt the import: the next + * launch reuses a type only when its status reads `completed` and rows exist, + * and global search finds nothing of a type whose rows are missing. + */ +async function expectCatalogComplete(page: Page): Promise { + const playlistId = xtreamPlaylistIdFromUrl(page.url()); + expect(playlistId).not.toBeNull(); + const imported = { + status: 'completed', + hasCategories: true, + hasContent: true, + }; + expect(await readXtreamImportState(page, playlistId as string)).toEqual({ + live: imported, + movie: imported, + series: imported, + }); +} + +test.describe('Electron Xtream catalog wait', () => { + test('@xtream @electron waits for every type when the portal answers after the overlay window', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const proxy = await startDelayingProxy(xtreamMockServer, true); + try { + const app = await launchElectronApp(dataDir); + try { + await addXtreamPortal(app.mainWindow, { + name: 'Slow account check portal', + serverUrl: proxy.origin, + }); + await waitForXtreamCatalog(app.mainWindow); + await expectCatalogComplete(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } finally { + await proxy.close(); + } + }); + + test('@xtream @electron waits for the re-import after a refresh, whose statuses still read completed', async ({ + dataDir, + request, + }) => { + test.setTimeout(150_000); + await resetMockServers(request, ['xtream']); + const proxy = await startDelayingProxy(xtreamMockServer, false); + try { + const app = await launchElectronApp(dataDir); + try { + const title = 'Slow refresh portal'; + await addXtreamPortal(app.mainWindow, { + name: title, + serverUrl: proxy.origin, + }); + await waitForXtreamCatalog(app.mainWindow); + + // A refresh deletes the catalog and re-imports it, leaving + // the three statuses at `completed` from the first import. + proxy.slowDown(); + await openSources(app.mainWindow); + await refreshSource(app.mainWindow, title, { confirm: true }); + await waitForXtreamCatalog(app.mainWindow); + await expectCatalogComplete(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } finally { + await proxy.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/xtream-import-wait.ts b/apps/electron-backend-e2e/src/xtream-import-wait.ts new file mode 100644 index 000000000..9bcf78d69 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-import-wait.ts @@ -0,0 +1,117 @@ +import type { Page } from '@playwright/test'; + +/** Content types an Xtream import persists, in the app's status keys. */ +export const XTREAM_IMPORT_TYPES = ['live', 'movie', 'series'] as const; + +export type XtreamImportType = (typeof XTREAM_IMPORT_TYPES)[number]; + +/** What the database holds for one content type of a portal. */ +export interface XtreamImportTypeState { + /** `xtream-import-status::` in `app_state`. */ + readonly status: string | null; + readonly hasCategories: boolean; + readonly hasContent: boolean; +} + +export type XtreamImportState = Record; + +const XTREAM_ROUTE_PLAYLIST_ID = /\/workspace\/xtreams\/([^/?#]+)/; +/** Category types use the plural `movies`; content and status use `movie`. */ +const CATEGORY_TYPE: Record = { + live: 'live', + movie: 'movies', + series: 'series', +}; +const POLL_MS = 100; +const DEFAULT_TIMEOUT_MS = 60_000; + +/** The portal id of an `/workspace/xtreams//…` URL, or null. */ +export function xtreamPlaylistIdFromUrl(url: string): string | null { + const match = XTREAM_ROUTE_PLAYLIST_ID.exec(url); + return match ? decodeURIComponent(match[1]) : null; +} + +/** Status, category and content presence of every type, read through the bridge. */ +export async function readXtreamImportState( + page: Page, + playlistId: string +): Promise { + return page.evaluate( + async ({ id, types, categoryType }) => { + const entries = await Promise.all( + types.map(async (type) => [ + type, + { + status: await window.electron.dbGetAppState( + `xtream-import-status:${id}:${type}` + ), + hasCategories: await window.electron.dbHasCategories( + id, + categoryType[type] + ), + hasContent: await window.electron.dbHasContent( + id, + type + ), + }, + ]) + ); + return Object.fromEntries(entries); + }, + { + id: playlistId, + types: [...XTREAM_IMPORT_TYPES], + categoryType: CATEGORY_TYPE, + } + ) as Promise; +} + +/** + * Whether the portal's catalog is complete, judged the way the app judges it + * on its next start: it reuses a type only when the status reads `completed` + * and rows exist. A type without categories is empty on the portal (a + * live-only panel), so its status alone counts; but a portal without any + * category has not been imported yet, or a refresh has just deleted it. The + * status alone is not enough either: a refresh deletes the catalog and + * re-imports it without resetting the status. + */ +export function isXtreamImportComplete(state: XtreamImportState): boolean { + const types = XTREAM_IMPORT_TYPES.map((type) => state[type]); + return ( + types.some(({ hasCategories }) => hasCategories) && + types.every( + ({ status, hasCategories, hasContent }) => + status === 'completed' && (hasContent || !hasCategories) + ) + ); +} + +/** + * Waits until the portal's catalog is complete (`isXtreamImportComplete`). + * + * The loading overlay alone cannot prove that: it appears only once the + * portal has answered the account check and the import session starts. When + * that answer takes longer than the caller waits for the overlay (a mock + * generating a large catalog on its first request does), a check for "no + * overlay" passes before anything was imported, and closing the app then + * interrupts the import. A `cancelled` or `failed` status does not end the + * wait: the app writes no status when a retry starts, so it may be left over + * from an earlier attempt that is being retried now. + */ +export async function waitForXtreamImportCompleted( + page: Page, + playlistId: string, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + let state = await readXtreamImportState(page, playlistId); + while (!isXtreamImportComplete(state)) { + if (Date.now() >= deadline) { + throw new Error( + `xtream-import-not-completed: ${JSON.stringify(state)}` + ); + } + await page.waitForTimeout(POLL_MS); + state = await readXtreamImportState(page, playlistId); + } +} diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 12d9c575b..655482899 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1048,9 +1048,24 @@ was unchanged from passing launches (the same 119 statements before the first key, the usual 2 for the query, none afterwards), and every failure traced was the first launch after seeding. The journey fails such an iteration instead of measuring it, so a full run occasionally fails J4. -The cause is in the app, not the harness, and is not fixed here. No J4 -baseline exists yet; J4 counters join the ratchet once three runner runs -agree. + +The cause was the seed, not the query. The portal's import starts only +after its account check answers, and a freshly started mock generates the +`large` catalog on that first request (up to about 10 s on a busy machine). +`waitForXtreamImportToFinish` gave the loading overlay 5 s to appear and +then accepted "no overlay" as finished, so the seed closed the app while +the series import was still running. The template then held 4,000 live +channels and 4,000 movies but no series, and `system` matches only series. +A run stops at its first failed iteration, which is why the failure always +showed at the first launch after seeding. The wait now also requires the +catalog to be complete the way the app judges it on its next start +(`apps/electron-backend-e2e/src/xtream-import-wait.ts`): every type's +persisted status (`xtream-import-status::`) reads `completed` and +the type has rows, unless the portal has no categories of that type. Rows +are required because a refresh deletes the catalog without resetting the +status. `xtream-import-wait.e2e.ts` holds the account check past the overlay +window and fails without it. No J4 baseline exists yet; J4 counters join the +ratchet once three runner runs agree. ## `renderer.initialBytes`