diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 0529f4baa..8a5abc5c4 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -48,6 +48,27 @@ async function addXtreamPortal(page: Page): Promise { await page.waitForURL(/xtreams.*vod/); } +async function addM3uPlaylist(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + + await setInputValue( + dialog.getByRole('textbox', { name: /Playlist URL/ }), + `${XTREAM_MOCK_SERVER}/playlist.m3u` + ); + await setInputValue( + dialog.getByRole('textbox', { name: 'Playlist title' }), + 'Self-hosted M3U' + ); + + await dialog + .getByRole('button', { name: 'Add playlist', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/playlists.*all/); +} + async function addStalkerPortal(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); @@ -65,6 +86,27 @@ async function addStalkerPortal(page: Page): Promise { await page.waitForURL(/stalker.*vod/); } +function collectBackendRequests(page: Page, path: string): string[] { + const requests: string[] = []; + page.on('request', (request) => { + const requestUrl = request.url(); + if (requestUrl.startsWith(`${WEB_BACKEND_URL}${path}`)) { + requests.push(requestUrl); + } + }); + return requests; +} + +function expectRequestsUseTargetId(requests: string[], path: string): void { + expect(requests.length).toBeGreaterThan(0); + for (const requestUrl of requests) { + const url = new URL(requestUrl); + expect(url.pathname).toBe(path); + expect(url.searchParams.get('targetId')).toBeTruthy(); + expect(url.searchParams.has('url')).toBeFalsy(); + } +} + test.beforeEach(async ({ page, request }) => { await request.post(`${XTREAM_MOCK_SERVER}/reset`); await request.post(`${STALKER_MOCK_SERVER}/reset`); @@ -90,17 +132,36 @@ test('@self-hosted runtime config points PWA calls at the monorepo backend', asy test('@self-hosted Xtream portal loads through web-backend proxy', async ({ page, }) => { + const xtreamRequests = collectBackendRequests(page, '/xtream'); + await addXtreamPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(xtreamRequests, '/xtream'); +}); + +test('@self-hosted M3U URL loads through web-backend proxy', async ({ + page, +}) => { + const parseRequests = collectBackendRequests(page, '/parse'); + + await addM3uPlaylist(page); + + await expect(page.getByText('4 channels')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('1. Channel 1')).toBeVisible(); + await expect(page.getByText('4. HappyKids TV')).toBeVisible(); + expectRequestsUseTargetId(parseRequests, '/parse'); }); test('@self-hosted Stalker portal loads through web-backend proxy', async ({ page, }) => { + const stalkerRequests = collectBackendRequests(page, '/stalker'); + await addStalkerPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(stalkerRequests, '/stalker'); }); diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 34d8b8e18..648a15c34 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -9,6 +9,16 @@ import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js const app = express(); const PORT = parseInt(process.env['PORT'] ?? '3211', 10); +const M3U_FIXTURE = `#EXTM3U +#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 +https://example.channels/path-to-file/1.m3u8 +#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV +https://example.channels/path-to-file/2.m3u8 +#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX +https://example.channels/path-to-file/3.m3u8 +#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV +https://example.channels/path-to-file/4.m3u8 +`; const marketingRasterAssetRoot = join( process.cwd(), 'apps/xtream-mock-server/public/marketing' @@ -28,6 +38,13 @@ app.post('/reset', (_req, res) => { res.json({ status: 'reset' }); }); +// ─── M3U fixture endpoint for self-hosted PWA URL import tests ─────────────── +app.get('/playlist.m3u', (_req, res) => { + res.type('audio/x-mpegurl') + .set('Cache-Control', 'no-store') + .send(M3U_FIXTURE); +}); + // ─── Local fictional artwork for release screenshots ────────────────────────── app.get('/assets/marketing/:kind/:slug', (req, res) => { const kind = req.params['kind'] as @@ -46,18 +63,20 @@ app.get('/assets/marketing/:kind/:slug', (req, res) => { } const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); - const rasterPath = join(marketingRasterAssetRoot, kind, `${rasterSlug}.png`); + const rasterPath = join( + marketingRasterAssetRoot, + kind, + `${rasterSlug}.png` + ); if (existsSync(rasterPath)) { - res - .type('image/png') + res.type('image/png') .set('Cache-Control', 'public, max-age=3600') .send(readFileSync(rasterPath)); return; } - res - .type('image/svg+xml') + res.type('image/svg+xml') .set('Cache-Control', 'public, max-age=3600') .send(renderMarketingAssetSvg(kind, slug, size)); }); @@ -149,8 +168,12 @@ server.on('error', (err: NodeJS.ErrnoException) => { server.listen(PORT, () => { console.log(`[xtream-mock] Listening on http://localhost:${PORT}`); - console.log(`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`); - console.log(`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`); + console.log( + `[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info` + ); + console.log( + `[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info` + ); console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`); }); @@ -173,4 +196,6 @@ process.on('unhandledRejection', (reason) => { // When Nx (or any process manager) closes stdin, prevent auto-exit. // The HTTP server handle is what keeps the process alive. process.stdin.resume(); -process.stdin.on('end', () => { /* ignore stdin close */ }); +process.stdin.on('end', () => { + /* ignore stdin close */ +}); diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 7e52683b2..a41f89880 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -95,6 +95,16 @@ Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same Angular service code works in both environments. +### M3U fixture endpoint + +``` +GET /playlist.m3u +``` + +Returns a small deterministic four-channel playlist. The self-hosted PWA E2E +suite uses this endpoint to verify M3U URL imports through `apps/web-backend` +and the provider target registry. + ### Stream stub endpoints ```