Files
iptvnator/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts
T
31c2cb3bbc test(e2e): read sidebar categories in one snapshot inside polls (#1728)
The "allows restoring live categories after every category is hidden"
Electron test failed intermittently on shard 1 across unrelated PRs and
master. The app hid every category correctly (the failure screenshot
shows "No categories available"), but readVisibleSidebarCategoryNames
looped over count() with per-row nth(index) reads. When the sidebar
removed a row between isVisible() and textContent(), textContent()
auto-waited for the missing element, so the expect.poll predicate never
returned and the poll timed out with "waiting on the predicate" instead
of retrying.

Read the visible rows with a single evaluateAll() snapshot in a shared
sidebar-categories.e2e-support.ts helper, use it for the category
picker in category-management and backup-roundtrip too, and record the
rule in the validation map.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:55:58 +02:00

268 lines
10 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
deleteSource,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { readVisibleSidebarCategories } from './sidebar-categories.e2e-support';
/**
* Full backup round-trip through the real UI, DB worker and IPC stack:
* hide a category, export the backup, delete the source, import the file
* back and verify the restored portal hides the same category again after
* its content is re-imported from the mock server (regression for #1017 —
* exported hidden categories lost their xtream IDs and the restore either
* hid everything or nothing).
*/
test.describe('Electron playlist backup round-trip', () => {
test('exports a backup and re-imports it with hidden categories restored', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Backup Roundtrip Xtream';
const exportPath = join(dataDir, 'roundtrip-backup.json');
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, { name: portalName });
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickVisibleCategoryWithContent(
app.mainWindow
);
let dialog = await openManageCategoriesDialog(app.mainWindow);
await setManagedCategoryChecked(dialog, targetCategory.name, false);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
// Export through the real settings flow with the native save
// dialog stubbed to a fixed path inside the test data dir.
await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => {
nativeDialog.showSaveDialog = async () => ({
canceled: false,
filePath: path,
});
}, exportPath);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'backup');
const backupSection = app.mainWindow.locator('#backup');
await backupSection
.getByRole('button', { name: 'Export', exact: true })
.click();
await expect(
app.mainWindow.getByText('Playlist backup exported.')
).toBeVisible({ timeout: 15000 });
// The exported manifest must reference hidden categories by
// numeric xtream ID — the #1017 regression exported anonymous
// { categoryType } entries.
const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as {
playlists: Array<{
portalType: string;
userState?: {
hiddenCategories?: Array<{
categoryType?: string;
xtreamId?: unknown;
}>;
};
}>;
};
const xtreamEntry = manifest.playlists.find(
(entry) => entry.portalType === 'xtream'
);
const hiddenCategories =
xtreamEntry?.userState?.hiddenCategories ?? [];
expect(hiddenCategories.length).toBeGreaterThan(0);
expect(
hiddenCategories.every(
(hiddenCategory) =>
typeof hiddenCategory.xtreamId === 'number'
)
).toBe(true);
await openSources(app.mainWindow);
await deleteSource(app.mainWindow, portalName);
await expect(
sourceRowByTitle(app.mainWindow, portalName)
).toHaveCount(0);
// Import the exported file back through the settings flow; the
// renderer opens a browser file chooser for it.
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'backup');
const fileChooserPromise =
app.mainWindow.waitForEvent('filechooser');
await backupSection
.getByRole('button', { name: 'Import', exact: true })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(exportPath);
await expect(
app.mainWindow.getByText(/Backup import finished: 1 imported/)
).toBeVisible({ timeout: 15000 });
// Restart before opening the restored portal: the root-provided
// XtreamStore still holds the deleted portal's in-memory state
// under the same playlist id and would skip content
// initialization in this session. A restart matches the primary
// restore workflow (fresh install) and forces a real re-import.
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
// Opening the restored portal re-imports content from the mock
// server; the pending restore state must hide the same category
// again.
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
// The restored hidden flag must live in the database itself,
// not only in the rendered sidebar state.
const restoredPlaylistId =
app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? '';
expect(restoredPlaylistId).not.toEqual('');
const restoredDbRows = await app.mainWindow.evaluate(
(playlistId) =>
(
window as unknown as {
electron: {
dbGetAllCategories: (
id: string,
type: string
) => Promise<
Array<{ name: string; hidden: boolean }>
>;
};
}
).electron.dbGetAllCategories(playlistId, 'live'),
restoredPlaylistId
);
expect(restoredDbRows.length).toBeGreaterThan(0);
expect(
restoredDbRows
.filter((row) => row.hidden)
.map((row) => row.name)
).toEqual([targetCategory.name]);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
const restoredRow = dialog.locator('.category-item').first();
await expect(restoredRow).toBeVisible({ timeout: 15000 });
await expect(
restoredRow.locator('mat-checkbox input')
).not.toBeChecked();
} finally {
await closeElectronApp(app);
}
});
});
async function openManageCategoriesDialog(page: Page): Promise<Locator> {
await page.getByRole('button', { name: 'Manage categories' }).click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
await expect(dialog.locator('.category-item').first()).toBeVisible({
timeout: 15000,
});
return dialog;
}
async function setManagedCategoryChecked(
dialog: Locator,
categoryName: string,
shouldBeChecked: boolean
): Promise<void> {
await dialog.locator('input[type="search"]').fill(categoryName);
const row = dialog.locator('.category-item').first();
await expect(row).toBeVisible({ timeout: 15000 });
const checkbox = row.locator('mat-checkbox input');
if (shouldBeChecked) {
await checkbox.check();
await expect(checkbox).toBeChecked();
} else {
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
}
}
function sidebarCategoryById(page: Page, categoryId: string): Locator {
return page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
);
}
async function pickVisibleCategoryWithContent(
page: Page
): Promise<{ id: string; name: string }> {
let picked: { id: string; name: string } | null = null;
await expect
.poll(
async () => {
const category = (
await readVisibleSidebarCategories(page)
).find(
(candidate) =>
candidate.id &&
candidate.name &&
candidate.itemCount > 0
);
if (category) {
picked = { id: category.id, name: category.name };
return true;
}
picked = null;
return false;
},
{
message:
'No visible Xtream category with content was found in the sidebar.',
timeout: 15000,
}
)
.toBe(true);
return picked!;
}