Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import playwright from 'eslint-plugin-playwright';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
playwright.configs['flat/recommended'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.js'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { nxE2EPreset } from '@nx/playwright/preset';
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
|
||||
// For CI, you may want to set BASE_URL to the deployed application.
|
||||
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
...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: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
// Uncomment for mobile browsers support
|
||||
/* {
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
}, */
|
||||
|
||||
// Uncomment for branded browsers
|
||||
/* {
|
||||
name: 'Microsoft Edge',
|
||||
use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
},
|
||||
{
|
||||
name: 'Google Chrome',
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
} */
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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('/');
|
||||
|
||||
// Basic checks
|
||||
expect(await page.title()).toBe('IPTVnator');
|
||||
|
||||
// Upload playlist test
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Add via file upload' }).click();
|
||||
await page.setInputFiles('input[type="file"]', fixturePath);
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add playlist',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled();
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
addButton.click(),
|
||||
]);
|
||||
await expect(page.getByText('test', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('4 channels')).toBeVisible();
|
||||
await expect(page.getByText('1. Channel 1')).toBeVisible();
|
||||
await expect(page.getByText('4. HappyKids TV')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
#EXTM3U url-tvg="http://xml-url/path/full.xml.gz"
|
||||
#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" catchup="shift" catchup-days="3" timeshift="3" group-title="News", Channel 1
|
||||
#EXTGRP:News
|
||||
https://example.channels/path-to-file/1.m3u8
|
||||
#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" catchup="shift" catchup-days="3" timeshift="3" group-title="News", Positive News TV
|
||||
#EXTGRP:News
|
||||
https://example.channels/path-to-file/2.m3u8
|
||||
#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" catchup="shift" catchup-days="3" timeshift="3" group-title="Sport", Sport TVX
|
||||
#EXTGRP:Sport
|
||||
https://example.channels/path-to-file/3.m3u8
|
||||
#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" catchup="shift" catchup-days="3" timeshift="3" group-title="Kids", HappyKids TV
|
||||
@@ -0,0 +1,94 @@
|
||||
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();
|
||||
}
|
||||
|
||||
async function saveSettings(page: Page) {
|
||||
const saveButton = page.locator('[data-test-id="save-settings"]');
|
||||
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
}
|
||||
|
||||
test.describe('Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Playwright creates a fresh browser context per test, so extra
|
||||
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('Check settings page', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
await page.locator('.settings-back-button').click();
|
||||
});
|
||||
|
||||
test('Change video player', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
|
||||
const playerSelect = page.locator('[data-test-id="select-video-player"]');
|
||||
|
||||
await expect(playerSelect).toContainText(
|
||||
/Video\.js/i
|
||||
);
|
||||
await playerSelect.click();
|
||||
await page.locator('mat-option[data-test-id="html5"]').click();
|
||||
|
||||
await saveSettings(page);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
|
||||
await expect(playerSelect).toContainText(
|
||||
/HTML5/i
|
||||
);
|
||||
});
|
||||
|
||||
test('Change app theme', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
await expect(
|
||||
page.getByRole('radio', { name: 'System theme' })
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
await page.getByRole('radio', { name: 'Dark theme' }).click();
|
||||
|
||||
await saveSettings(page);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('radio', { name: 'Dark theme' })
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
test('Change app language', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
const languageSelect = page.locator('[data-test-id="select-language"]');
|
||||
|
||||
await expect(languageSelect).toContainText(
|
||||
'English'
|
||||
);
|
||||
await languageSelect.click();
|
||||
await page.locator('mat-option[data-test-id="de"]').click();
|
||||
|
||||
await saveSettings(page);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
|
||||
await expect(languageSelect).toContainText(
|
||||
'Deutsch'
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await page.screenshot({
|
||||
path: join(
|
||||
process.cwd(),
|
||||
'dist/.playwright/apps/web-e2e/screenshots/settings',
|
||||
`${testInfo.title}.png`
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { expect, Locator, Page, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Stalker Portal E2E Tests
|
||||
*
|
||||
* These tests use the stalker-mock-server (apps/stalker-mock-server) to simulate
|
||||
* a real Stalker portal. The mock server starts automatically alongside the Angular
|
||||
* dev server when running e2e tests (see playwright.config.ts).
|
||||
*
|
||||
* Default scenario MAC (00:1A:79:00:00:01) provides:
|
||||
* - 8 categories per content type (VOD / Series / ITV)
|
||||
* - 40 items per category
|
||||
* - 3 seasons × 8 episodes per series item
|
||||
*
|
||||
* Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker"
|
||||
*/
|
||||
|
||||
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
||||
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
|
||||
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
|
||||
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
|
||||
|
||||
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
|
||||
const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
|
||||
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
|
||||
const MINIMAL_MAC = '00:1A:79:00:00:03';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept calls to the Angular dev backend (/stalker proxy) and redirect
|
||||
* them to the mock server. This avoids needing a real backend or changing
|
||||
* any app environment configuration.
|
||||
*/
|
||||
async function interceptStalkerRequests(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/stalker**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(BACKEND_PROXY);
|
||||
// Forward all query params unchanged
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
async function setInputValue(input: Locator, value: string): Promise<void> {
|
||||
await input.fill('');
|
||||
await input.fill(value);
|
||||
|
||||
if ((await input.inputValue()) === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await input.click();
|
||||
await input.press('ControlOrMeta+A');
|
||||
await input.press('Backspace');
|
||||
await input.type(value);
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stalker portal via the UI:
|
||||
* 1. Click the "add playlist" button to open the unified dialog
|
||||
* 2. Select "Stalker" toggle
|
||||
* 3. Fill in the form and submit
|
||||
*/
|
||||
async function addStalkerPortal(
|
||||
page: Page,
|
||||
options: { name?: string; mac?: string } = {}
|
||||
): Promise<void> {
|
||||
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Stalker', exact: true }).click();
|
||||
|
||||
await setInputValue(dialog.locator('input#title'), name);
|
||||
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
|
||||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
// Wait for dialog to close
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/stalker.*vod/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
// Reset mock server state (clears in-memory favorites and cache)
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
|
||||
// Playwright creates a fresh browser context per test, so extra
|
||||
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
||||
await page.goto('/');
|
||||
|
||||
// Redirect backend proxy calls to the mock server
|
||||
await interceptStalkerRequests(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('@stalker health check — mock server is running', async ({ request }) => {
|
||||
const response = await request.get(`${MOCK_SERVER}/health`);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
});
|
||||
|
||||
test('@stalker add a Stalker portal and see it in the playlist list', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page, { name: 'My Test Portal' });
|
||||
|
||||
// Portal card should appear on the home page
|
||||
await expect(
|
||||
page.getByText('My Test Portal', { exact: false })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker VOD — categories load from mock server', async ({ page }) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
// Default scenario has 8 VOD categories (+ 1 "All categories" prepended by the store)
|
||||
const categoryItems = page.locator('.category-item');
|
||||
await expect(categoryItems.first()).toBeVisible({ timeout: 10_000 });
|
||||
const count = await categoryItems.count();
|
||||
expect(count).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
|
||||
test('@stalker VOD — content list loads after selecting a category', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
// Click the first non-"All" category
|
||||
const categories = page.locator('.category-item');
|
||||
await categories.nth(1).click();
|
||||
|
||||
// Content grid / list should appear with items
|
||||
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);
|
||||
});
|
||||
|
||||
test('@stalker minimal scenario — correct item counts', async ({ page }) => {
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Minimal Portal',
|
||||
mac: MINIMAL_MAC,
|
||||
});
|
||||
|
||||
// Minimal scenario: 2 categories (+ "All" = 3 visible)
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
const count = await categories.count();
|
||||
// At least 2 real categories
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('@stalker EPG data loads for ITV channel', async ({ page }) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
const shortEpgRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('action=get_short_epg')) {
|
||||
shortEpgRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to ITV tab
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
// ITV view requires an explicit category selection before channels render
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
// Wait for channels to appear
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
expect(shortEpgRequests).toHaveLength(0);
|
||||
|
||||
// Click a channel — EPG info should appear
|
||||
await channels.first().click();
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
await expect(page.locator('app-epg-list')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator('app-epg-list .selected-date')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
const epgInfoRequests: string[] = [];
|
||||
const shortEpgRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('action=get_epg_info')) {
|
||||
epgInfoRequests.push(request.url());
|
||||
}
|
||||
if (request.url().includes('action=get_short_epg')) {
|
||||
shortEpgRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.nth(1)).toBeVisible({ timeout: 20_000 });
|
||||
expect(shortEpgRequests).toHaveLength(0);
|
||||
|
||||
await channels.first().click();
|
||||
await expect(page.locator('app-epg-list')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect
|
||||
.poll(() => epgInfoRequests.length, { timeout: 20_000 })
|
||||
.toBe(1);
|
||||
|
||||
await channels.nth(1).click();
|
||||
await expect(channels.nth(1)).toHaveClass(/active/, { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(() => epgInfoRequests.length, { timeout: 20_000 })
|
||||
.toBe(1);
|
||||
expect(shortEpgRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('@stalker mock server reset clears cached state', async ({ request }) => {
|
||||
// Generate data for default MAC
|
||||
const before = await request.get(
|
||||
`${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}`
|
||||
);
|
||||
expect(before.ok()).toBeTruthy();
|
||||
|
||||
// Reset
|
||||
const reset = await request.post(`${MOCK_SERVER}/reset`);
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
|
||||
// Data is regenerated identically (deterministic seed)
|
||||
const after = await request.get(
|
||||
`${MOCK_SERVER}/stalker?action=get_categories&type=vod&macAddress=${DEFAULT_MAC}`
|
||||
);
|
||||
const beforeBody = (await before.json()).payload.js;
|
||||
const afterBody = (await after.json()).payload.js;
|
||||
expect(afterBody).toEqual(beforeBody);
|
||||
});
|
||||
|
||||
test('@stalker create_link returns a playable stream URL', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/stalker?action=create_link&cmd=ffrt4://vod/20001/index.m3u8&macAddress=${DEFAULT_MAC}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
const streamUrl: string = body.payload.js.cmd;
|
||||
expect(streamUrl).toMatch(/^https?:\/\//);
|
||||
expect(streamUrl).toMatch(/\.m3u8$/);
|
||||
});
|
||||
|
||||
test('@stalker series — seasons load for a series item', async ({
|
||||
request,
|
||||
}) => {
|
||||
// First fetch a series item to get its ID
|
||||
const listResponse = await request.get(
|
||||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&category=3001&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
|
||||
);
|
||||
const listBody = await listResponse.json();
|
||||
const firstItem = listBody.payload.js.data[0];
|
||||
expect(firstItem).toBeDefined();
|
||||
|
||||
// Fetch seasons for the first series item
|
||||
const seasonsResponse = await request.get(
|
||||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=series&movie_id=${firstItem.id}&macAddress=${DEFAULT_MAC}`
|
||||
);
|
||||
const seasonsBody = await seasonsResponse.json();
|
||||
const seasons = seasonsBody.payload.js;
|
||||
expect(Array.isArray(seasons)).toBeTruthy();
|
||||
// Default scenario has 3 seasons per series
|
||||
expect(seasons.length).toBe(3);
|
||||
expect(seasons[0].name).toBe('Season 1');
|
||||
expect(Array.isArray(seasons[0].series)).toBeTruthy();
|
||||
// Default scenario has 8 episodes per season
|
||||
expect(seasons[0].series.length).toBe(8);
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
import { APIRequestContext, test, expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Xtream Codes E2E Tests
|
||||
*
|
||||
* These tests use the xtream-mock-server (apps/xtream-mock-server) to simulate
|
||||
* a real Xtream Codes API portal. The mock server starts automatically alongside
|
||||
* the Angular dev server when running e2e tests (see playwright.config.ts).
|
||||
*
|
||||
* Default scenario (user1:pass1) provides:
|
||||
* - 8 categories per content type (live / VOD / series)
|
||||
* - 40 items per category
|
||||
* - 3 seasons × 8 episodes per series item
|
||||
*
|
||||
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
|
||||
*/
|
||||
|
||||
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
|
||||
const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
|
||||
/** Default scenario credentials */
|
||||
const DEFAULT_USERNAME = 'user1';
|
||||
const DEFAULT_PASSWORD = 'pass1';
|
||||
const EPG_USERNAME = 'epg';
|
||||
const EPG_PASSWORD = 'epg';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
|
||||
* to the mock server. This avoids any real backend requirement.
|
||||
*/
|
||||
async function interceptXtreamRequests(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const originalUrl = new URL(route.request().url());
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
originalUrl.searchParams.forEach((value, key) => {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
});
|
||||
await route.continue({ url: mockUrl.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an Xtream portal via the UI.
|
||||
*/
|
||||
async function addXtreamPortal(
|
||||
page: Page,
|
||||
options: { name?: string; username?: string; password?: string } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
name = 'Mock Xtream Portal',
|
||||
username = DEFAULT_USERNAME,
|
||||
password = DEFAULT_PASSWORD,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('tab', { name: 'Xtream', exact: true }).click();
|
||||
|
||||
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 dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
|
||||
// Playwright creates a fresh browser context per test, so extra
|
||||
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
||||
await page.goto('/');
|
||||
|
||||
await interceptXtreamRequests(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('@xtream health check — mock server is running', async ({ request }) => {
|
||||
const response = await request.get(`${MOCK_SERVER}/health`);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.server).toBe('xtream-mock-server');
|
||||
});
|
||||
|
||||
test('@xtream get_account_info — active account returns correct fields', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.user_info.username).toBe(DEFAULT_USERNAME);
|
||||
expect(body.user_info.status).toBe('Active');
|
||||
expect(body.server_info.url).toBeDefined();
|
||||
expect(Array.isArray(body.user_info.allowed_output_formats)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('@xtream get_account_info — expired account returns Expired status', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=expired&password=expired`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
// Expired scenario: still returns 'Active' but exp_date is in the past
|
||||
// The app's portal-status.service.ts detects expiry via exp_date, not status string
|
||||
expect(body.user_info.status).toBe('Active');
|
||||
const expDate = new Date(parseInt(body.user_info.exp_date) * 1000);
|
||||
expect(expDate.getFullYear()).toBe(2020);
|
||||
});
|
||||
|
||||
test('@xtream get_live_categories — returns expected category count', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const categories = await response.json();
|
||||
expect(Array.isArray(categories)).toBeTruthy();
|
||||
// Default scenario: 8 live categories
|
||||
expect(categories.length).toBe(8);
|
||||
expect(categories[0]).toHaveProperty('category_id');
|
||||
expect(categories[0]).toHaveProperty('category_name');
|
||||
});
|
||||
|
||||
test('@xtream get_live_streams — streams have required fields', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const streams = await response.json();
|
||||
expect(Array.isArray(streams)).toBeTruthy();
|
||||
expect(streams.length).toBeGreaterThan(0);
|
||||
|
||||
const first = streams[0];
|
||||
expect(first.stream_type).toBe('live');
|
||||
expect(first.stream_id).toBeGreaterThan(0);
|
||||
expect(first.epg_channel_id).toMatch(/channel-\d+\.mock/);
|
||||
});
|
||||
|
||||
test('@xtream get_vod_streams — streams have rating and container_extension', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const streams = await response.json();
|
||||
expect(Array.isArray(streams)).toBeTruthy();
|
||||
|
||||
const first = streams[0];
|
||||
expect(first.stream_type).toBe('movie');
|
||||
expect(typeof first.rating).toBe('number');
|
||||
expect(['mkv', 'mp4', 'avi']).toContain(first.container_extension);
|
||||
});
|
||||
|
||||
test('@xtream get_series — series list has correct structure', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const series = await response.json();
|
||||
expect(Array.isArray(series)).toBeTruthy();
|
||||
expect(series.length).toBeGreaterThan(0);
|
||||
|
||||
const first = series[0];
|
||||
expect(first.series_id).toBeGreaterThan(0);
|
||||
expect(typeof first.name).toBe('string');
|
||||
expect(typeof first.cover).toBe('string');
|
||||
expect(Array.isArray(first.backdrop_path)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('@xtream get_series_info — seasons and episodes present', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Get a series ID first
|
||||
const listResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series`
|
||||
);
|
||||
const series = await listResponse.json();
|
||||
const firstSeriesId = series[0].series_id;
|
||||
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_info&series_id=${firstSeriesId}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const info = await response.json();
|
||||
|
||||
expect(Array.isArray(info.seasons)).toBeTruthy();
|
||||
// Default scenario: 3 seasons per series
|
||||
expect(info.seasons.length).toBe(3);
|
||||
expect(info.seasons[0].name).toBe('Season 1');
|
||||
|
||||
// Episodes are keyed by season number string
|
||||
expect(info.episodes['1']).toBeDefined();
|
||||
expect(Array.isArray(info.episodes['1'])).toBeTruthy();
|
||||
// Default scenario: 8 episodes per season
|
||||
expect(info.episodes['1'].length).toBe(8);
|
||||
expect(info.episodes['1'][0].episode_num).toBe(1);
|
||||
});
|
||||
|
||||
test('@xtream get_vod_info — returns full movie details', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Get a VOD stream ID first
|
||||
const listResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_streams`
|
||||
);
|
||||
const streams = await listResponse.json();
|
||||
const firstVodId = streams[0].stream_id;
|
||||
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_info&vod_id=${firstVodId}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const details = await response.json();
|
||||
|
||||
expect(details.info).toBeDefined();
|
||||
expect(details.movie_data).toBeDefined();
|
||||
expect(details.info.duration_secs).toBeGreaterThan(0);
|
||||
expect(details.movie_data.stream_id).toBe(firstVodId);
|
||||
});
|
||||
|
||||
test('@xtream get_short_epg — returns base64-encoded listings', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Get a live stream ID first
|
||||
const listResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams`
|
||||
);
|
||||
const streams = await listResponse.json();
|
||||
const streamId = streams[0].stream_id;
|
||||
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_short_epg&stream_id=${streamId}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
|
||||
expect(Array.isArray(body.epg_listings)).toBeTruthy();
|
||||
expect(body.epg_listings.length).toBeGreaterThan(0);
|
||||
|
||||
const listing = body.epg_listings[0];
|
||||
// Verify base64 encoding — decode and check it's valid text
|
||||
const decodedTitle = Buffer.from(listing.title, 'base64').toString('utf-8');
|
||||
expect(decodedTitle.length).toBeGreaterThan(0);
|
||||
expect(listing.start_timestamp).toBeDefined();
|
||||
expect(listing.stop_timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
test('@xtream epg fixture — short epg starts at the current program, respects limit, and exposes timestamp-vs-string mismatches', async ({
|
||||
request,
|
||||
}) => {
|
||||
const stream = await getEpgFixtureStream(request);
|
||||
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_short_epg&stream_id=${stream.stream_id}&limit=2`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.epg_listings).toHaveLength(2);
|
||||
|
||||
const [currentListing, nextListing] = body.epg_listings;
|
||||
expect(decodeXtreamText(currentListing.title)).toBe('Global Headlines');
|
||||
expect(decodeXtreamText(nextListing.title)).toBe('Market Wrap');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(Number.parseInt(currentListing.start_timestamp, 10)).toBeLessThanOrEqual(
|
||||
now
|
||||
);
|
||||
expect(Number.parseInt(currentListing.stop_timestamp, 10)).toBeGreaterThanOrEqual(
|
||||
now
|
||||
);
|
||||
expect(currentListing.start).not.toBe(
|
||||
formatXtreamDateTime(
|
||||
Number.parseInt(currentListing.start_timestamp, 10)
|
||||
)
|
||||
);
|
||||
expect(currentListing.end).not.toBe(
|
||||
formatXtreamDateTime(
|
||||
Number.parseInt(currentListing.stop_timestamp, 10)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('@xtream epg fixture — full epg and legacy alias return the same ordered schedule with a midnight boundary program', async ({
|
||||
request,
|
||||
}) => {
|
||||
const stream = await getEpgFixtureStream(request);
|
||||
|
||||
const fullResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_data_table&stream_id=${stream.stream_id}`
|
||||
);
|
||||
const aliasResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_date_table&stream_id=${stream.stream_id}`
|
||||
);
|
||||
|
||||
expect(fullResponse.ok()).toBeTruthy();
|
||||
expect(aliasResponse.ok()).toBeTruthy();
|
||||
|
||||
const fullBody = await fullResponse.json();
|
||||
const aliasBody = await aliasResponse.json();
|
||||
|
||||
expect(aliasBody).toEqual(fullBody);
|
||||
|
||||
const titles = fullBody.epg_listings.map((listing: XtreamRawEpgListing) =>
|
||||
decodeXtreamText(listing.title)
|
||||
);
|
||||
expect(titles).toEqual([
|
||||
'Earlier Bulletin',
|
||||
'Global Headlines',
|
||||
'Market Wrap',
|
||||
'Overnight Update',
|
||||
'Late Edition',
|
||||
'After Midnight',
|
||||
]);
|
||||
|
||||
const boundaryListing = fullBody.epg_listings.find(
|
||||
(listing: XtreamRawEpgListing) =>
|
||||
decodeXtreamText(listing.title) === 'Late Edition'
|
||||
);
|
||||
if (!boundaryListing) {
|
||||
throw new Error('Expected the full EPG fixture to include Late Edition.');
|
||||
}
|
||||
|
||||
const boundaryStart = new Date(
|
||||
Number.parseInt(boundaryListing.start_timestamp, 10) * 1000
|
||||
);
|
||||
const boundaryStop = new Date(
|
||||
Number.parseInt(boundaryListing.stop_timestamp, 10) * 1000
|
||||
);
|
||||
|
||||
expect(boundaryStart.getUTCDate()).not.toBe(boundaryStop.getUTCDate());
|
||||
});
|
||||
|
||||
test('@xtream reset — data regenerates identically after reset', async ({
|
||||
request,
|
||||
}) => {
|
||||
const url = `${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`;
|
||||
|
||||
const before = await (await request.get(url)).json();
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
const after = await (await request.get(url)).json();
|
||||
|
||||
// Data is deterministic: same after reset
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
test('@xtream proxy endpoint — returns payload wrapper', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/xtream?url=${MOCK_SERVER}&username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_vod_categories`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.action).toBe('get_vod_categories');
|
||||
expect(Array.isArray(body.payload)).toBeTruthy();
|
||||
expect(body.payload.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('@xtream category filter — get_live_streams filtered by category_id', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Get categories to find a real ID
|
||||
const catResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_categories`
|
||||
);
|
||||
const categories = await catResponse.json();
|
||||
const categoryId = categories[0].category_id;
|
||||
|
||||
// Fetch streams filtered by that category
|
||||
const streamResponse = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_live_streams&category_id=${categoryId}`
|
||||
);
|
||||
const streams = await streamResponse.json();
|
||||
expect(Array.isArray(streams)).toBeTruthy();
|
||||
expect(streams.length).toBeGreaterThan(0);
|
||||
|
||||
// All returned streams should belong to this category
|
||||
for (const s of streams) {
|
||||
expect(String(s.category_id)).toBe(String(categoryId));
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream add portal and see it in the playlist list', async ({ page }) => {
|
||||
await addXtreamPortal(page, { name: 'My Xtream Test Portal' });
|
||||
|
||||
await expect(
|
||||
page.getByText('My Xtream Test Portal', { exact: false })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('@xtream minimal scenario — reduced item count', async ({ request }) => {
|
||||
const response = await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_categories`
|
||||
);
|
||||
const categories = await response.json();
|
||||
// Minimal scenario: 2 categories
|
||||
expect(categories.length).toBe(2);
|
||||
|
||||
const streams = await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=minimal&password=minimal&action=get_live_streams`
|
||||
)
|
||||
).json();
|
||||
// 2 categories × 5 items = 10
|
||||
expect(streams.length).toBe(10);
|
||||
});
|
||||
|
||||
type XtreamLiveStream = {
|
||||
category_id: string;
|
||||
name: string;
|
||||
stream_id: number;
|
||||
};
|
||||
|
||||
type XtreamRawEpgListing = {
|
||||
end: string;
|
||||
start: string;
|
||||
start_timestamp: string;
|
||||
stop_timestamp: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
async function getEpgFixtureStream(
|
||||
request: APIRequestContext
|
||||
): Promise<XtreamLiveStream> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
|
||||
const epgCategory = categories.find(
|
||||
(category) => category.category_name === 'EPG Focus'
|
||||
);
|
||||
if (!epgCategory) {
|
||||
throw new Error('Expected the EPG fixture category to exist.');
|
||||
}
|
||||
|
||||
const streams = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_streams&category_id=${epgCategory.category_id}`
|
||||
)
|
||||
).json()) as XtreamLiveStream[];
|
||||
|
||||
const stream = streams.find((item) => item.name === 'Timezone News');
|
||||
if (!stream) {
|
||||
throw new Error('Expected the EPG fixture stream to exist.');
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function decodeXtreamText(value: string): string {
|
||||
return Buffer.from(value, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
return new Date(timestampSeconds * 1000)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace('.000Z', '');
|
||||
}
|
||||
@@ -0,0 +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/**/*.d.ts",
|
||||
"src/**/*.e2e.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user