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:
4gray authored and GitHub committed 2026-09-07 21:24:34 +02:00
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,