Files
iptvnator/apps/electron-backend-e2e/src/settings.e2e.ts
T
4gray bfad82c26c fix(settings): stop settings silently reverting on restart (#1272)
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.

A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.

updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.

Two follow-ups from review, both wider than the report:

- a second launch now re-creates the main window when the lock owner has none
  left, so closing the last window on macOS no longer leaves a second launch
  quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
  window, so the downloads broadcaster stops holding a destroyed window. This
  also fixes the same bug on the pre-existing dock `activate` path.

Closes #1156
Closes #102
2026-07-27 23:14:30 +02:00

842 lines
30 KiB
TypeScript

import { Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
createMutableTextServer,
enableRemoteControl,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchCompetingElectronInstance,
launchElectronApp,
LaunchedElectronApp,
m3uFixturePath,
openGlobalRecent,
openSettings,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
saveSettings,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
defaultXtreamPassword,
defaultXtreamUsername,
} from './electron-test-fixtures';
import { fetchXtreamVodFixture } from './portal-mock-fixtures';
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="news-1">
<display-name>News One</display-name>
</channel>
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="news-1">
<title>Morning Bulletin</title>
<desc>Daily morning news.</desc>
</programme>
</tv>
`;
test.describe('Electron Settings', () => {
test('@settings @electron shows manual app update fallback when self-update is unavailable', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible();
await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible();
await expect(
app.mainWindow.getByTestId('app-update-open-release')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('app-update-download')
).toHaveCount(0);
await expect(
app.mainWindow.getByTestId('app-update-install')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@settings @electron gates external MPV playback behind double-click when enabled', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await installExternalPlayerLaunchCapture(app);
await openSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId(
'external-player-double-click-setting'
)
).toHaveCount(0);
await selectSettingsOption(
app.mainWindow,
'select-video-player',
'mpv'
);
const doubleClickSetting = app.mainWindow.getByTestId(
'external-player-double-click-setting'
);
const doubleClickCheckbox = doubleClickSetting.locator(
'input[type="checkbox"]'
);
await expect(doubleClickSetting).toBeVisible();
await expect(doubleClickCheckbox).not.toBeChecked();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
const firstChannel = channelItemByTitle(
app.mainWindow,
'Channel 1'
).first();
await expect(firstChannel).toBeVisible({ timeout: 20000 });
await firstChannel.click();
await expectExternalPlayerLaunchCount(app, 1);
await expectExternalPlayerLaunch(app, 0, {
player: 'mpv',
title: 'Channel 1',
url: 'https://example.channels/path-to-file/1.m3u8',
});
await openSettings(app.mainWindow);
await expect(doubleClickSetting).toBeVisible();
await doubleClickCheckbox.check();
await saveSettings(app.mainWindow);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await resetExternalPlayerLaunches(app);
const secondChannel = channelItemByTitle(
app.mainWindow,
'Positive News TV'
).first();
await expect(secondChannel).toBeVisible({ timeout: 20000 });
await secondChannel.click();
await expect(secondChannel).toHaveClass(/active/);
await expectNoExternalPlayerLaunchesAfterSettled(app);
await secondChannel.dblclick();
await expectExternalPlayerLaunchCount(app, 1);
await expectExternalPlayerLaunch(app, 0, {
player: 'mpv',
title: 'Positive News TV',
url: 'https://example.channels/path-to-file/2.m3u8',
});
} finally {
await closeElectronApp(app);
}
});
test('@settings @persistence @electron refuses a second instance so it cannot break settings storage', async ({
dataDir,
}) => {
const runningApp = await launchElectronApp(dataDir);
try {
const competing = await launchCompetingElectronInstance(dataDir);
expect(
{
exitCode: competing.exitCode,
signal: competing.signal,
timedOut: competing.timedOut,
},
competing.stderr
).toEqual({ exitCode: 0, signal: null, timedOut: false });
// The running instance keeps its window and its storage lock.
expect(runningApp.electronApp.windows().length).toBeGreaterThan(0);
await expect(runningApp.mainWindow.locator('body')).toBeVisible();
} finally {
await closeElectronApp(runningApp);
}
// The lock is released on exit, so a later launch starts normally.
const relaunch = await launchElectronApp(dataDir);
await closeElectronApp(relaunch);
});
test('@settings @electron re-opens a window when a second launch arrives with none open', async ({
dataDir,
}) => {
// Only macOS keeps the process alive after its last window closes;
// elsewhere `window-all-closed` quits and the next launch is a plain
// cold start.
test.skip(
process.platform !== 'darwin',
'macOS-only windowless-process behaviour'
);
const runningApp = await launchElectronApp(dataDir);
try {
await runningApp.electronApp.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) {
window.close();
}
});
await expect
.poll(() => runningApp.electronApp.windows().length)
.toBe(0);
const recreatedWindow =
runningApp.electronApp.waitForEvent('window');
const competing = await launchCompetingElectronInstance(dataDir);
expect(competing.timedOut).toBe(false);
expect(competing.exitCode).toBe(0);
// Without this the guard would quit the launch into nothing and
// leave the user staring at no window at all.
await expect((await recreatedWindow).locator('body')).toBeVisible();
} finally {
await closeElectronApp(runningApp);
}
});
test('@settings @persistence @electron persists changed desktop settings across app restart', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml; charset=utf-8',
resourcePath: '/settings-guide.xml',
});
const firstLaunch = await launchElectronApp(dataDir);
try {
await openSettings(firstLaunch.mainWindow);
await selectSettingsOption(
firstLaunch.mainWindow,
'select-language',
'de'
);
await firstLaunch.mainWindow
.locator('[data-test-id="DARK_THEME"]')
.click();
await selectSettingsOption(
firstLaunch.mainWindow,
'select-video-player',
'html5'
);
const sharedControlsCheckbox = firstLaunch.mainWindow
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]');
await expect(sharedControlsCheckbox).toBeVisible();
await sharedControlsCheckbox.check();
await selectSettingsOption(
firstLaunch.mainWindow,
'select-stream-format',
'ts'
);
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
)
.uncheck();
await enableRemoteControl(firstLaunch.mainWindow, 8877);
await firstLaunch.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await firstLaunch.mainWindow
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await saveSettings(firstLaunch.mainWindow);
} finally {
await closeElectronApp(firstLaunch);
}
const secondLaunch = await launchElectronApp(dataDir);
try {
await openSettings(secondLaunch.mainWindow);
await expect(
secondLaunch.mainWindow.getByTestId('select-language')
).toContainText('Deutsch');
await expect(
secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]')
).toHaveAttribute('aria-checked', 'true');
await expect(
secondLaunch.mainWindow.getByTestId('select-video-player')
).toContainText(/HTML5/i);
await expect(
secondLaunch.mainWindow
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]')
).toBeChecked();
await expect(
secondLaunch.mainWindow.getByTestId('select-stream-format')
).toContainText('ts');
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
)
).not.toBeChecked();
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
)
).toBeChecked();
await expect(
secondLaunch.mainWindow.locator('#remoteControlPort')
).toHaveValue('8877');
await expect(
secondLaunch.mainWindow.locator('.epg-source-row input').first()
).toHaveValue(epgServer.resourceUrl);
} finally {
await closeElectronApp(secondLaunch);
await epgServer.close();
}
});
test('@settings @playback @electron applies shared controls to the next HTML5 session', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-video-player',
'html5'
);
const sharedControlsCheckbox = app.mainWindow
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]');
await expect(sharedControlsCheckbox).toBeVisible();
await sharedControlsCheckbox.check();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await app.mainWindow.route(
'https://example.channels/path-to-file/1.m3u8',
() => {
// Keep the synthetic stream pending so its network failure
// cannot replace the shared controls with a diagnostic.
}
);
const firstChannel = channelItemByTitle(
app.mainWindow,
'Channel 1'
).first();
await expect(firstChannel).toBeVisible({ timeout: 20000 });
await firstChannel.click();
const video = app.mainWindow.locator(
'app-html-video-player video'
);
await expect(video).toBeAttached();
await video.evaluate<void, HTMLVideoElement>((video) => {
const ownerDocument = video.ownerDocument;
let activePictureInPictureElement: Element | null = null;
video.dataset['pictureInPictureRequestCount'] = '0';
video.dataset['pictureInPictureExitCount'] = '0';
Object.defineProperty(
ownerDocument,
'pictureInPictureEnabled',
{
configurable: true,
value: true,
}
);
Object.defineProperty(
ownerDocument,
'pictureInPictureElement',
{
configurable: true,
get: () => activePictureInPictureElement,
}
);
Object.defineProperty(ownerDocument, 'exitPictureInPicture', {
configurable: true,
value: async (): Promise<void> => {
video.dataset['pictureInPictureExitCount'] = String(
Number(
video.dataset['pictureInPictureExitCount'] ??
'0'
) + 1
);
const previousOwner = activePictureInPictureElement;
activePictureInPictureElement = null;
previousOwner?.dispatchEvent(
new Event('leavepictureinpicture')
);
},
});
Object.defineProperty(video, 'requestPictureInPicture', {
configurable: true,
value: async (): Promise<PictureInPictureWindow> => {
video.dataset['pictureInPictureRequestCount'] = String(
Number(
video.dataset['pictureInPictureRequestCount'] ??
'0'
) + 1
);
activePictureInPictureElement = video;
video.dispatchEvent(new Event('enterpictureinpicture'));
const pictureInPictureWindow: PictureInPictureWindow =
Object.assign(new EventTarget(), {
height: video.videoHeight,
onresize: null,
width: video.videoWidth,
});
return pictureInPictureWindow;
},
});
Object.defineProperty(video, 'disablePictureInPicture', {
configurable: true,
value: false,
});
Object.defineProperty(video, 'readyState', {
configurable: true,
value: HTMLMediaElement.HAVE_METADATA,
});
video.dispatchEvent(new Event('loadedmetadata'));
});
const playerControls = app.mainWindow.locator(
'app-html-video-player app-player-controls'
);
await expect(playerControls).toBeVisible();
await expect(
app.mainWindow.locator('app-html-video-player video[controls]')
).toHaveCount(0);
const enterPictureInPicture = playerControls.getByRole('button', {
name: 'Enter picture-in-picture',
});
await expect(enterPictureInPicture).toBeVisible();
await expect(enterPictureInPicture).toBeEnabled();
await expect(enterPictureInPicture).toHaveAttribute(
'aria-pressed',
'false'
);
await expect(video).toHaveAttribute(
'data-picture-in-picture-request-count',
'0'
);
await enterPictureInPicture.click();
await expect(video).toHaveAttribute(
'data-picture-in-picture-request-count',
'1'
);
const exitPictureInPicture = playerControls.getByRole('button', {
name: 'Exit picture-in-picture',
});
await expect(exitPictureInPicture).toBeVisible();
await expect(exitPictureInPicture).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(video).toHaveAttribute(
'data-picture-in-picture-exit-count',
'0'
);
await exitPictureInPicture.click();
await expect(video).toHaveAttribute(
'data-picture-in-picture-exit-count',
'1'
);
await expect(enterPictureInPicture).toBeVisible();
await expect(enterPictureInPicture).toHaveAttribute(
'aria-pressed',
'false'
);
} finally {
await closeElectronApp(app);
}
});
test('@settings @persistence @electron persists the EPG view mode across app restart', async ({
dataDir,
}) => {
const firstLaunch = await launchElectronApp(dataDir);
try {
await openSettings(firstLaunch.mainWindow);
const listToggle = firstLaunch.mainWindow.locator(
'[data-test-id="epg-view-mode-list"]'
);
await expect(listToggle).toBeVisible();
await listToggle.click();
await expect(listToggle).toHaveAttribute('aria-checked', 'true');
await saveSettings(firstLaunch.mainWindow);
} finally {
await closeElectronApp(firstLaunch);
}
const secondLaunch = await launchElectronApp(dataDir);
try {
await openSettings(secondLaunch.mainWindow);
await expect(
secondLaunch.mainWindow.locator(
'[data-test-id="epg-view-mode-list"]'
)
).toHaveAttribute('aria-checked', 'true');
} finally {
await closeElectronApp(secondLaunch);
}
});
test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => {
const firstLaunch = await launchElectronApp(dataDir);
try {
await openSettings(firstLaunch.mainWindow);
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
)
.uncheck();
for (const toggleId of [
'toggle-dashboard-hero',
'toggle-dashboard-rail-continue-watching',
'toggle-dashboard-rail-live-favorites',
'toggle-dashboard-rail-recently-watched-live',
'toggle-dashboard-rail-favorite-movies-and-series',
'toggle-dashboard-rail-recent-sources',
'toggle-dashboard-rail-xtream-recently-added',
]) {
await expect(
firstLaunch.mainWindow
.getByTestId(toggleId)
.locator('input[type="checkbox"]')
).toBeDisabled();
}
await saveSettings(firstLaunch.mainWindow);
} finally {
await closeElectronApp(firstLaunch);
}
const secondLaunch = await launchElectronApp(dataDir);
try {
await secondLaunch.mainWindow.waitForURL(/\/workspace\/sources$/);
await expect(
secondLaunch.mainWindow.getByRole('link', {
name: 'Dashboard',
exact: true,
})
).toHaveCount(0);
await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute(
'href',
/\/workspace\/sources$/
);
} finally {
await closeElectronApp(secondLaunch);
}
});
test('@settings @dashboard @electron hides an individually disabled dashboard rail while dashboard remains enabled', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await goToDashboard(app.mainWindow);
await expect(
app.mainWindow.getByTestId('dashboard-recent-sources-rail')
).toBeVisible({ timeout: 20000 });
await openSettings(app.mainWindow);
await app.mainWindow
.locator('.settings-section-item')
.filter({ hasText: 'Dashboard' })
.first()
.click();
await expect(
app.mainWindow
.getByTestId('toggle-show-dashboard')
.locator('input[type="checkbox"]')
).toBeChecked();
await app.mainWindow
.getByTestId('toggle-dashboard-rail-recent-sources')
.locator('input[type="checkbox"]')
.uncheck();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
await expect(
app.mainWindow.getByTestId('dashboard-recent-sources-rail')
).toHaveCount(0);
await expect(
app.mainWindow.getByRole('link', {
name: 'Dashboard',
exact: true,
})
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('restores the last section-level view across restart when configured', async ({
dataDir,
}) => {
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-behavior',
'restore-last-view'
);
await saveSettings(app.mainWindow);
await openGlobalRecent(app.mainWindow);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await app.mainWindow.waitForURL(/\/workspace\/global-recent$/);
} finally {
await closeElectronApp(app);
}
});
test('ignores settings and restores only the section root after a detail route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, {
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-behavior',
'restore-last-view'
);
await saveSettings(app.mainWindow);
await addXtreamPortal(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Movies');
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await clickFirstGridListCard(app.mainWindow);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await openSettings(app.mainWindow);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod$/);
} finally {
await closeElectronApp(app);
}
});
test('@settings @electron @persistence sizes and clears the TMDB metadata cache', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
// Seeded through the same preload bridge the enrichment service
// uses, so this exercises real IPC -> DB worker -> SQLite.
// Enrichment itself cannot run here: it needs a TMDB API key,
// which builds outside the release pipeline do not carry.
await app.mainWindow.evaluate(async () => {
await window.electron.dbSetTmdbMetadata({
mediaType: 'movie',
lookupKey: 'id:603|v2',
language: 'en-US',
tmdbId: 603,
payload: JSON.stringify({ id: 603, title: 'The Matrix' }),
fetchedAt: new Date().toISOString(),
});
});
await openSettings(app.mainWindow);
await app.mainWindow.getByTestId('settings-section-tmdb').click();
// Sizing is deferred until this section is the active one
await expect(
app.mainWindow.getByTestId('tmdb-cache-size')
).toHaveText(/\b1 entries/);
const clearButton =
app.mainWindow.getByTestId('tmdb-clear-cache');
await expect(clearButton).toBeEnabled();
await clearButton.click();
// Re-read reports an empty cache, so there is nothing to clear
await expect(clearButton).toBeDisabled();
await expect(
app.mainWindow.getByTestId('tmdb-cache-size')
).toHaveText(/\b0 entries/);
// ...and the row is gone from the database, not just the panel
const remaining = await app.mainWindow.evaluate(() =>
window.electron.dbGetTmdbMetadata('movie', 'id:603|v2', 'en-US')
);
expect(remaining).toBeNull();
} finally {
await closeElectronApp(app);
}
});
});
async function selectSettingsOption(
page: Page,
selectTestId: string,
optionTestId: string
): Promise<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
type CapturedExternalPlayerLaunch = {
player: 'mpv' | 'vlc';
title: string;
url: string;
};
const externalPlayerLaunchCaptureKey =
'__iptvnatorE2eExternalPlayerLaunches';
async function installExternalPlayerLaunchCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.evaluate(({ ipcMain }, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, CapturedExternalPlayerLaunch[] | undefined>;
globalRef[captureKey] = [];
const captureLaunch =
(player: 'mpv' | 'vlc') =>
async (
_event: unknown,
url: string,
title: string,
thumbnail?: string | null
) => {
const launches = globalRef[captureKey] ?? [];
const now = new Date().toISOString();
launches.push({
player,
title,
url,
});
globalRef[captureKey] = launches;
return {
canClose: false,
id: `e2e-${player}-${launches.length}`,
player,
startedAt: now,
status: 'opened',
streamUrl: url,
thumbnail: thumbnail ?? null,
title,
updatedAt: now,
};
};
ipcMain.removeHandler('OPEN_MPV_PLAYER');
ipcMain.removeHandler('OPEN_VLC_PLAYER');
ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv'));
ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc'));
}, externalPlayerLaunchCaptureKey);
}
async function getExternalPlayerLaunches(
app: LaunchedElectronApp
): Promise<CapturedExternalPlayerLaunch[]> {
return app.electronApp.evaluate((_, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, CapturedExternalPlayerLaunch[] | undefined>;
return globalRef[captureKey] ?? [];
}, externalPlayerLaunchCaptureKey);
}
async function resetExternalPlayerLaunches(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.evaluate((_, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, CapturedExternalPlayerLaunch[] | undefined>;
globalRef[captureKey] = [];
}, externalPlayerLaunchCaptureKey);
}
async function expectExternalPlayerLaunchCount(
app: LaunchedElectronApp,
count: number
): Promise<void> {
await expect
.poll(async () => (await getExternalPlayerLaunches(app)).length, {
timeout: 10000,
})
.toBe(count);
}
async function expectExternalPlayerLaunch(
app: LaunchedElectronApp,
index: number,
expected: CapturedExternalPlayerLaunch
): Promise<void> {
const launches = await getExternalPlayerLaunches(app);
expect(launches[index]).toMatchObject({
player: expected.player,
url: expected.url,
});
expect(launches[index]?.title.trim()).toBe(expected.title);
}
async function expectNoExternalPlayerLaunchesAfterSettled(
app: LaunchedElectronApp
): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 750));
expect(await getExternalPlayerLaunches(app)).toHaveLength(0);
}