diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 2ae7da4b6..3f7876809 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -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 diff --git a/apps/electron-backend-e2e/playwright.config.ts b/apps/electron-backend-e2e/playwright.config.ts index 47244390a..94137651e 100644 --- a/apps/electron-backend-e2e/playwright.config.ts +++ b/apps/electron-backend-e2e/playwright.config.ts @@ -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: {}, + }, + ], }); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index c239c4432..644eae15f 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -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"] + } } - } } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts new file mode 100644 index 000000000..634ede935 --- /dev/null +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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({ + 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 { + 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 { + try { + await app.electronApp.close(); + } catch (error) { + console.warn('Failed to close Electron app cleanly:', error); + } +} + +async function findMainWindow(app: ElectronApplication): Promise { + 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 { + 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 { + await page.getByRole('button', { name: 'Add playlist' }).click(); +} + +export async function stubNativePlaylistFileDialog( + electronApp: ElectronApplication, + filePath: string +): Promise { + 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 { + 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 { + 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 { + 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 { + await page.getByRole('link', { name: 'Open settings' }).click(); + await expect(page.getByTestId('settings-container')).toBeVisible(); +} + +export async function enableRemoteControl( + page: Page, + port: number +): Promise { + 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 { + await page.getByTestId('save-settings').click(); + await expect( + page.getByText('Success! Configuration was saved.') + ).toBeVisible(); +} + +export async function goToDashboard(page: Page): Promise { + await page.getByRole('link', { name: 'Dashboard', exact: true }).click(); + await page.waitForURL(/\/workspace\/dashboard$/); +} + +export async function waitForXtreamCatalog(page: Page): Promise { + 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 { + 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 { + 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 { + 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 { + for (const provider of providers) { + const server = + provider === 'stalker' ? stalkerMockServer : xtreamMockServer; + const response = await request.post(`${server}/reset`); + expect(response.ok()).toBeTruthy(); + } +} diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts new file mode 100644 index 000000000..579cb6dd9 --- /dev/null +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -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); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts new file mode 100644 index 000000000..88ffe5054 --- /dev/null +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -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); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts new file mode 100644 index 000000000..487a3ef8b --- /dev/null +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -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); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/smoke.e2e.ts b/apps/electron-backend-e2e/src/smoke.e2e.ts index 7d5ea35a9..a7167e8ac 100644 --- a/apps/electron-backend-e2e/src/smoke.e2e.ts +++ b/apps/electron-backend-e2e/src/smoke.e2e.ts @@ -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 { - // 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); + } }); }); diff --git a/apps/electron-backend-e2e/tsconfig.json b/apps/electron-backend-e2e/tsconfig.json index 32bbdccb5..dcc6815b6 100644 --- a/apps/electron-backend-e2e/tsconfig.json +++ b/apps/electron-backend-e2e/tsconfig.json @@ -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" + ] } diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index e93033fce..1b026af08 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.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(); +const electronConfigDirectory = getElectronConfigDirectory(); +const storeOptions = electronConfigDirectory + ? { dir: electronConfigDirectory } + : {}; + +export const store = new Conf(storeOptions); diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index 5601b526f..b549d9b06 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -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 diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 36f07eb42..ad5949966 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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()) { diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 578d073f0..c1045404c 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -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' }, } */ - ], + ], }); diff --git a/apps/web-e2e/project.json b/apps/web-e2e/project.json index f61bd5674..b7417356f 100644 --- a/apps/web-e2e/project.json +++ b/apps/web-e2e/project.json @@ -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": {} + } } diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index a87989c70..e17d70031 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -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(); }); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index ce673d207..22b6d9331 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -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` + ), }); }); }); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 20d5c593c..20671e032 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -59,14 +59,15 @@ async function addStalkerPortal( ): Promise { 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 diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 10b89c9f7..ae81ae00a 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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' }); } diff --git a/apps/web-e2e/tsconfig.json b/apps/web-e2e/tsconfig.json index 0b670c679..dcc6815b6 100644 --- a/apps/web-e2e/tsconfig.json +++ b/apps/web-e2e/tsconfig.json @@ -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" + ] } diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index f657af822..00dce0482 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -109,9 +109,7 @@ role="radio" (click)="selectTheme(themeOption.value)" > - {{ - themeOption.icon - }} + {{ themeOption.icon }} {{ themeOption.labelKey | translate }} @@ -487,6 +485,7 @@
@@ -519,6 +518,7 @@ matInput type="number" id="remoteControlPort" + data-test-id="remote-control-port" formControlName="remoteControlPort" min="1" max="65535" diff --git a/libs/shared/database/src/index.ts b/libs/shared/database/src/index.ts index 3734f7440..9df4dff04 100644 --- a/libs/shared/database/src/index.ts +++ b/libs/shared/database/src/index.ts @@ -6,3 +6,4 @@ export * from './lib/schema'; export * from './lib/connection'; +export * from './lib/path-utils'; diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index c46deece9..a390c6dc0 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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; @@ -26,14 +25,7 @@ let initPromise: Promise | 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(); } /** diff --git a/libs/shared/database/src/lib/path-utils.ts b/libs/shared/database/src/lib/path-utils.ts new file mode 100644 index 000000000..f36471c6e --- /dev/null +++ b/libs/shared/database/src/lib/path-utils.ts @@ -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')); +}