Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+12
View File
@@ -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: {},
},
];
+86
View File
@@ -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' },
} */
],
});
+15
View File
@@ -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": {}
}
}
+31
View File
@@ -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();
});
+11
View File
@@ -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
+94
View File
@@ -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`
),
});
});
});
+309
View File
@@ -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);
});
+488
View File
@@ -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', '');
}
+21
View File
@@ -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"
]
}