diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json
index 644eae15f..4d777d241 100644
--- a/apps/electron-backend-e2e/project.json
+++ b/apps/electron-backend-e2e/project.json
@@ -10,7 +10,7 @@
],
"targets": {
"e2e": {
- "dependsOn": ["electron-backend:build"]
+ "dependsOn": ["electron-backend:build-e2e"]
}
}
}
diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts
index b06856276..2622c1330 100644
--- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts
+++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts
@@ -23,6 +23,10 @@ export const electronMainPath = join(
workspaceRoot,
'dist/apps/electron-backend/main.js'
);
+export const packagedRendererIndexPath = join(
+ workspaceRoot,
+ 'dist/apps/web/index.html'
+);
export const m3uFixturePath = join(
workspaceRoot,
'apps/web-e2e/src/fixtures/test.m3u'
@@ -124,6 +128,7 @@ export async function launchElectronApp(
`Electron build not found at ${electronMainPath}. Run the build before executing the E2E suite.`
);
}
+ assertPackagedRendererBuildIsElectronSafe();
const args = [electronMainPath];
@@ -164,6 +169,24 @@ export async function closeElectronApp(
}
}
+function assertPackagedRendererBuildIsElectronSafe(): void {
+ if (!existsSync(packagedRendererIndexPath)) {
+ throw new Error(
+ `Renderer build not found at ${packagedRendererIndexPath}. Run pnpm nx run electron-backend:build-e2e before executing the Electron E2E suite.`
+ );
+ }
+
+ const indexHtml = readFileSync(packagedRendererIndexPath, 'utf8');
+ const baseHrefMatch = indexHtml.match(/';
+
+ if (baseHref !== './') {
+ throw new Error(
+ `Renderer build at ${packagedRendererIndexPath} is not file-safe for packaged Electron. Found base href ${JSON.stringify(baseHref)}. Run pnpm nx run electron-backend:build-e2e or use an Electron E2E Nx target so dist/apps/web is rebuilt with the electron-e2e configuration.`
+ );
+ }
+}
+
async function findMainWindow(app: ElectronApplication): Promise {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
@@ -183,14 +206,37 @@ async function findMainWindow(app: ElectronApplication): Promise {
async function waitForAppReady(page: Page): Promise {
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('app-root', { timeout: 30000 });
- await page.waitForFunction(
- () => {
- const appRoot = document.querySelector('app-root');
+ try {
+ await page.waitForFunction(
+ () => {
+ const appRoot = document.querySelector('app-root');
- return Boolean(appRoot && appRoot.innerHTML.trim().length > 0);
- },
- { timeout: 30000 }
- );
+ return Boolean(appRoot && appRoot.innerHTML.trim().length > 0);
+ },
+ { timeout: 30000 }
+ );
+ } catch (error) {
+ const diagnostics = await page.evaluate(() => ({
+ appRootLength:
+ document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
+ baseHref:
+ document
+ .querySelector('base')
+ ?.getAttribute('href') ?? '',
+ readyState: document.readyState,
+ title: document.title,
+ url: location.href,
+ }));
+
+ const reason =
+ error instanceof Error ? error.message : 'unknown startup error';
+
+ throw new Error(
+ `Electron app did not render visible app-root within 30000ms. ${reason}. Diagnostics: ${JSON.stringify(
+ diagnostics
+ )}`
+ );
+ }
}
export async function openAddPlaylistDialog(page: Page): Promise {
@@ -665,6 +711,21 @@ export async function switchUnifiedCollectionContent(
await clickButtonToggleOption(toggleGroup, contentLabel);
}
+export async function clearCurrentUnifiedCollection(
+ page: Page
+): Promise {
+ await page
+ .getByRole('button', {
+ name: /Clear .* (favorites|recently viewed)/i,
+ })
+ .click();
+
+ const dialog = page.locator('mat-dialog-container').last();
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole('button', { name: /^Yes$/i }).click();
+ await expect(dialog).toBeHidden();
+}
+
async function clickButtonToggleOption(
toggleGroup: Locator,
label: string
diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts
index a03457a4f..d6882734d 100644
--- a/apps/electron-backend-e2e/src/favorites.e2e.ts
+++ b/apps/electron-backend-e2e/src/favorites.e2e.ts
@@ -3,6 +3,7 @@ import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
+ clearCurrentUnifiedCollection,
clickCategoryById,
clickGridListCardByTitle,
clickCategoryByNameExact,
@@ -105,6 +106,72 @@ test.describe('Electron Favorites', () => {
}
});
+ test('keeps playlist M3U favorites cleared after navigating away and back', async ({
+ dataDir,
+ }) => {
+ const playlistTitle = 'm3u-clear-favorites-source.m3u';
+ const playlistSourceTitle = 'm3u-clear-favorites-source';
+ const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
+ {
+ groupTitle: 'News',
+ name: 'M3U Clear Favorite One',
+ url: 'https://streams.example.test/m3u-clear-favorite-one.m3u8',
+ },
+ {
+ groupTitle: 'News',
+ name: 'M3U Clear Favorite Two',
+ url: 'https://streams.example.test/m3u-clear-favorite-two.m3u8',
+ },
+ ]);
+ const app = await launchElectronApp(dataDir);
+
+ try {
+ await importM3uPlaylistFromNativeDialog(app, filePath);
+ await waitForM3uCatalog(app.mainWindow);
+ await toggleFavoriteForChannel(
+ app.mainWindow,
+ 'M3U Clear Favorite One'
+ );
+ await toggleFavoriteForChannel(
+ app.mainWindow,
+ 'M3U Clear Favorite Two'
+ );
+ await openPlaylistFavorites(app.mainWindow);
+
+ await expect(
+ channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
+ ).toHaveCount(1);
+ await expect(
+ channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
+ ).toHaveCount(1);
+
+ await clearCurrentUnifiedCollection(app.mainWindow);
+
+ await expect(
+ channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
+ ).toHaveCount(0);
+ await expect(
+ channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
+ ).toHaveCount(0);
+
+ await openSources(app.mainWindow);
+ await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
+ .first()
+ .click();
+ await waitForM3uCatalog(app.mainWindow);
+ await openPlaylistFavorites(app.mainWindow);
+
+ await expect(
+ channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
+ ).toHaveCount(0);
+ await expect(
+ channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
+ ).toHaveCount(0);
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
test('shows Xtream live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
request,
diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json
index 06a0bbf51..a925fd5be 100644
--- a/apps/electron-backend/project.json
+++ b/apps/electron-backend/project.json
@@ -41,6 +41,27 @@
}
}
},
+ "build-e2e": {
+ "dependsOn": [
+ "electron-backend:build-worker",
+ {
+ "projects": ["web"],
+ "target": "build-e2e"
+ },
+ {
+ "projects": ["remote-control-web"],
+ "target": "build-e2e"
+ }
+ ],
+ "executor": "nx-electron:build",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/apps/electron-backend",
+ "main": "apps/electron-backend/src/main.ts",
+ "tsConfig": "apps/electron-backend/tsconfig.app.json",
+ "assets": ["apps/electron-backend/src/assets"]
+ }
+ },
"serve": {
"executor": "nx:run-commands",
"options": {
diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json
index 437e5542f..53cf78907 100644
--- a/apps/remote-control-web/project.json
+++ b/apps/remote-control-web/project.json
@@ -57,6 +57,12 @@
},
"defaultConfiguration": "production"
},
+ "build-e2e": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "pnpm nx run remote-control-web:build:development"
+ }
+ },
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
diff --git a/apps/web/project.json b/apps/web/project.json
index e5de67e89..bf423df83 100644
--- a/apps/web/project.json
+++ b/apps/web/project.json
@@ -102,10 +102,22 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true
+ },
+ "electron-e2e": {
+ "baseHref": "./",
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
}
},
"defaultConfiguration": "production"
},
+ "build-e2e": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "pnpm nx run web:build:electron-e2e"
+ }
+ },
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
diff --git a/nx.json b/nx.json
index 6e0773a20..89dd5aceb 100644
--- a/nx.json
+++ b/nx.json
@@ -50,6 +50,18 @@
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
+ },
+ "e2e-ci--src/*.e2e.ts": {
+ "dependsOn": [
+ {
+ "projects": ["electron-backend"],
+ "target": "build-e2e"
+ },
+ {
+ "projects": ["stalker-mock-server", "xtream-mock-server"],
+ "target": "serve"
+ }
+ ]
}
},
"plugins": [