mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
fix(xtream): preserve favorites across catalog refreshes
This commit is contained in:
1 parent
ec4b9286a7
commit
8ccfcb68cc
121 files changed
+4000
-398
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickGridListCardByTitle,
|
||||
closeElectronApp,
|
||||
contentCardByTitle,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures';
|
||||
import { readSavedFavorites } from './support/xtream-favorites-catalog';
|
||||
|
||||
test('@critical @persistence @xtream @electron rejects a reused favorite identity until the older saved entry is explicitly removed', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = { username: 'minimal', password: 'minimal' };
|
||||
const fixture = await fetchXtreamVodFixture(request, credentials);
|
||||
const item = fixture.items[0];
|
||||
const title = getXtreamTitle(item);
|
||||
const sourceTitle = 'Favorite identity conflict';
|
||||
const oldTitle = 'Older saved movie';
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
app = await launchElectronApp(dataDir);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...credentials,
|
||||
name: sourceTitle,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
const playlistId = /\/xtreams\/([^/]+)/.exec(
|
||||
new URL(app.mainWindow.url()).pathname
|
||||
)?.[1];
|
||||
if (!playlistId) throw new Error('Expected a playlist route');
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
// Seed an old snapshot and conflicting current metadata via the real
|
||||
// worker bridge. The subsequent save/removal runs through the UI.
|
||||
await app.mainWindow.evaluate(
|
||||
async ({ playlistId, xtreamId, oldTitle }) => {
|
||||
const row = await window.electron.dbGetContentByXtreamId(
|
||||
xtreamId,
|
||||
playlistId,
|
||||
'movie'
|
||||
);
|
||||
if (!row) throw new Error('Expected imported content');
|
||||
const metadata =
|
||||
await window.electron.dbSetContentMetadataIfMissing(
|
||||
row.id,
|
||||
{
|
||||
tmdbId: 12345,
|
||||
releaseYear: 2000,
|
||||
}
|
||||
);
|
||||
if (!metadata.success)
|
||||
throw new Error('Could not set current movie identity');
|
||||
const restored = await window.electron.dbRestoreXtreamFavorites(
|
||||
playlistId,
|
||||
[
|
||||
{
|
||||
xtreamId,
|
||||
contentType: 'movie',
|
||||
addedAt: '2024-01-01',
|
||||
position: 3,
|
||||
snapshot: {
|
||||
title: oldTitle,
|
||||
tmdbId: 99999999,
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
if (!restored.success)
|
||||
throw new Error('Could not seed the older favorite');
|
||||
},
|
||||
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
|
||||
);
|
||||
const [before] = await readSavedFavorites(app.mainWindow, playlistId);
|
||||
expect(before).toMatchObject({ id: null, title: oldTitle });
|
||||
|
||||
await clickGridListCardByTitle(app.mainWindow, title);
|
||||
const addButton = app.mainWindow
|
||||
.getByRole('button', { name: 'Add to favorites', exact: true })
|
||||
.first();
|
||||
await expect(addButton).toBeVisible();
|
||||
await addButton.click();
|
||||
await expect(
|
||||
app.mainWindow.getByText(
|
||||
'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.',
|
||||
{ exact: true }
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(addButton).toBeVisible();
|
||||
expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([
|
||||
before,
|
||||
]);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Global favorites');
|
||||
const oldCard = contentCardByTitle(app.mainWindow, oldTitle);
|
||||
await oldCard
|
||||
.getByRole('button', { name: /remove from favorites/i })
|
||||
.click();
|
||||
await expect(oldCard).toHaveCount(0);
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await clickGridListCardByTitle(app.mainWindow, title);
|
||||
await addButton.click();
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.getByRole('button', {
|
||||
name: 'Remove from favorites',
|
||||
exact: true,
|
||||
})
|
||||
.first()
|
||||
).toBeVisible();
|
||||
const [saved] = await readSavedFavorites(app.mainWindow, playlistId);
|
||||
expect(saved.title).toBe(title);
|
||||
expect(saved.id).not.toBeNull();
|
||||
expect(saved.favorite_id).not.toBe(before.favorite_id);
|
||||
} finally {
|
||||
if (app) await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { XtreamPendingRestoreState } from '@iptvnator/shared/interfaces';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
contentCardByTitle,
|
||||
electronMainPath,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
switchUnifiedCollectionScope,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
workspaceRoot,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
createFavoritesCatalogProxy,
|
||||
readSavedFavorites,
|
||||
savedFavoriteIdentity,
|
||||
} from './support/xtream-favorites-catalog';
|
||||
|
||||
test('@critical @persistence @xtream @electron recovers legacy pending favorites without catalog initialization while the portal is unreachable', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const proxy = await createFavoritesCatalogProxy();
|
||||
const sourceTitle = 'Offline Legacy Favorites';
|
||||
const savedTitle = 'Saved offline movie';
|
||||
const fallbackTitle = 'Saved movie #910002';
|
||||
const legacyState: XtreamPendingRestoreState = {
|
||||
favorites: [
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 910001,
|
||||
addedAt: '2024-06-01T10:00:00.000Z',
|
||||
position: 4,
|
||||
snapshot: { title: savedTitle },
|
||||
},
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 910002,
|
||||
addedAt: '2024-06-02T10:00:00.000Z',
|
||||
position: 5,
|
||||
},
|
||||
],
|
||||
hiddenCategories: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
const serializedLegacyState = JSON.stringify(legacyState);
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
app = await launchElectronApp(dataDir);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: sourceTitle,
|
||||
serverUrl: proxy.origin,
|
||||
username: 'minimal',
|
||||
password: 'minimal',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
const playlistId = /\/xtreams\/([^/]+)/.exec(
|
||||
new URL(app.mainWindow.url()).pathname
|
||||
)?.[1];
|
||||
if (!playlistId)
|
||||
throw new Error('The imported Xtream route has no playlist ID');
|
||||
await openSources(app.mainWindow);
|
||||
proxy.setMode({ kind: 'offline' });
|
||||
const catalogActions = [
|
||||
'get_live_categories',
|
||||
'get_vod_categories',
|
||||
'get_series_categories',
|
||||
'get_live_streams',
|
||||
'get_vod_streams',
|
||||
'get_series',
|
||||
];
|
||||
const catalogRequestsBefore = catalogActions.map((action) =>
|
||||
proxy.responseCount(action)
|
||||
);
|
||||
const accountRequestsBefore = proxy.responseCount('get_account_info');
|
||||
|
||||
// Recreate an older profile: only pending localStorage owns the favorites.
|
||||
// Calling the raw bridge here must not accidentally perform conversion.
|
||||
await app.mainWindow.evaluate(
|
||||
async ({ id, legacy }) => {
|
||||
const deleted = await window.electron.dbDeleteXtreamContent(id);
|
||||
if (!deleted.success)
|
||||
throw new Error('Could not clear the test catalog');
|
||||
localStorage.setItem(`xtream-restore-${id}`, legacy);
|
||||
},
|
||||
{ id: playlistId, legacy: serializedLegacyState }
|
||||
);
|
||||
expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual(
|
||||
[]
|
||||
);
|
||||
|
||||
// Discard the already-loaded renderer store so the next visit must probe
|
||||
// the unavailable account before it can consider initializeContent().
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
await openSources(app.mainWindow);
|
||||
await expect(
|
||||
sourceRowByTitle(app.mainWindow, sourceTitle).first()
|
||||
).toBeVisible();
|
||||
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
|
||||
await expect(
|
||||
app.mainWindow.getByRole('heading', {
|
||||
name: 'Portal unavailable',
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible();
|
||||
expect(proxy.responseCount('get_account_info')).toBeGreaterThan(
|
||||
accountRequestsBefore
|
||||
);
|
||||
expect(
|
||||
catalogActions.map((action) => proxy.responseCount(action))
|
||||
).toEqual(catalogRequestsBefore);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Global favorites');
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
for (const title of [savedTitle, fallbackTitle]) {
|
||||
const card = contentCardByTitle(app.mainWindow, title);
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card).toContainText('Catalog status unknown');
|
||||
await expect(
|
||||
card.locator('.content-card__activation')
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
const converted = await readSavedFavorites(app.mainWindow, playlistId);
|
||||
expect(converted).toHaveLength(2);
|
||||
expect(
|
||||
converted.every(
|
||||
(row) => row.id === null && row.catalog_status === 'unknown'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(savedFavoriteIdentity(converted)).toEqual([
|
||||
{
|
||||
favoriteId: expect.any(Number),
|
||||
xtreamId: 910001,
|
||||
title: savedTitle,
|
||||
addedAt: legacyState.favorites[0].addedAt,
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
favoriteId: expect.any(Number),
|
||||
xtreamId: 910002,
|
||||
title: fallbackTitle,
|
||||
addedAt: legacyState.favorites[1].addedAt,
|
||||
position: 5,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
catalogActions.map((action) => proxy.responseCount(action))
|
||||
).toEqual(catalogRequestsBefore);
|
||||
|
||||
const removedCard = contentCardByTitle(app.mainWindow, savedTitle);
|
||||
await removedCard
|
||||
.getByRole('button', { name: /remove from favorites/i })
|
||||
.click();
|
||||
await expect(removedCard).toHaveCount(0);
|
||||
const retained = savedFavoriteIdentity(
|
||||
converted.filter((row) => row.xtream_id === 910002)
|
||||
);
|
||||
expect(
|
||||
savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, playlistId)
|
||||
)
|
||||
).toEqual(retained);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(
|
||||
(id) => localStorage.getItem(`xtream-restore-${id}`),
|
||||
playlistId
|
||||
)
|
||||
).toBe(serializedLegacyState);
|
||||
|
||||
// The unchanged legacy payload remains eligible for reads after restart;
|
||||
// the worker's durable conversion receipt must prevent resurrection.
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
|
||||
await expect(
|
||||
app.mainWindow.getByRole('heading', {
|
||||
name: 'Portal unavailable',
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible();
|
||||
await openWorkspaceSection(app.mainWindow, 'Global favorites');
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, fallbackTitle)
|
||||
).toContainText('Catalog status unknown');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, savedTitle)
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, playlistId)
|
||||
)
|
||||
).toEqual(retained);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(
|
||||
(id) => localStorage.getItem(`xtream-restore-${id}`),
|
||||
playlistId
|
||||
)
|
||||
).toBe(serializedLegacyState);
|
||||
expect(
|
||||
catalogActions.map((action) => proxy.responseCount(action))
|
||||
).toEqual(catalogRequestsBefore);
|
||||
} finally {
|
||||
try {
|
||||
if (app) await closeElectronApp(app);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('@critical @persistence @xtream @electron upgrades orphaned legacy favorites without blocking startup or losing saved rows', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const schema = await readFile(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'libs/shared/database/src/lib/testing/fixtures/v0.24.0-schema.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const databaseFile = join(dataDir, 'databases/iptvnator.db');
|
||||
const dependency = join(workspaceRoot, 'node_modules/better-sqlite3');
|
||||
const wrapper = join(dataDir, 'legacy-favorites-startup.cjs');
|
||||
const seed = `
|
||||
INSERT INTO playlists (id, name, type) VALUES ('legacy', 'Legacy favorites', 'xtream');
|
||||
INSERT INTO categories (id, playlist_id, name, type, xtream_id)
|
||||
VALUES (1, 'legacy', 'Movies', 'movies', 1);
|
||||
INSERT INTO content (id, category_id, title, xtream_id, type)
|
||||
VALUES (1, 1, 'Legacy saved movie', 7, 'movie');
|
||||
INSERT INTO favorites (id, content_id, playlist_id, added_at, position)
|
||||
VALUES (42, 1, 'legacy', '2024-01-01', 3),
|
||||
(43, 1, 'deleted-playlist', '2024-01-02', 4);`;
|
||||
await mkdir(join(dataDir, 'databases'), { recursive: true });
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`const Database = require(${JSON.stringify(dependency)});
|
||||
const db = new Database(${JSON.stringify(databaseFile)});
|
||||
db.pragma('foreign_keys = OFF');
|
||||
db.exec(${JSON.stringify(schema)});
|
||||
db.exec(${JSON.stringify(seed)});
|
||||
db.close();
|
||||
require(${JSON.stringify(electronMainPath)});`
|
||||
);
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
app = await launchElectronApp(dataDir, { entryPoint: wrapper });
|
||||
for (let startup = 0; startup < 2; startup++) {
|
||||
if (startup) app = await restartElectronApp(app, dataDir);
|
||||
await openWorkspaceSection(app.mainWindow, 'Global favorites');
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, 'Legacy saved movie')
|
||||
).toHaveCount(1);
|
||||
expect(
|
||||
savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, 'legacy')
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
favoriteId: 42,
|
||||
xtreamId: 7,
|
||||
title: 'Legacy saved movie',
|
||||
addedAt: '2024-01-01',
|
||||
position: 3,
|
||||
},
|
||||
]);
|
||||
// Orphaned favorites have no source to display, but must remain in
|
||||
// storage alongside healthy favorites after both startup paths.
|
||||
const rows = await app.electronApp.evaluate(
|
||||
(_electron, { databaseFile, dependency }) => {
|
||||
const Database = process
|
||||
.getBuiltinModule('module')
|
||||
.createRequire(dependency)(dependency);
|
||||
const db = new Database(databaseFile, { readonly: true });
|
||||
try {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, playlist_id, content_id, title, added_at, position FROM favorites ORDER BY id'
|
||||
)
|
||||
.all();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
{ databaseFile, dependency }
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 42,
|
||||
playlist_id: 'legacy',
|
||||
content_id: 1,
|
||||
title: 'Legacy saved movie',
|
||||
added_at: '2024-01-01',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
playlist_id: 'deleted-playlist',
|
||||
content_id: 1,
|
||||
title: 'Legacy saved movie',
|
||||
added_at: '2024-01-02',
|
||||
position: 4,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
if (app) await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
waitForStalkerCatalog,
|
||||
waitForXtreamWorkspaceReady,
|
||||
writeTemporaryM3uFile,
|
||||
type LaunchedElectronApp,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchStalkerCategoryFixture,
|
||||
@@ -48,6 +49,12 @@ import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
import {
|
||||
createFavoritesCatalogProxy,
|
||||
readSavedFavorites,
|
||||
refreshFavoriteCatalog,
|
||||
savedFavoriteIdentity,
|
||||
} from './support/xtream-favorites-catalog';
|
||||
|
||||
test.describe('Electron Favorites', () => {
|
||||
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
|
||||
@@ -430,6 +437,262 @@ test.describe('Electron Favorites', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@critical @persistence @xtream @electron keeps durable favorites through partial refresh, repeated refresh, restart and catalog recovery', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = { username: 'minimal', password: 'minimal' };
|
||||
const fixture = await fetchXtreamVodFixture(request, credentials);
|
||||
const proxy = await createFavoritesCatalogProxy();
|
||||
const portalTitle = 'Durable Partial Favorites';
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
app = await launchElectronApp(dataDir);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...credentials,
|
||||
name: portalTitle,
|
||||
serverUrl: proxy.origin,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
const { playlistId, titles } = await saveTwoMovieFavorites(
|
||||
app.mainWindow,
|
||||
fixture
|
||||
);
|
||||
const original = savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, playlistId)
|
||||
);
|
||||
const missing = original.find((row) => row.title === titles[0]);
|
||||
if (!missing) throw new Error('The newly saved movie is missing');
|
||||
expect(missing.favoriteId).toBeGreaterThan(0);
|
||||
proxy.setMode({ kind: 'omit', xtreamId: missing.xtreamId });
|
||||
|
||||
// Both real refresh entry points must retain the same durable records.
|
||||
for (const entry of ['sources', 'header'] as const) {
|
||||
await refreshFavoriteCatalog(
|
||||
app.mainWindow,
|
||||
proxy,
|
||||
playlistId,
|
||||
portalTitle,
|
||||
entry
|
||||
);
|
||||
const saved = await readSavedFavorites(
|
||||
app.mainWindow,
|
||||
playlistId
|
||||
);
|
||||
expect(savedFavoriteIdentity(saved)).toEqual(original);
|
||||
expect(
|
||||
saved.find((row) => row.favorite_id === missing.favoriteId)
|
||||
).toMatchObject({
|
||||
id: null,
|
||||
title: titles[0],
|
||||
catalog_status: 'missing',
|
||||
});
|
||||
expect(
|
||||
saved.find((row) => row.favorite_id !== missing.favoriteId)
|
||||
).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
catalog_status: 'present',
|
||||
});
|
||||
await expectSavedMovieCards(app.mainWindow, titles);
|
||||
const card = contentCardByTitle(app.mainWindow, titles[0]);
|
||||
await expect(card).toContainText('Not in the current catalog');
|
||||
const activation = card.locator('.content-card__activation');
|
||||
await expect(activation).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
const detailRequests = proxy.responseCount('get_vod_info');
|
||||
await activation.focus();
|
||||
await app.mainWindow.keyboard.press('Enter');
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toHaveCount(0);
|
||||
expect(proxy.responseCount('get_vod_info')).toBe(
|
||||
detailRequests
|
||||
);
|
||||
await switchUnifiedCollectionScope(
|
||||
app.mainWindow,
|
||||
'All playlists'
|
||||
);
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, titles[0])
|
||||
).toContainText('Not in the current catalog');
|
||||
}
|
||||
|
||||
// The provider still omits the movie while a new Electron process opens.
|
||||
app = await restartElectronApp(app, dataDir);
|
||||
await openSources(app.mainWindow);
|
||||
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await expectSavedMovieCards(app.mainWindow, titles);
|
||||
expect(
|
||||
savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, playlistId)
|
||||
)
|
||||
).toEqual(original);
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, titles[0])
|
||||
).toContainText('Not in the current catalog');
|
||||
|
||||
proxy.setMode({ kind: 'normal' });
|
||||
await refreshFavoriteCatalog(
|
||||
app.mainWindow,
|
||||
proxy,
|
||||
playlistId,
|
||||
portalTitle,
|
||||
'sources'
|
||||
);
|
||||
const recovered = await readSavedFavorites(
|
||||
app.mainWindow,
|
||||
playlistId
|
||||
);
|
||||
expect(savedFavoriteIdentity(recovered)).toEqual(original);
|
||||
expect(recovered).toHaveLength(2);
|
||||
expect(
|
||||
recovered.every(
|
||||
(row) => row.id != null && row.catalog_status === 'present'
|
||||
)
|
||||
).toBe(true);
|
||||
await expectSavedMovieCards(app.mainWindow, titles);
|
||||
const recoveredCard = contentCardByTitle(app.mainWindow, titles[0]);
|
||||
await expect(
|
||||
recoveredCard.locator('.content-card__activation')
|
||||
).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await recoveredCard.click();
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(titles[0]);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.getByRole('button', { name: /remove from favorites/i })
|
||||
.first()
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
try {
|
||||
if (app) await closeElectronApp(app);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('@persistence @xtream @electron distinguishes an empty catalog from HTTP failure and keeps explicit favorite removal after recovery', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const credentials = { username: 'minimal', password: 'minimal' };
|
||||
const fixture = await fetchXtreamVodFixture(request, credentials);
|
||||
const proxy = await createFavoritesCatalogProxy();
|
||||
const portalTitle = 'Durable Empty Favorites';
|
||||
let app: LaunchedElectronApp | undefined;
|
||||
try {
|
||||
app = await launchElectronApp(dataDir);
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...credentials,
|
||||
name: portalTitle,
|
||||
serverUrl: proxy.origin,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
const { playlistId, titles } = await saveTwoMovieFavorites(
|
||||
app.mainWindow,
|
||||
fixture
|
||||
);
|
||||
const original = savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, playlistId)
|
||||
);
|
||||
|
||||
for (const kind of ['empty', 'failure'] as const) {
|
||||
proxy.setMode({ kind });
|
||||
await refreshFavoriteCatalog(
|
||||
app.mainWindow,
|
||||
proxy,
|
||||
playlistId,
|
||||
portalTitle,
|
||||
'sources',
|
||||
kind === 'empty' ? 'completed' : 'failed'
|
||||
);
|
||||
const saved = await readSavedFavorites(
|
||||
app.mainWindow,
|
||||
playlistId
|
||||
);
|
||||
expect(savedFavoriteIdentity(saved)).toEqual(original);
|
||||
expect(
|
||||
saved.every(
|
||||
(row) =>
|
||||
row.id === null &&
|
||||
row.catalog_status ===
|
||||
(kind === 'empty' ? 'missing' : 'unknown')
|
||||
)
|
||||
).toBe(true);
|
||||
await expectSavedMovieCards(app.mainWindow, titles);
|
||||
for (const title of titles) {
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, title)
|
||||
).toContainText(
|
||||
kind === 'empty'
|
||||
? 'Not in the current catalog'
|
||||
: 'Catalog status unknown'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const removedCard = contentCardByTitle(app.mainWindow, titles[0]);
|
||||
await removedCard
|
||||
.getByRole('button', { name: /remove from favorites/i })
|
||||
.click();
|
||||
await expect(removedCard).toHaveCount(0);
|
||||
const retained = original.filter((row) => row.title !== titles[0]);
|
||||
expect(
|
||||
savedFavoriteIdentity(
|
||||
await readSavedFavorites(app.mainWindow, playlistId)
|
||||
)
|
||||
).toEqual(retained);
|
||||
|
||||
proxy.setMode({ kind: 'normal' });
|
||||
// Replaying a stale refresh snapshot must never resurrect an explicit removal.
|
||||
for (let refresh = 0; refresh < 2; refresh++) {
|
||||
await refreshFavoriteCatalog(
|
||||
app.mainWindow,
|
||||
proxy,
|
||||
playlistId,
|
||||
portalTitle,
|
||||
'sources'
|
||||
);
|
||||
const saved = await readSavedFavorites(
|
||||
app.mainWindow,
|
||||
playlistId
|
||||
);
|
||||
expect(savedFavoriteIdentity(saved)).toEqual(retained);
|
||||
expect(saved[0]).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
catalog_status: 'present',
|
||||
});
|
||||
await expectSavedMovieCards(app.mainWindow, [titles[1]]);
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, titles[0])
|
||||
).toHaveCount(0);
|
||||
await switchUnifiedCollectionScope(
|
||||
app.mainWindow,
|
||||
'All playlists'
|
||||
);
|
||||
await expect(
|
||||
contentCardByTitle(app.mainWindow, titles[0])
|
||||
).toHaveCount(0);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, titles[1]);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (app) await closeElectronApp(app);
|
||||
} finally {
|
||||
await proxy.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({
|
||||
dataDir,
|
||||
request,
|
||||
@@ -740,6 +1003,44 @@ const xtreamCredentials = {
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function saveTwoMovieFavorites(
|
||||
page: Page,
|
||||
fixture: Awaited<ReturnType<typeof fetchXtreamVodFixture>>
|
||||
): Promise<{ playlistId: string; titles: [string, string] }> {
|
||||
const playlistId = /\/xtreams\/([^/]+)/.exec(
|
||||
new URL(page.url()).pathname
|
||||
)?.[1];
|
||||
if (!playlistId)
|
||||
throw new Error('The imported Xtream route has no playlist ID');
|
||||
await page.getByRole('link', { name: 'Movies', exact: true }).click();
|
||||
await clickCategoryByNameExact(page, fixture.categoryName);
|
||||
const first = await clickFirstGridListCard(page);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
await goBackFromDetail(page);
|
||||
const second = fixture.items
|
||||
.map(getXtreamTitle)
|
||||
.find((title) => title && title !== first);
|
||||
if (!second) throw new Error('The movie fixture needs two distinct titles');
|
||||
await clickGridListCardByTitle(page, second);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
await goBackFromDetail(page);
|
||||
await expect
|
||||
.poll(async () => (await readSavedFavorites(page, playlistId)).length)
|
||||
.toBe(2);
|
||||
return { playlistId, titles: [first, second] };
|
||||
}
|
||||
|
||||
async function expectSavedMovieCards(
|
||||
page: Page,
|
||||
titles: readonly string[]
|
||||
): Promise<void> {
|
||||
await openPlaylistFavorites(page);
|
||||
for (const title of titles) {
|
||||
await expectVisibleContentCardTitle(page, title);
|
||||
await expect(contentCardByTitle(page, title)).toHaveCount(1);
|
||||
}
|
||||
}
|
||||
|
||||
// By accessible name, not class: the Xtream movie detail's favorite control is
|
||||
// an icon-only button that carries its label in aria-label, while series and
|
||||
// Stalker details still use the labeled variant. This matches both.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createServer } from 'node:http';
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { ElectronBridgeXtreamFavorite } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
expect,
|
||||
openSources,
|
||||
refreshSource,
|
||||
sourceRowByTitle,
|
||||
waitForXtreamWorkspaceReady,
|
||||
xtreamMockServer,
|
||||
} from '../electron-test-fixtures';
|
||||
|
||||
export type FavoritesCatalogMode =
|
||||
| { kind: 'normal' | 'empty' | 'failure' | 'offline' }
|
||||
| { kind: 'omit'; xtreamId: number };
|
||||
|
||||
export interface FavoritesCatalogProxy {
|
||||
origin: string;
|
||||
setMode(mode: FavoritesCatalogMode): void;
|
||||
responseCount(action: string): number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Main-process HTTP cannot be intercepted by Playwright's page.route(). */
|
||||
export async function createFavoritesCatalogProxy(): Promise<FavoritesCatalogProxy> {
|
||||
let mode: FavoritesCatalogMode = { kind: 'normal' };
|
||||
const responses = new Map<string, number>();
|
||||
const pending = new Set<AbortController>();
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? '/', xtreamMockServer);
|
||||
const action = url.searchParams.get('action') ?? 'get_account_info';
|
||||
const requestMode = mode;
|
||||
response.once('finish', () => {
|
||||
responses.set(action, (responses.get(action) ?? 0) + 1);
|
||||
});
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
if (
|
||||
requestMode.kind === 'offline' ||
|
||||
(action === 'get_vod_streams' && requestMode.kind === 'failure')
|
||||
) {
|
||||
response.writeHead(503).end('{"error":"fixture-unavailable"}');
|
||||
return;
|
||||
}
|
||||
if (action === 'get_vod_streams' && requestMode.kind === 'empty') {
|
||||
response.end('[]');
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
pending.add(controller);
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
void (async () => {
|
||||
try {
|
||||
const upstream = await fetch(url, {
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
let body = await upstream.text();
|
||||
if (
|
||||
action === 'get_vod_streams' &&
|
||||
requestMode.kind === 'omit'
|
||||
) {
|
||||
const movies = JSON.parse(body) as {
|
||||
stream_id: number | string;
|
||||
}[];
|
||||
body = JSON.stringify(
|
||||
movies.filter(
|
||||
(movie) =>
|
||||
Number(movie.stream_id) !== requestMode.xtreamId
|
||||
)
|
||||
);
|
||||
}
|
||||
response.writeHead(upstream.status).end(body);
|
||||
} catch {
|
||||
if (!response.destroyed) {
|
||||
response
|
||||
.writeHead(502)
|
||||
.end('{"error":"fixture-upstream-failed"}');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
pending.delete(controller);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Favorites fixture did not bind a TCP port');
|
||||
}
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
setMode: (next) => {
|
||||
mode = next;
|
||||
},
|
||||
responseCount: (action) => responses.get(action) ?? 0,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
pending.forEach((controller) => controller.abort());
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readSavedFavorites(page: Page, playlistId: string) {
|
||||
return page.evaluate(
|
||||
(id) => window.electron.dbGetFavorites(id),
|
||||
playlistId
|
||||
);
|
||||
}
|
||||
|
||||
/** Exclude the replaceable catalog link while retaining every user-owned field. */
|
||||
export function savedFavoriteIdentity(rows: ElectronBridgeXtreamFavorite[]) {
|
||||
return rows.map((row) => ({
|
||||
favoriteId: row.favorite_id,
|
||||
xtreamId: row.xtream_id,
|
||||
title: row.title,
|
||||
addedAt: row.added_at,
|
||||
position: row.position,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function refreshFavoriteCatalog(
|
||||
page: Page,
|
||||
proxy: FavoritesCatalogProxy,
|
||||
playlistId: string,
|
||||
title: string,
|
||||
entry: 'sources' | 'header',
|
||||
expectedImportState: 'completed' | 'failed' = 'completed'
|
||||
): Promise<void> {
|
||||
await openSources(page);
|
||||
if (entry === 'header') {
|
||||
await sourceRowByTitle(page, title).first().click();
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
}
|
||||
const requestsBefore = proxy.responseCount('get_vod_streams');
|
||||
if (entry === 'sources') {
|
||||
await refreshSource(page, title, { confirm: true });
|
||||
} else {
|
||||
await page
|
||||
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
||||
.click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Refresh playlist', exact: true })
|
||||
.click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
}
|
||||
// A completed status from the previous generation cannot satisfy this refresh.
|
||||
await expect
|
||||
.poll(() => proxy.responseCount('get_vod_streams'), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(requestsBefore);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
(id) =>
|
||||
window.electron.dbGetAppState(
|
||||
`xtream-import-status:${id}:movie`
|
||||
),
|
||||
playlistId
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(expectedImportState);
|
||||
await expect(page.locator('.workspace-loading-overlay')).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user