mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Favorites and recently-viewed rows store Stalker items as full JSON snapshots, so a vclub-style embedded series[] episode list froze at the moment the row was written: a series favorited when only episode 1 was out kept showing one episode forever when opened from favorites, recents, Continue Watching, or any dashboard rail. New withStalkerSnapshotRefresh() store feature renders the stored snapshot immediately and re-fetches the item from the portal in the background via a title search (get_ordered_list&type=vod&search=..., matched by id, paginated up to 5 pages, wildcard-category retry), patching fresh episodes and cmd into the active selection. The patch is guarded on both the item id and the active playlist id, since Stalker ids are only unique per portal. Only the in-memory selection is patched — the stored snapshot row is deliberately left alone, because every entry path into the detail view runs this refresh and writing it back would add an uncontrolled background writer to the whole-playlist read-modify-write that every favorite/recent mutation performs. Also fixes the stalker-mock-server embedded-series scenario, which generated series[] as objects the app's vclub adapters filter out instead of the episode-number arrays real portals send. Regular type=series and Ministra is_series items are unaffected; Xtream is unaffected (get_series_info is never cached). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
705 lines
26 KiB
TypeScript
705 lines
26 KiB
TypeScript
import {
|
||
type APIRequestContext,
|
||
type Page,
|
||
} from '@playwright/test';
|
||
import { setInputValue } from './e2e-helpers';
|
||
import { expect, test } from './fixtures';
|
||
import {
|
||
getRegisteredProviderUrl,
|
||
interceptProviderTargetRegistration,
|
||
} from './provider-target-route';
|
||
|
||
/**
|
||
* 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 / Radio)
|
||
* - 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';
|
||
|
||
/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
|
||
const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
|
||
|
||
/** Legacy pagination MAC — portal without get_all_channels support */
|
||
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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> {
|
||
const providerTargets = await interceptProviderTargetRegistration(page);
|
||
|
||
await page.route('**/localhost:3000/stalker**', async (route) => {
|
||
const originalUrl = new URL(route.request().url());
|
||
const mockUrl = new URL(BACKEND_PROXY);
|
||
const providerUrl = getRegisteredProviderUrl(
|
||
originalUrl,
|
||
providerTargets
|
||
);
|
||
|
||
if (providerUrl) {
|
||
mockUrl.searchParams.set('url', providerUrl);
|
||
}
|
||
|
||
originalUrl.searchParams.forEach((value, key) => {
|
||
if (key === 'targetId') {
|
||
return;
|
||
}
|
||
|
||
mockUrl.searchParams.set(key, value);
|
||
});
|
||
await route.continue({ url: mockUrl.toString() });
|
||
});
|
||
}
|
||
|
||
async function resetMockServer(request: APIRequestContext): Promise<void> {
|
||
let lastError: unknown;
|
||
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
try {
|
||
const response = await request.post(`${MOCK_SERVER}/reset`);
|
||
if (response.ok()) {
|
||
return;
|
||
}
|
||
|
||
lastError = new Error(
|
||
`Reset failed with status ${response.status()}`
|
||
);
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
|
||
await new Promise((resolve) =>
|
||
setTimeout(resolve, 250 * (attempt + 1))
|
||
);
|
||
}
|
||
|
||
throw lastError;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
|
||
await dialog.getByRole('radio', { name: /Stalker portal/i }).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();
|
||
await expect(dialog).toBeHidden();
|
||
await page.waitForURL(/stalker.*vod/);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test setup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test.beforeEach(async ({ page, request }) => {
|
||
// Reset mock server state (clears in-memory favorites and cache)
|
||
await resetMockServer(request);
|
||
|
||
// 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).not.toHaveCount(0, { timeout: 10_000 });
|
||
await expect(contentItems.first()).toBeVisible({ timeout: 10_000 });
|
||
});
|
||
|
||
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 PWA hides EPG for ITV channel', async ({ page }) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const epgInfoRequests: string[] = [];
|
||
const shortEpgRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (url.includes('action=get_epg_info')) {
|
||
epgInfoRequests.push(url);
|
||
}
|
||
if (url.includes('action=get_short_epg')) {
|
||
shortEpgRequests.push(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 — PWA/browser playback must not expose Electron EPG UI.
|
||
await channels.first().click();
|
||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-web-player-view')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
expect(epgInfoRequests).toHaveLength(0);
|
||
expect(shortEpgRequests).toHaveLength(0);
|
||
});
|
||
|
||
test('@stalker radio — stations use the inline audio player without EPG', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const radioListRequests: string[] = [];
|
||
const epgRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (
|
||
url.includes('type=radio') &&
|
||
url.includes('action=get_ordered_list')
|
||
) {
|
||
radioListRequests.push(url);
|
||
}
|
||
if (
|
||
url.includes('action=get_epg_info') ||
|
||
url.includes('action=get_short_epg')
|
||
) {
|
||
epgRequests.push(url);
|
||
}
|
||
});
|
||
|
||
await page.getByRole('link', { name: /radio/i }).click();
|
||
await page.waitForURL(/stalker.*radio/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
|
||
const stations = page.locator('[data-test-id="channel-item"]');
|
||
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
|
||
await expect.poll(() => radioListRequests.length).toBeGreaterThan(0);
|
||
|
||
await stations.first().click();
|
||
await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-audio-player')).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
await page.getByRole('button', { name: 'Hide channels list' }).click();
|
||
const restoreButton = page.getByRole('button', {
|
||
name: 'Show channels list',
|
||
});
|
||
await expect(restoreButton).toBeVisible();
|
||
await restoreButton.click();
|
||
await expect(stations.first()).toBeVisible();
|
||
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
expect(epgRequests).toHaveLength(0);
|
||
});
|
||
|
||
test('@stalker PWA skips bulk EPG 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(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
|
||
await channels.nth(1).click();
|
||
await expect(channels.nth(1)).toHaveClass(/active/, { timeout: 20_000 });
|
||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||
expect(epgInfoRequests).toHaveLength(0);
|
||
expect(shortEpgRequests).toHaveLength(0);
|
||
});
|
||
|
||
test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const allChannelsRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
if (request.url().includes('action=get_all_channels')) {
|
||
allChannelsRequests.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 });
|
||
|
||
// BEFORE any category click (Xtream parity): entering the Live TV section
|
||
// preloads the full list, so the main area shows the paginated
|
||
// all-channels grid and the categories already carry count badges.
|
||
const allItemsGrid = page.locator('app-stalker-itv-all-items');
|
||
await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
await expect(
|
||
allItemsGrid.locator('.mat-mdc-paginator-range-label')
|
||
).toContainText('of 320');
|
||
await expect(categories.nth(0).locator('.item-count')).toHaveText('320', {
|
||
timeout: 10_000,
|
||
});
|
||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
|
||
|
||
await categories.nth(1).click();
|
||
|
||
const channels = page.locator('[data-test-id="channel-item"]');
|
||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||
|
||
// Regression for "search only finds the first 14 loaded items": once the
|
||
// full list is cached, the whole category (40 channels) is available
|
||
// without scrolling through 14-item pages.
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 20_000,
|
||
});
|
||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||
|
||
// Regression: switching to another category once the full list is cached
|
||
// must serve that category from the cache, not get stuck on an empty
|
||
// skeleton. (The reset-on-category-change effect used to clobber the
|
||
// synchronously served list.)
|
||
await categories.nth(2).click();
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 20_000,
|
||
});
|
||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||
// No further get_all_channels request — it's served from the session cache.
|
||
const requestsAfterFirstCategory = allChannelsRequests.length;
|
||
await categories.nth(3).click();
|
||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||
expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory);
|
||
|
||
// Back to the first category for the search assertions below.
|
||
await categories.nth(1).click();
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// Search a channel from deep in the list (beyond the first 14 items).
|
||
const deepChannelName = (
|
||
await channels.nth(30).locator('.channel-name').textContent()
|
||
)?.trim();
|
||
expect(deepChannelName).toBeTruthy();
|
||
|
||
const searchInput = page.locator('input[type="search"]');
|
||
await searchInput.fill(deepChannelName as string);
|
||
await searchInput.press('Enter');
|
||
|
||
await expect(
|
||
page
|
||
.locator('[data-test-id="channel-item"] .channel-name')
|
||
.filter({ hasText: deepChannelName as string })
|
||
.first()
|
||
).toBeVisible({ timeout: 10_000 });
|
||
// The "loaded only" degraded-search hint must be gone in full-list mode.
|
||
await expect(page.locator('.search-chip--status')).toHaveCount(0);
|
||
});
|
||
|
||
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page);
|
||
|
||
const adultListRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (
|
||
url.includes('action=get_ordered_list') &&
|
||
url.includes('type=itv') &&
|
||
url.includes('genre=1099')
|
||
) {
|
||
adultListRequests.push(url);
|
||
}
|
||
});
|
||
|
||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||
await page.waitForURL(/stalker.*itv/);
|
||
|
||
const categories = page.locator('.category-item');
|
||
// Wait until the full list is cached (a regular category shows its badge).
|
||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40', {
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// The censored genre is excluded from get_all_channels, so its real count
|
||
// is unknown — no badge instead of a misleading "0".
|
||
const adultCategory = page.locator('.category-item', {
|
||
hasText: 'For adults',
|
||
});
|
||
await expect(adultCategory).toBeVisible();
|
||
await expect(adultCategory.locator('.item-count')).toHaveCount(0);
|
||
|
||
// Clicking it falls back to the legacy paged flow and still shows channels.
|
||
await adultCategory.click();
|
||
const channels = page.locator('[data-test-id="channel-item"]');
|
||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
|
||
page,
|
||
}) => {
|
||
await addStalkerPortal(page, {
|
||
name: 'Legacy Stalker Portal',
|
||
mac: LEGACY_PAGINATION_MAC,
|
||
});
|
||
|
||
const allChannelsRequests: string[] = [];
|
||
const crawlRequests: string[] = [];
|
||
page.on('request', (request) => {
|
||
const url = request.url();
|
||
if (url.includes('action=get_all_channels')) {
|
||
allChannelsRequests.push(url);
|
||
}
|
||
// The full-list crawl pages through ALL genres (genre=*).
|
||
if (
|
||
url.includes('action=get_ordered_list') &&
|
||
url.includes('type=itv') &&
|
||
(url.includes('genre=*') || url.includes('genre=%2A'))
|
||
) {
|
||
crawlRequests.push(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.first()).toBeVisible({ timeout: 20_000 });
|
||
|
||
// The crawl collects all 6 × 40 channels; the selected category then
|
||
// shows its full 40 items without manual lazy-load scrolling.
|
||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||
timeout: 30_000,
|
||
});
|
||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||
expect(crawlRequests.length).toBeGreaterThan(1);
|
||
});
|
||
|
||
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 mock server returns radio categories and stations', async ({
|
||
request,
|
||
}) => {
|
||
const categoriesResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_categories&type=radio&macAddress=${DEFAULT_MAC}`
|
||
);
|
||
expect(categoriesResponse.ok()).toBeTruthy();
|
||
const categoriesBody = await categoriesResponse.json();
|
||
expect(categoriesBody.payload.js.length).toBeGreaterThan(0);
|
||
|
||
const firstCategory = categoriesBody.payload.js[0].id;
|
||
const stationsResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=radio&category=${firstCategory}&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
|
||
);
|
||
expect(stationsResponse.ok()).toBeTruthy();
|
||
const stationsBody = await stationsResponse.json();
|
||
const firstStation = stationsBody.payload.js.data[0];
|
||
expect(firstStation).toEqual(
|
||
expect.objectContaining({
|
||
category_id: firstCategory,
|
||
radio: true,
|
||
})
|
||
);
|
||
});
|
||
|
||
test('@stalker favorites — embedded-series favorite refreshes newly released episodes', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
// Find an embedded-series VOD item in the mock catalog first
|
||
const listResponse = await request.get(
|
||
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
|
||
);
|
||
const listBody = await listResponse.json();
|
||
const embeddedItem = listBody.payload.js.data.find(
|
||
(item: { series?: unknown[] }) =>
|
||
Array.isArray(item.series) && item.series.length > 0
|
||
);
|
||
expect(embeddedItem).toBeDefined();
|
||
const episodeCount: number = embeddedItem.series.length;
|
||
|
||
await addStalkerPortal(page, {
|
||
name: 'Embedded Series Portal',
|
||
mac: EMBEDDED_SERIES_MAC,
|
||
});
|
||
|
||
// Open the embedded-series item from its category and favorite it —
|
||
// this persists a snapshot with the current episode list
|
||
const categories = page.locator('.category-item');
|
||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||
await categories.nth(1).click();
|
||
const card = page.getByText(embeddedItem.name).first();
|
||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||
await card.click();
|
||
|
||
await expect(
|
||
page.getByRole('heading', {
|
||
name: `${episodeCount}. Episode ${episodeCount}`,
|
||
exact: true,
|
||
})
|
||
).toBeVisible({ timeout: 10_000 });
|
||
await page.getByRole('button', { name: 'Add to favorites' }).click();
|
||
// Wait for the async favorite persistence before navigating away
|
||
await expect(
|
||
page.getByRole('button', { name: 'Remove from favorites' })
|
||
).toBeVisible({ timeout: 10_000 });
|
||
|
||
// From now on the portal has "released" one more episode: extend
|
||
// series[] in every search response (the background snapshot refresh
|
||
// re-fetches the item via a title search)
|
||
await page.route('**/localhost:3000/stalker**', async (route) => {
|
||
const originalUrl = new URL(route.request().url());
|
||
if (!originalUrl.searchParams.get('search')) {
|
||
await route.fallback();
|
||
return;
|
||
}
|
||
|
||
const mockUrl = new URL(BACKEND_PROXY);
|
||
const targetId = originalUrl.searchParams.get('targetId');
|
||
const providerUrl = targetId
|
||
? Buffer.from(targetId, 'base64url').toString()
|
||
: originalUrl.searchParams.get('url');
|
||
if (providerUrl) {
|
||
mockUrl.searchParams.set('url', providerUrl);
|
||
}
|
||
originalUrl.searchParams.forEach((value, key) => {
|
||
if (key === 'targetId') {
|
||
return;
|
||
}
|
||
mockUrl.searchParams.set(key, value);
|
||
});
|
||
|
||
const response = await route.fetch({ url: mockUrl.toString() });
|
||
const body = await response.json();
|
||
const rows: { series?: string[] }[] =
|
||
body?.payload?.js?.data ?? body?.js?.data ?? [];
|
||
for (const row of rows) {
|
||
if (Array.isArray(row.series) && row.series.length > 0) {
|
||
row.series = [
|
||
...row.series,
|
||
String(row.series.length + 1),
|
||
];
|
||
}
|
||
}
|
||
await route.fulfill({ response, body: JSON.stringify(body) });
|
||
});
|
||
|
||
// Open the item from the Favorites view: the stored snapshot renders
|
||
// first, then the background refresh patches in the new episode
|
||
const favoritesUrl = new URL(page.url());
|
||
favoritesUrl.pathname = favoritesUrl.pathname.replace(
|
||
/\/vod.*$/,
|
||
'/favorites'
|
||
);
|
||
favoritesUrl.search = '';
|
||
await page.goto(favoritesUrl.toString());
|
||
|
||
const favoriteCard = page.getByText(embeddedItem.name).first();
|
||
await expect(favoriteCard).toBeVisible({ timeout: 10_000 });
|
||
await favoriteCard.click();
|
||
|
||
// Snapshot episodes are visible immediately…
|
||
await expect(
|
||
page.getByRole('heading', { name: '1. Episode 1', exact: true })
|
||
).toBeVisible({ timeout: 10_000 });
|
||
// …and the newly released episode appears after the background refresh
|
||
await expect(
|
||
page.getByRole('heading', {
|
||
name: `${episodeCount + 1}. Episode ${episodeCount + 1}`,
|
||
exact: true,
|
||
})
|
||
).toBeVisible({ timeout: 10_000 });
|
||
});
|
||
|
||
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);
|
||
});
|