mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
0c451b0dd7
commit
81c2aab829
23 files changed
+955
-443
No files matched your search
@@ -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
|
||||
@@ -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: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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' },
|
||||
} */
|
||||
],
|
||||
],
|
||||
});
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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`
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
|
||||
export * from './lib/schema';
|
||||
export * from './lib/connection';
|
||||
export * from './lib/path-utils';
|
||||
@@ -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'));
|
||||
}
|
||||
Reference in new issue
Block a user