mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
test(e2e): wait for the persisted Xtream import status, not only the overlay (#1897)
* test(e2e): wait for the persisted Xtream import status, not only the overlay waitForXtreamImportToFinish gave the loading overlay 5 s to appear and then treated "no overlay" as finished. The overlay appears only once the portal's account check answers; a freshly started mock generates the large catalog on that first request, so J4's seed sometimes closed the app while the series import was still running. The template then had no series rows, and J4's query for "system" (series titles only) returned an empty list on the first launch after seeding. On an Xtream route the wait now also requires the import status the app persists to read completed for live, movie and series. The new xtream-import-wait.e2e.ts holds get_account_info past the overlay window and fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): judge the Xtream catalog complete the way the app does Review follow-ups on #1897: - A refresh deletes the catalog and re-imports it without resetting the persisted import statuses, so `completed` alone proves nothing after one. A type now counts as imported when its status reads completed and it has rows, the same rule the app uses before reusing its cache; a type without categories (a live-only panel) counts by status, and a portal without any category is not imported yet. A second spec refreshes a portal behind the slowed account check. Today the refresh keeps its preparation overlay up across that check, so it passes with the earlier wait as well; it guards the helper's contract. - The proxy is closed in its own finally, so a failed launch or close no longer leaves it listening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+320
-4
No files matched your search
@@ -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<void> {
|
||||
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 });
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<DelayingProxy> {
|
||||
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<void>((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<void>((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<void> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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:<playlistId>:<type>` in `app_state`. */
|
||||
readonly status: string | null;
|
||||
readonly hasCategories: boolean;
|
||||
readonly hasContent: boolean;
|
||||
}
|
||||
|
||||
export type XtreamImportState = Record<XtreamImportType, XtreamImportTypeState>;
|
||||
|
||||
const XTREAM_ROUTE_PLAYLIST_ID = /\/workspace\/xtreams\/([^/?#]+)/;
|
||||
/** Category types use the plural `movies`; content and status use `movie`. */
|
||||
const CATEGORY_TYPE: Record<XtreamImportType, string> = {
|
||||
live: 'live',
|
||||
movie: 'movies',
|
||||
series: 'series',
|
||||
};
|
||||
const POLL_MS = 100;
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** The portal id of an `/workspace/xtreams/<id>/…` 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<XtreamImportState> {
|
||||
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<XtreamImportState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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:<id>:<type>`) 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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user