mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(migration): show startup preparation and recover source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures * fix(migration): keep inventory reload failures recoverable * test(migration): type the deferred recovery hook * fix(migration): reconcile failed EPG cleanup before recovery * fix(migration): await settings before inventory readiness * style(migration): add a subtle theme-aware startup watermark
This commit is contained in:
1 parent
97b0264dee
commit
9f950a1530
37 files changed
+1024
-14
No files matched your search
@@ -4,7 +4,7 @@ import {
|
||||
Page,
|
||||
} from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
@@ -14,8 +14,18 @@ import {
|
||||
electronMainPath,
|
||||
workspaceRoot,
|
||||
openSources,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
} from './electron-test-fixtures';
|
||||
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
interface StartupTestGlobals {
|
||||
__failPlaylistReads: boolean;
|
||||
__deferRecoveredEpg: boolean;
|
||||
__releaseStartupEpg: () => void;
|
||||
__resolveLegacyRecoveryDialog: () => void;
|
||||
}
|
||||
|
||||
const migrationKey = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const recoveryKey = 'playlists-electron-backend-profile-v1';
|
||||
@@ -56,12 +66,23 @@ async function sourceCount(page: Page) {
|
||||
async function launchWithRecoveryChoice(
|
||||
dataDir: string,
|
||||
response: number,
|
||||
retry = false
|
||||
retry = false,
|
||||
deferChoice = false,
|
||||
startupFault?: 'defer-epg' | 'fail-playlist-reads'
|
||||
) {
|
||||
const wrapper = join(dataDir, 'recovery-launch.cjs');
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`const {dialog}=require('electron'); dialog.showMessageBox=async()=>({response:${response},checkboxChecked:false}); require(${JSON.stringify(electronMainPath)});`
|
||||
`const {dialog,ipcMain}=require('electron');
|
||||
dialog.showMessageBox=()=>new Promise(resolve=>{const choose=()=>resolve({response:${response},checkboxChecked:false}); ${deferChoice ? 'globalThis.__resolveLegacyRecoveryDialog=choose;' : 'choose();'}});
|
||||
globalThis.__failPlaylistReads=${startupFault === 'fail-playlist-reads'};
|
||||
const handle=ipcMain.handle.bind(ipcMain);
|
||||
ipcMain.handle=(channel,handler)=>handle(channel,async(...args)=>{
|
||||
if(channel==='DB_GET_APP_PLAYLIST_METAS' && globalThis.__failPlaylistReads) throw new Error('Synthetic playlist read failure');
|
||||
if(channel==='EPG_RECONCILE_SOURCES' && (${startupFault === 'defer-epg'} || globalThis.__deferRecoveredEpg)) await new Promise(resolve=>{globalThis.__releaseStartupEpg=resolve;});
|
||||
return handler(...args);
|
||||
});
|
||||
require(${JSON.stringify(electronMainPath)});`
|
||||
);
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
@@ -74,14 +95,258 @@ async function launchWithRecoveryChoice(
|
||||
env: buildElectronLaunchEnvironment(dataDir),
|
||||
});
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('app-root');
|
||||
await page.waitForFunction(
|
||||
() => typeof window.electron?.dbGetAppPlaylists === 'function'
|
||||
try {
|
||||
// The route is intentionally still empty while recovery is pending.
|
||||
await page.waitForSelector('app-root', { state: 'attached' });
|
||||
await page.waitForFunction(
|
||||
() => typeof window.electron?.dbGetAppPlaylists === 'function'
|
||||
);
|
||||
return { app, page };
|
||||
} catch (error) {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exercise the same failure after a real backup import clears a loaded store. */
|
||||
async function verifyBackupReloadRecovery(
|
||||
app: ElectronApplication,
|
||||
page: Page,
|
||||
dataDir: string
|
||||
) {
|
||||
const backupPath = join(dataDir, 'startup-retry-backup.json');
|
||||
await app.evaluate(({ dialog }, filePath) => {
|
||||
dialog.showSaveDialog = async () => ({ canceled: false, filePath });
|
||||
}, backupPath);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'backup');
|
||||
const backup = page.locator('#backup');
|
||||
await backup.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await expect(page.getByText('Playlist backup exported.')).toBeVisible();
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__failPlaylistReads'
|
||||
] = true;
|
||||
});
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await backup.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await (await chooser).setFiles(backupPath);
|
||||
const startup = page.locator('app-startup-status');
|
||||
await expect(startup.getByRole('alert')).toContainText(
|
||||
'Your sources could not be loaded'
|
||||
);
|
||||
return { app, page };
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__failPlaylistReads'
|
||||
] = false;
|
||||
});
|
||||
await startup.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Current synthetic source', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(startup.locator('section')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe('v0.19 profile migration', () => {
|
||||
for (const response of [0, 1]) {
|
||||
test(`keeps current sources usable with a corrupt legacy profile: choice ${response}`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const initial = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await initial.mainWindow.evaluate(() =>
|
||||
window.electron.dbUpsertAppPlaylist({
|
||||
_id: 'current',
|
||||
title: 'Current synthetic source',
|
||||
count: 0,
|
||||
importDate: '2026-01-01',
|
||||
lastUsage: '2026-01-01',
|
||||
autoRefresh: false,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
const legacy = join(
|
||||
dataDir,
|
||||
'electron-backend',
|
||||
'IndexedDB',
|
||||
'file__0.indexeddb.leveldb'
|
||||
);
|
||||
await mkdir(legacy, { recursive: true });
|
||||
const marker = join(legacy, 'CURRENT');
|
||||
const corruptManifest = 'Synthetic invalid LevelDB manifest';
|
||||
await writeFile(marker, corruptManifest);
|
||||
const { app, page } = await launchWithRecoveryChoice(
|
||||
dataDir,
|
||||
response
|
||||
);
|
||||
try {
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Current synthetic source', { exact: true })
|
||||
).toBeVisible();
|
||||
expect(await sourceCount(page)).toBe(1);
|
||||
expect(await readFile(marker, 'utf8')).toBe(corruptManifest);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
recoveryKey
|
||||
)
|
||||
).toBe(response === 0 ? 'declined' : null);
|
||||
} finally {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const fault of ['defer-epg', 'fail-playlist-reads'] as const) {
|
||||
test(`keeps startup actionable after declining recovery: ${fault}`, async ({
|
||||
dataDir,
|
||||
}, testInfo) => {
|
||||
const initial = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await initial.mainWindow.evaluate(() =>
|
||||
window.electron.dbUpsertAppPlaylist({
|
||||
_id: 'current',
|
||||
title: 'Current synthetic source',
|
||||
count: 1,
|
||||
playlist: {
|
||||
header: { raw: '#EXTM3U' },
|
||||
items: [
|
||||
{
|
||||
id: 'synthetic-channel',
|
||||
raw: '#EXTINF:-1,Synthetic channel\nhttps://channel.invalid/live',
|
||||
name: 'Synthetic channel',
|
||||
url: 'https://channel.invalid/live',
|
||||
group: { title: 'Test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
importDate: '2026-01-01',
|
||||
lastUsage: '2026-01-01',
|
||||
autoRefresh: false,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
await seedLegacyProfile(dataDir, 'm3u', false);
|
||||
const { app, page } = await launchWithRecoveryChoice(
|
||||
dataDir,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
fault
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
recoveryKey
|
||||
)
|
||||
)
|
||||
.toBe('declined');
|
||||
const startup = page.locator('app-startup-status section');
|
||||
await expect(startup).toHaveCSS('app-region', 'drag');
|
||||
if (fault === 'defer-epg') {
|
||||
await expect(page.locator('#initial-splash')).toHaveCount(
|
||||
0
|
||||
);
|
||||
await expect(
|
||||
page.locator('app-startup-status').getByRole('status')
|
||||
).toContainText('Preparing your library');
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(
|
||||
() =>
|
||||
typeof (
|
||||
globalThis as typeof globalThis &
|
||||
StartupTestGlobals
|
||||
)['__releaseStartupEpg'] === 'function'
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`startup-${theme}.png`),
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 480, height: 640 });
|
||||
await expect(page.locator('body')).toHaveJSProperty(
|
||||
'scrollWidth',
|
||||
480
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('startup-dark-narrow.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await app.evaluate(() =>
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__releaseStartupEpg'
|
||||
]()
|
||||
);
|
||||
} else {
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Your sources could not be loaded'
|
||||
);
|
||||
await expect(
|
||||
startup.getByRole('button', {
|
||||
name: 'Retry',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCSS('app-region', 'no-drag');
|
||||
await app.evaluate(() => {
|
||||
const hooks = globalThis as typeof globalThis &
|
||||
StartupTestGlobals;
|
||||
hooks.__failPlaylistReads = false;
|
||||
hooks.__deferRecoveredEpg = true;
|
||||
});
|
||||
await page
|
||||
.getByRole('button', { name: 'Retry', exact: true })
|
||||
.click();
|
||||
// Metadata is readable again, but startup must still wait
|
||||
// for the reconciliation that failed during settings load.
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(
|
||||
() =>
|
||||
typeof (
|
||||
globalThis as typeof globalThis &
|
||||
StartupTestGlobals
|
||||
).__releaseStartupEpg
|
||||
)
|
||||
)
|
||||
.toBe('function');
|
||||
await expect(startup).toHaveAttribute('role', 'status');
|
||||
await app.evaluate(() => {
|
||||
const hooks = globalThis as typeof globalThis &
|
||||
StartupTestGlobals;
|
||||
hooks.__deferRecoveredEpg = false;
|
||||
hooks.__releaseStartupEpg();
|
||||
});
|
||||
}
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Current synthetic source', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-startup-status section')
|
||||
).toHaveCount(0);
|
||||
if (fault === 'fail-playlist-reads') {
|
||||
await verifyBackupReloadRecovery(app, page, dataDir);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const active of ['xtream-2', 'stalker-60', 'm3u']) {
|
||||
test(`imports all 65 sources with ${active} last active, offline`, async ({
|
||||
dataDir,
|
||||
@@ -298,8 +563,27 @@ test.describe('v0.19 profile migration', () => {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
await seedLegacyProfile(dataDir, 'xtream-2', false);
|
||||
let recovered = await launchWithRecoveryChoice(dataDir, 0);
|
||||
let recovered = await launchWithRecoveryChoice(dataDir, 0, false, true);
|
||||
try {
|
||||
// Leave the offer pending until renderer startup has reached it.
|
||||
// A synchronous stub plus raw DB reads cannot detect a blank route
|
||||
// after the real user eventually chooses Keep current sources.
|
||||
await expect
|
||||
.poll(() =>
|
||||
recovered.app.evaluate(
|
||||
() =>
|
||||
typeof (
|
||||
globalThis as typeof globalThis &
|
||||
StartupTestGlobals
|
||||
)['__resolveLegacyRecoveryDialog'] === 'function'
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
await recovered.app.evaluate(() =>
|
||||
(globalThis as typeof globalThis & StartupTestGlobals)[
|
||||
'__resolveLegacyRecoveryDialog'
|
||||
]()
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
recovered.page.evaluate(
|
||||
@@ -314,6 +598,12 @@ test.describe('v0.19 profile migration', () => {
|
||||
app.getPath('userData')
|
||||
)
|
||||
).toBe(join(dataDir, 'user-data'));
|
||||
await openSources(recovered.page);
|
||||
await expect(
|
||||
recovered.page.getByText('Current edited source', {
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
|
||||
Reference in new issue
Block a user