test(e2e): add end-to-end tests for playlist import, providers, and settings persistence

- Implemented tests for importing M3U playlists via native dialog.
- Added smoke tests for loading Xtream and Stalker content through IPC.
- Created tests to verify persistence of remote control settings across app restarts.
- Updated smoke tests to check for main window properties and workspace content rendering.

refactor(electron-backend): enhance store service and database path handling

- Modified store service to use a dynamic configuration directory based on environment.
- Refactored database connection to utilize new path utility functions for better path management.

fix(e2e): improve test selectors and structure for better reliability

- Updated test selectors in web-e2e tests to use role-based queries for better accessibility.
- Refactored settings tests to reduce redundancy and improve clarity.

chore(e2e): update TypeScript configuration for better inclusion of test files

- Adjusted tsconfig.json to ensure proper inclusion of e2e test files.

Entire-Checkpoint: bb9a8e2e351e
This commit is contained in:
4gray committed 2026-03-17 23:30:16 +01:00
1 parent 0c451b0dd7
commit 81c2aab829
23 files changed
+955 -443

No files matched your search

+89 -52
View File
@@ -1,61 +1,98 @@
name: 'Cross-Platform E2E Tests'
on:
push:
branches:
- master
pull_request:
branches:
- master
push:
branches:
- master
pull_request:
branches:
- master
jobs:
e2e-tests:
name: E2E on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
env:
NX_SKIP_NX_CACHE: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Build Backend
run: pnpm nx build electron-backend
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run E2E Tests (Linux)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e
electron-e2e-tests:
name: Electron E2E on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
env:
CI: true
NX_SKIP_NX_CACHE: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- name: Run E2E Tests (Windows/Mac)
if: runner.os != 'Linux'
run: pnpm nx run electron-backend-e2e:e2e
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Build Backend
run: pnpm nx build electron-backend
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run Electron E2E Tests (Linux)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e
env:
CI: true
- name: Run Electron E2E Tests (Windows/Mac)
if: runner.os != 'Linux'
run: pnpm nx run electron-backend-e2e:e2e
env:
CI: true
- name: Upload Electron Test Results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-electron-${{ matrix.os }}
path: dist/playwright-report/electron-backend-e2e/
retention-days: 7
web-e2e-tests:
name: Web E2E on Ubuntu Chromium
runs-on: ubuntu-latest
env:
CI: true
NX_SKIP_NX_CACHE: true
- name: Upload Test Results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.os }}
path: dist/playwright-report/electron-backend-e2e/
retention-days: 7
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Install Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Run Web E2E Tests
run: pnpm nx run web-e2e:e2e -- --project=chromium
env:
CI: true
- name: Upload Web Test Results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-web-ubuntu
path: dist/.playwright/apps/web-e2e/playwright-report/
retention-days: 7
+61 -38
View File
@@ -1,47 +1,70 @@
import { defineConfig } from '@playwright/test';
import { workspaceRoot } from '@nx/devkit';
/**
* Playwright configuration for Electron e2e tests.
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './src',
/* Match .e2e.ts files */
testMatch: '**/*.e2e.ts',
/* Run tests sequentially since we're testing an Electron app */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env['CI'],
/* Retry on CI only */
retries: process.env['CI'] ? 2 : 0,
/* Single worker since we're testing an Electron app */
workers: 1,
/* Reporter to use */
reporter: [
['list'],
['html', { outputFolder: '../../dist/playwright-report/electron-backend-e2e' }],
],
/* Shared settings for all the projects below */
use: {
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Screenshots on failure */
screenshot: 'on',
/* Video on failure */
video: 'on-first-retry',
},
/* Output folder for test artifacts */
outputDir: '../../dist/test-results/electron-backend-e2e',
/* Timeout for each test */
timeout: 60000,
/* Timeout for expect() assertions */
expect: {
timeout: 10000,
},
projects: [
{
name: 'electron',
use: {},
testDir: './src',
/* Match .e2e.ts files */
testMatch: '**/*.e2e.ts',
/* Run tests sequentially since we're testing an Electron app */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env['CI'],
/* Retry on CI only */
retries: process.env['CI'] ? 2 : 0,
/* Single worker since we're testing an Electron app */
workers: 1,
/* Reporter to use */
reporter: [
['list'],
[
'html',
{
outputFolder:
'../../dist/playwright-report/electron-backend-e2e',
},
],
],
/* Shared settings for all the projects below */
use: {
/* Align with the app's existing test ids */
testIdAttribute: 'data-test-id',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Screenshots on failure */
screenshot: 'on',
/* Video on failure */
video: 'on-first-retry',
},
],
webServer: [
{
command: 'pnpm nx run stalker-mock-server:serve',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run xtream-mock-server:serve',
url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
],
/* Output folder for test artifacts */
outputDir: '../../dist/test-results/electron-backend-e2e',
/* Timeout for each test */
timeout: 60000,
/* Timeout for expect() assertions */
expect: {
timeout: 10000,
},
projects: [
{
name: 'electron',
use: {},
},
],
});
+13 -9
View File
@@ -1,12 +1,16 @@
{
"name": "electron-backend-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/electron-backend-e2e/src",
"implicitDependencies": ["electron-backend"],
"targets": {
"e2e": {
"dependsOn": ["electron-backend:build"]
"name": "electron-backend-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/electron-backend-e2e/src",
"implicitDependencies": [
"electron-backend",
"stalker-mock-server",
"xtream-mock-server"
],
"targets": {
"e2e": {
"dependsOn": ["electron-backend:build"]
}
}
}
}
@@ -0,0 +1,345 @@
import {
_electron as electron,
APIRequestContext,
ElectronApplication,
expect,
Page,
test as base,
} from '@playwright/test';
import { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
workspaceRoot,
'dist/apps/electron-backend/main.js'
);
export const m3uFixturePath = join(
workspaceRoot,
'apps/web-e2e/src/fixtures/test.m3u'
);
export const stalkerMockPort = process.env['MOCK_PORT'] ?? '3210';
export const xtreamMockPort = process.env['XTREAM_MOCK_PORT'] ?? '3211';
export const stalkerMockServer = `http://localhost:${stalkerMockPort}`;
export const xtreamMockServer = `http://localhost:${xtreamMockPort}`;
export const defaultXtreamPortalName = 'Mock Xtream Portal';
export const defaultStalkerPortalName = 'Mock Stalker Portal';
export const defaultXtreamUsername = 'user1';
export const defaultXtreamPassword = 'pass1';
export const defaultStalkerMacAddress = '00:1A:79:00:00:01';
type PortalProvider = 'stalker' | 'xtream';
type ElectronFixtures = {
dataDir: string;
};
type PortalDebugEvent = {
durationMs: number;
operation: string;
provider: PortalProvider;
request: unknown;
requestId: string;
response?: unknown;
startedAt: string;
status: 'success' | 'error';
transport: 'electron-main' | 'electron-renderer' | 'pwa-http';
};
declare global {
interface Window {
__portalDebugEvents?: PortalDebugEvent[];
__portalDebugUnsubscribe?: (() => void) | undefined;
}
}
export type LaunchedElectronApp = {
electronApp: ElectronApplication;
mainWindow: Page;
};
export const test = base.extend<ElectronFixtures>({
dataDir: async ({}, use) => {
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-electron-e2e-'));
await use(dataDir);
rmSync(dataDir, { force: true, recursive: true });
},
});
export { expect };
export async function launchElectronApp(
dataDir: string
): Promise<LaunchedElectronApp> {
if (!existsSync(electronMainPath)) {
throw new Error(
`Electron build not found at ${electronMainPath}. Run the build before executing the E2E suite.`
);
}
const args = [electronMainPath];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
}
const electronApp = await electron.launch({
args,
env: {
...process.env,
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
},
});
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
await startPortalDebugCapture(mainWindow);
return {
electronApp,
mainWindow,
};
}
export async function closeElectronApp(
app: LaunchedElectronApp
): Promise<void> {
try {
await app.electronApp.close();
} catch (error) {
console.warn('Failed to close Electron app cleanly:', error);
}
}
async function findMainWindow(app: ElectronApplication): Promise<Page> {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000));
const windows = app.windows();
for (const window of windows) {
const title = await window.title();
if (!title.includes('DevTools')) {
return window;
}
}
return app.firstWindow();
}
async function waitForAppReady(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('app-root', { timeout: 30000 });
await page.waitForFunction(
() => {
const appRoot = document.querySelector('app-root');
return Boolean(appRoot && appRoot.innerHTML.trim().length > 0);
},
{ timeout: 30000 }
);
}
export async function openAddPlaylistMenu(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
}
export async function stubNativePlaylistFileDialog(
electronApp: ElectronApplication,
filePath: string
): Promise<void> {
await electronApp.evaluate(async ({ dialog }, selectedFilePath) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [selectedFilePath],
}) as any;
}, filePath);
}
export async function importM3uPlaylistFromNativeDialog(
app: LaunchedElectronApp,
filePath: string
): Promise<void> {
await stubNativePlaylistFileDialog(app.electronApp, filePath);
await openAddPlaylistMenu(app.mainWindow);
await app.mainWindow
.getByRole('menuitem', { name: 'Add via file upload' })
.click();
await app.mainWindow.locator('mat-dialog-container .file-upload').click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
}
export async function addXtreamPortal(
page: Page,
options: {
name?: string;
password?: string;
serverUrl?: string;
username?: string;
} = {}
): Promise<void> {
const {
name = defaultXtreamPortalName,
password = defaultXtreamPassword,
serverUrl = xtreamMockServer,
username = defaultXtreamUsername,
} = options;
await openAddPlaylistMenu(page);
await page.getByRole('menuitem', { name: 'Add Xtreme Code' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(serverUrl);
await dialog.locator('#username').fill(username);
await dialog.locator('#password').fill(password);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
export async function addStalkerPortal(
page: Page,
options: {
macAddress?: string;
name?: string;
portalUrl?: string;
} = {}
): Promise<void> {
const {
macAddress = defaultStalkerMacAddress,
name = defaultStalkerPortalName,
portalUrl = `${stalkerMockServer}/portal.php`,
} = options;
await openAddPlaylistMenu(page);
await page.getByRole('menuitem', { name: 'Add Stalker Portal' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.locator('#title').fill(name);
await dialog.locator('#portalUrl').fill(portalUrl);
await dialog.locator('#macAddress').fill(macAddress);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
export async function openSettings(page: Page): Promise<void> {
await page.getByRole('link', { name: 'Open settings' }).click();
await expect(page.getByTestId('settings-container')).toBeVisible();
}
export async function enableRemoteControl(
page: Page,
port: number
): Promise<void> {
const remoteControlCheckbox = page.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
);
await remoteControlCheckbox.scrollIntoViewIfNeeded();
await remoteControlCheckbox.check();
await page.locator('#remoteControlPort').fill(String(port));
}
export async function saveSettings(page: Page): Promise<void> {
await page.getByTestId('save-settings').click();
await expect(
page.getByText('Success! Configuration was saved.')
).toBeVisible();
}
export async function goToDashboard(page: Page): Promise<void> {
await page.getByRole('link', { name: 'Dashboard', exact: true }).click();
await page.waitForURL(/\/workspace\/dashboard$/);
}
export async function waitForXtreamCatalog(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/xtreams\/.+/);
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 20000 });
await categories.first().click();
const contentItems = page.locator(
'.content-card, [data-test-id="channel-item"], mat-card'
);
await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
}
export async function waitForStalkerCatalog(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/stalker\/.+/);
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 20000 });
await categories.first().click();
const contentItems = page.locator(
'.content-card, [data-test-id="channel-item"], mat-card'
);
await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
}
async function startPortalDebugCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const electronApi = window.electron as typeof window.electron & {
onPortalDebugEvent?: (
callback: (event: PortalDebugEvent) => void
) => (() => void) | undefined;
};
window.__portalDebugUnsubscribe?.();
window.__portalDebugEvents = [];
if (!electronApi.onPortalDebugEvent) {
return;
}
window.__portalDebugUnsubscribe = electronApi.onPortalDebugEvent(
(event: PortalDebugEvent) => {
window.__portalDebugEvents?.push(event as PortalDebugEvent);
}
);
});
}
export async function expectPortalDebugSuccess(
page: Page,
provider: PortalProvider
): Promise<void> {
await expect
.poll(
async () => {
return page.evaluate((targetProvider) => {
return (
window.__portalDebugEvents?.filter(
(event) =>
event.provider === targetProvider &&
event.status === 'success' &&
event.transport === 'electron-main'
).length ?? 0
);
}, provider);
},
{ timeout: 20000 }
)
.toBeGreaterThan(0);
}
export async function resetMockServers(
request: APIRequestContext,
providers: PortalProvider[]
): Promise<void> {
for (const provider of providers) {
const server =
provider === 'stalker' ? stalkerMockServer : xtreamMockServer;
const response = await request.post(`${server}/reset`);
expect(response.ok()).toBeTruthy();
}
}
@@ -0,0 +1,27 @@
import {
closeElectronApp,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
m3uFixturePath,
test,
} from './electron-test-fixtures';
test.describe('Electron Native Playlist Import', () => {
test('imports an M3U playlist via the native file picker path', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(4);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,68 @@
import {
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultStalkerPortalName,
defaultXtreamPortalName,
expect,
expectPortalDebugSuccess,
goToDashboard,
launchElectronApp,
resetMockServers,
stalkerMockServer,
test,
waitForStalkerCatalog,
waitForXtreamCatalog,
} from './electron-test-fixtures';
test.describe('Electron Provider Smoke Tests', () => {
test('loads Xtream content through the Electron IPC path', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await waitForXtreamCatalog(app.mainWindow);
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
await goToDashboard(app.mainWindow);
await expect(
app.mainWindow.getByRole('link', {
name: new RegExp(defaultXtreamPortalName, 'i'),
})
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('loads Stalker content through the Electron IPC path', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
portalUrl: `${stalkerMockServer}/portal.php`,
});
await waitForStalkerCatalog(app.mainWindow);
await expectPortalDebugSuccess(app.mainWindow, 'stalker');
await goToDashboard(app.mainWindow);
await expect(
app.mainWindow.getByRole('link', {
name: new RegExp(defaultStalkerPortalName, 'i'),
})
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,42 @@
import {
closeElectronApp,
enableRemoteControl,
expect,
launchElectronApp,
openSettings,
saveSettings,
test,
} from './electron-test-fixtures';
test.describe('Electron Settings', () => {
test('persists remote control settings across app restart', async ({
dataDir,
}) => {
const firstLaunch = await launchElectronApp(dataDir);
try {
await openSettings(firstLaunch.mainWindow);
await enableRemoteControl(firstLaunch.mainWindow, 8877);
await saveSettings(firstLaunch.mainWindow);
} finally {
await closeElectronApp(firstLaunch);
}
const secondLaunch = await launchElectronApp(dataDir);
try {
await openSettings(secondLaunch.mainWindow);
const remoteControlCheckbox = secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
);
await expect(remoteControlCheckbox).toBeChecked();
await expect(
secondLaunch.mainWindow.locator('#remoteControlPort')
).toHaveValue('8877');
} finally {
await closeElectronApp(secondLaunch);
}
});
});
+60 -155
View File
@@ -1,171 +1,76 @@
import {
_electron as electron,
ElectronApplication,
closeElectronApp,
expect,
Page,
launchElectronApp,
test,
} from '@playwright/test';
import { existsSync, mkdirSync } from 'fs';
import { join, resolve } from 'path';
/**
* Smoke test for the Electron application.
* Verifies that the app starts correctly and renders the main window.
*/
let electronApp: ElectronApplication;
let mainWindow: Page;
// Workspace root (where nx.json is located)
const workspaceRoot = resolve(__dirname, '../../..');
// Path to the packaged Electron app main.js file
const electronMainPath = join(
workspaceRoot,
'dist/apps/electron-backend/main.js'
);
// Path for screenshots
const screenshotDir = join(
workspaceRoot,
'dist/test-results/electron-backend-e2e'
);
// Ensure screenshot directory exists
if (!existsSync(screenshotDir)) {
mkdirSync(screenshotDir, { recursive: true });
}
/**
* Helper to find the main application window (not DevTools)
*/
async function findMainWindow(app: ElectronApplication): Promise<Page> {
// Wait for windows to be available
await new Promise((resolve) => setTimeout(resolve, 2000));
const windows = app.windows();
console.log(`Found ${windows.length} window(s)`);
for (const window of windows) {
const title = await window.title();
console.log(`Window title: "${title}"`);
// Skip DevTools windows
if (!title.includes('DevTools')) {
return window;
}
}
// If no non-DevTools window found, wait for one
console.log('Waiting for main window...');
return await app.firstWindow();
}
} from './electron-test-fixtures';
test.describe('Electron App Smoke Test', () => {
test.beforeAll(async () => {
console.log(`Launching Electron app from: ${electronMainPath}`);
console.log(`Platform: ${process.platform}`);
console.log(`CI: ${process.env['CI']}`);
test('app should start and display the dashboard', async ({ dataDir }) => {
const app = await launchElectronApp(dataDir);
// Check if web build exists
const webIndexPath = join(workspaceRoot, 'dist/apps/web/index.html');
console.log(`Web index path: ${webIndexPath}`);
console.log(`Web index exists: ${existsSync(webIndexPath)}`);
// Launch the Electron app
// On Linux CI, we need to disable sandbox due to permission issues
const args = [electronMainPath];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
}
electronApp = await electron.launch({
args,
env: {
...process.env,
NODE_ENV: 'test',
ELECTRON_IS_DEV: '0',
},
});
// Find the main application window (not DevTools)
mainWindow = await findMainWindow(electronApp);
// Wait for the window to be fully loaded
await mainWindow.waitForLoadState('domcontentloaded');
// Debug: Log the current URL
const url = mainWindow.url();
console.log(`Page URL: ${url}`);
// Wait for Angular to bootstrap and render
await mainWindow.waitForSelector('app-root', { timeout: 30000 });
// Wait for Angular to fully initialize and set the title
await mainWindow.waitForFunction(
() => document.title.length > 0,
{ timeout: 30000 }
);
console.log('Electron app launched successfully');
});
test.afterAll(async () => {
if (electronApp) {
await electronApp.close();
try {
await expect
.poll(async () => app.mainWindow.title())
.toContain('IPTVnator');
await expect(
app.mainWindow.getByRole('link', {
name: 'Dashboard',
exact: true,
})
).toBeVisible();
await expect(
app.mainWindow.getByRole('link', { name: 'Open settings' })
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('app should start and display main window', async () => {
expect(mainWindow).toBeTruthy();
test('app should expose the expected main window properties', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
await mainWindow.screenshot({
path: join(screenshotDir, 'smoke-test-screenshot.png'),
fullPage: true,
});
try {
const isVisible = await app.electronApp.evaluate(
async ({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isVisible() : false;
}
);
const title = await mainWindow.title();
console.log(`Window title: ${title}`);
expect(title).toContain('IPTVnator');
expect(isVisible).toBe(true);
const bounds = await app.electronApp.evaluate(
async ({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.getBounds() : null;
}
);
expect(bounds).not.toBeNull();
expect(bounds?.width).toBeGreaterThan(800);
expect(bounds?.height).toBeGreaterThan(600);
} finally {
await closeElectronApp(app);
}
});
test('app should have expected window properties', async () => {
const isVisible = await electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isVisible() : false;
});
expect(isVisible).toBe(true);
test('app should render workspace content', async ({ dataDir }) => {
const app = await launchElectronApp(dataDir);
const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.getBounds() : null;
});
expect(bounds).not.toBeNull();
expect(bounds?.width).toBeGreaterThan(800);
expect(bounds?.height).toBeGreaterThan(600);
await mainWindow.screenshot({
path: join(screenshotDir, 'window-properties-screenshot.png'),
});
});
test('app should render main content', async () => {
// Wait for app-root to have actual content
await mainWindow.waitForFunction(
() => {
const appRoot = document.querySelector('app-root');
return appRoot && appRoot.innerHTML.trim().length > 0;
},
{ timeout: 30000 }
);
const bodyContent = await mainWindow.locator('body').innerHTML();
expect(bodyContent.length).toBeGreaterThan(0);
await mainWindow.screenshot({
path: join(screenshotDir, 'rendered-content-screenshot.png'),
fullPage: true,
});
console.log('Smoke test completed successfully');
try {
await expect(
app.mainWindow.getByRole('button', {
name: 'Open command palette',
})
).toBeVisible();
await expect(
app.mainWindow.getByRole('button', { name: 'Add playlist' })
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});
+19 -23
View File
@@ -1,25 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts",
"src/**/*.e2e.ts"
]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.d.ts",
"src/**/*.e2e.ts"
]
}
@@ -1,4 +1,5 @@
import { Conf } from 'electron-conf/main';
import { getElectronConfigDirectory } from 'database';
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
@@ -13,4 +14,9 @@ export type StoreType = {
};
// Export singleton store instance
export const store = new Conf<StoreType>();
const electronConfigDirectory = getElectronConfigDirectory();
const storeOptions = electronConfigDirectory
? { dir: electronConfigDirectory }
: {};
export const store = new Conf<StoreType>(storeOptions);
@@ -1,7 +1,7 @@
import type BetterSqlite3 from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { getDatabasePath } from 'database';
import { createRequire } from 'module';
import { homedir } from 'os';
import { join } from 'path';
import { SaxesParser, SaxesTagPlain } from 'saxes';
import { Readable } from 'stream';
@@ -143,17 +143,6 @@ const loggerLabel = '[EPG Worker]';
const CHANNEL_BATCH_SIZE = 100;
const PROGRAM_BATCH_SIZE = 1000;
/**
* Get database file path (same as main app)
*/
function getDatabasePath(): string {
const dbDir = join(homedir(), '.iptvnator', 'databases');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'iptvnator.db');
}
/**
* Database helper class for EPG operations
* Creates its own connection to avoid blocking main thread
+7
View File
@@ -1,4 +1,5 @@
import { app, BrowserWindow } from 'electron';
import { getElectronUserDataPath } from 'database';
import fixPath from 'fix-path';
import App from './app/app';
import { initDatabase } from './app/database/connection';
@@ -20,6 +21,12 @@ import XtreamEvents from './app/events/xtream.events';
app.setName('iptvnator');
const electronUserDataPath = getElectronUserDataPath();
if (electronUserDataPath) {
app.setPath('userData', electronUserDataPath);
}
export default class Main {
static initialize() {
if (SquirrelEvents.handleEvents()) {
+49 -48
View File
@@ -15,55 +15,56 @@ const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run local dev servers before starting the tests.
* Both the Angular app and the Stalker mock server start in parallel.
* Set MOCK_PORT to override the default mock server port (3210).
*/
webServer: [
{
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run stalker-mock-server:serve',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run xtream-mock-server:serve',
url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
],
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
...nxE2EPreset(__filename, { testDir: './src' }),
testMatch: ['**/*.e2e.ts'],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run local dev servers before starting the tests.
* Both the Angular app and the Stalker mock server start in parallel.
* Set MOCK_PORT to override the default mock server port (3210).
*/
webServer: [
{
command: 'pnpm nx run web:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run stalker-mock-server:serve',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run xtream-mock-server:serve',
url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
],
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Uncomment for mobile browsers support
/* {
// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
@@ -72,8 +73,8 @@ export default defineConfig({
use: { ...devices['iPhone 12'] },
}, */
// Uncomment for branded browsers
/* {
// Uncomment for branded browsers
/* {
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
@@ -81,5 +82,5 @@ export default defineConfig({
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
} */
],
],
});
+13 -7
View File
@@ -1,9 +1,15 @@
{
"name": "web-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/web-e2e/src",
"implicitDependencies": ["web"],
"// targets": "to see all targets run: nx show project web-e2e --web",
"targets": {}
"name": "web-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/web-e2e/src",
"implicitDependencies": [
"web",
"stalker-mock-server",
"xtream-mock-server"
],
"// targets": "to see all targets run: nx show project web-e2e --web",
"targets": {
"e2e": {}
}
}
+11 -4
View File
@@ -1,4 +1,7 @@
import { expect, test } from '@playwright/test';
import { join } from 'path';
const fixturePath = join(__dirname, 'fixtures/test.m3u');
test('basic test', async ({ page }) => {
await page.goto('/');
@@ -7,8 +10,12 @@ test('basic test', async ({ page }) => {
expect(await page.title()).toBe('IPTVnator');
// Upload playlist test
await page.getByTestId('add-playlist').click();
await page.click('"Add via file upload"');
await page.setInputFiles('input[type="file"]', './e2e/fixtures/test.m3u');
await expect(page.getByTestId('channel-item')).toHaveCount(4);
await page.getByRole('button', { name: 'Add playlist' }).click();
await page.getByRole('menuitem', { name: 'Add via file upload' }).click();
await page.setInputFiles('input[type="file"]', fixturePath);
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await expect(page.getByText('test.m3u')).toBeVisible();
await expect(page.getByText('4 channels')).toBeVisible();
await expect(page.getByText('1. Channel 1')).toBeVisible();
await expect(page.getByText('4. HappyKids TV')).toBeVisible();
});
+43 -41
View File
@@ -1,4 +1,12 @@
import { expect, test } from '@playwright/test';
import { expect, Page, test } from '@playwright/test';
import { join } from 'path';
async function openSettings(page: Page) {
await page.locator('a[href$="/workspace/settings"]').click();
await page.waitForURL(/\/workspace\/settings$/);
await expect(page.locator('.settings-container')).toBeVisible();
await expect(page.locator('.settings-back-button')).toBeVisible();
}
test.describe('Settings', () => {
test.beforeEach(async ({ page }) => {
@@ -17,68 +25,62 @@ test.describe('Settings', () => {
});
test('Check settings page', async ({ page }) => {
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await expect(page.getByTestId('settings-container')).toBeVisible();
await page.getByTestId('back-to-home').click();
await openSettings(page);
await page.locator('.settings-back-button').click();
});
test('Change video player', async ({ page }) => {
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await openSettings(page);
await expect(page.locator('text="VideoJs Player"')).toBeVisible();
await page.getByTestId('select-video-player').click();
await page.getByTestId('html5').click();
await expect(page.getByText('Video.js player')).toBeVisible();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'HTML5 video player' }).click();
await page.getByTestId('save-settings').click();
await page.getByTestId('back-to-home').click();
await page.locator('button[type="submit"]').click();
await page.locator('.settings-back-button').click();
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await openSettings(page);
await expect(page.locator('text="HTML5 Video Player"')).toBeVisible();
await expect(page.getByText('HTML5 video player')).toBeVisible();
});
test('Change app theme', async ({ page }) => {
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await expect(page.getByTestId('SYSTEM_THEME')).toHaveAttribute(
'aria-checked',
'true'
);
await page.getByTestId('DARK_THEME').click();
await openSettings(page);
await expect(
page.getByRole('radio', { name: 'System theme' })
).toHaveAttribute('aria-checked', 'true');
await page.getByRole('radio', { name: 'Dark theme' }).click();
await page.getByTestId('save-settings').click();
await page.getByTestId('back-to-home').click();
await page.locator('button[type="submit"]').click();
await page.locator('.settings-back-button').click();
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await openSettings(page);
await expect(page.getByTestId('DARK_THEME')).toHaveAttribute(
'aria-checked',
'true'
);
await expect(
page.getByRole('radio', { name: 'Dark theme' })
).toHaveAttribute('aria-checked', 'true');
});
test('Change app language', async ({ page }) => {
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await expect(page.locator('text="English"')).toBeVisible();
await page.getByTestId('select-language').click();
await page.getByTestId('de').click();
await openSettings(page);
await expect(page.getByText('English')).toBeVisible();
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Deutsch' }).click();
await page.getByTestId('save-settings').click();
await page.getByTestId('back-to-home').click();
await page.getByTestId('pwa-menu').click();
await page.getByTestId('pwa-open-settings').click();
await page.locator('button[type="submit"]').click();
await page.locator('.settings-back-button').click();
await openSettings(page);
await expect(page.locator('text="Deutsch"')).toBeVisible();
await expect(page.getByText('Deutsch')).toBeVisible();
});
test.afterEach(async ({ page }, testInfo) => {
await page.screenshot({
path: `./e2e/screenshots/settings/${testInfo.title}.png`,
path: join(
process.cwd(),
'dist/.playwright/apps/web-e2e/screenshots/settings',
`${testInfo.title}.png`
),
});
});
});
+15 -10
View File
@@ -59,14 +59,15 @@ async function addStalkerPortal(
): Promise<void> {
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByTestId('add-playlist').click();
await page.getByRole('button', { name: 'Add playlist' }).click();
await page.getByText('Stalker Portal').click();
const dialog = page.locator('mat-dialog-container');
await page.locator('#title').fill(name);
await page.locator('#portalUrl').fill(PORTAL_URL);
await page.locator('#macAddress').fill(mac);
await dialog.locator('#title').fill(name);
await dialog.locator('#portalUrl').fill(PORTAL_URL);
await dialog.locator('#macAddress').fill(mac);
await page.getByRole('button', { name: 'Add' }).click();
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
// Wait for dialog to close
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
@@ -133,7 +134,7 @@ test('@stalker VOD — categories load from mock server', async ({ page }) => {
await page.waitForURL(/stalker.*vod/);
// Default scenario has 8 VOD categories (+ 1 "All categories" prepended by the store)
const categoryItems = page.locator('mat-list-item, [class*="category"]');
const categoryItems = page.locator('.category-item, [class*="category"]');
await expect(categoryItems.first()).toBeVisible({ timeout: 10_000 });
const count = await categoryItems.count();
expect(count).toBeGreaterThanOrEqual(8);
@@ -147,11 +148,13 @@ test('@stalker VOD — content list loads after selecting a category', async ({
await page.waitForURL(/stalker.*vod/);
// Click the first non-"All" category
const categories = page.locator('mat-list-item');
const categories = page.locator('.category-item');
await categories.first().click();
// Content grid / list should appear with items
const contentItems = page.locator('[class*="content-item"], mat-card');
const contentItems = page.locator(
'.content-card, [data-test-id="channel-item"], mat-card'
);
await expect(contentItems.first()).toBeVisible({ timeout: 10_000 });
const itemCount = await contentItems.count();
expect(itemCount).toBeGreaterThan(0);
@@ -166,7 +169,7 @@ test('@stalker minimal scenario — correct item counts', async ({ page }) => {
await page.waitForURL(/stalker.*vod/);
// Minimal scenario: 2 categories (+ "All" = 3 visible)
const categories = page.locator('mat-list-item');
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
const count = await categories.count();
// At least 2 real categories
@@ -183,7 +186,9 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
await page.waitForURL(/stalker.*itv/);
// Wait for channels to appear
const channels = page.locator('mat-list-item, [class*="channel"]');
const channels = page.locator(
'[data-test-id="channel-item"], [class*="channel"]'
);
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
// Click a channel — EPG info should appear
+7 -6
View File
@@ -54,15 +54,16 @@ async function addXtreamPortal(
password = DEFAULT_PASSWORD,
} = options;
await page.getByTestId('add-playlist').click();
await page.getByRole('button', { name: 'Add playlist' }).click();
await page.getByText('Xtream').click();
const dialog = page.locator('mat-dialog-container');
await page.locator('#title').fill(name);
await page.locator('#serverUrl').fill(MOCK_SERVER);
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(MOCK_SERVER);
await dialog.locator('#username').fill(username);
await dialog.locator('#password').fill(password);
await page.getByRole('button', { name: 'Add' }).click();
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
+19 -22
View File
@@ -1,24 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts"
]
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.d.ts",
"src/**/*.e2e.ts"
]
}
@@ -109,9 +109,7 @@
role="radio"
(click)="selectTheme(themeOption.value)"
>
<mat-icon>{{
themeOption.icon
}}</mat-icon>
<mat-icon>{{ themeOption.icon }}</mat-icon>
<span>{{
themeOption.labelKey | translate
}}</span>
@@ -487,6 +485,7 @@
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="remoteControl"
data-test-id="remote-control-enabled"
></mat-checkbox>
</div>
</div>
@@ -519,6 +518,7 @@
matInput
type="number"
id="remoteControlPort"
data-test-id="remote-control-port"
formControlName="remoteControlPort"
min="1"
max="65535"
+1
View File
@@ -6,3 +6,4 @@
export * from './lib/schema';
export * from './lib/connection';
export * from './lib/path-utils';
+4 -12
View File
@@ -1,7 +1,8 @@
/**
* Database connection and initialization for IPTVnator
* Uses Drizzle ORM with better-sqlite3
* Stores database file under ~/.iptvnator/databases/
* Stores database file under ~/.iptvnator/databases/ by default.
* E2E tests can override the root with IPTVNATOR_E2E_DATA_DIR.
*
* Provides two connection modes:
* - Full access (for electron-backend): creates tables, read-write
@@ -11,10 +12,8 @@
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import * as schema from './schema';
import { getIptvnatorDatabasePath } from './path-utils';
export type DatabaseInstance = BetterSQLite3Database<typeof schema>;
@@ -26,14 +25,7 @@ let initPromise: Promise<DatabaseInstance> | null = null;
* Get the database file path
*/
export function getDatabasePath(): string {
const dbDir = join(homedir(), '.iptvnator', 'databases');
// Ensure the directory exists
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'iptvnator.db');
return getIptvnatorDatabasePath();
}
/**
@@ -0,0 +1,51 @@
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
export const IPTVNATOR_E2E_DATA_DIR_ENV = 'IPTVNATOR_E2E_DATA_DIR';
function ensureDirectory(dirPath: string): string {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
return dirPath;
}
export function getIptvnatorDataRoot(): string {
const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim();
if (e2eDataDir) {
return ensureDirectory(e2eDataDir);
}
return ensureDirectory(join(homedir(), '.iptvnator'));
}
export function getIptvnatorDatabaseDirectory(): string {
return ensureDirectory(join(getIptvnatorDataRoot(), 'databases'));
}
export function getIptvnatorDatabasePath(): string {
return join(getIptvnatorDatabaseDirectory(), 'iptvnator.db');
}
export function getElectronUserDataPath(): string | null {
const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim();
if (!e2eDataDir) {
return null;
}
return ensureDirectory(join(e2eDataDir, 'user-data'));
}
export function getElectronConfigDirectory(): string | null {
const e2eDataDir = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]?.trim();
if (!e2eDataDir) {
return null;
}
return ensureDirectory(join(e2eDataDir, 'config'));
}