From 87a7f7ecfabe2207bcd20ebaa919ba1859e6207e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 10:08:37 +0200 Subject: [PATCH] test(web-e2e): move Stalker portal helpers into fixtures stalker.e2e.ts was at 1196 of 1200 counted lines. Move the mock endpoints, scenario MACs and page helpers to stalker-portal.fixture.ts and the embedded-series steps three tests repeated to stalker-embedded-series.fixture.ts. Every test stays in stalker.e2e.ts in the same order, with the same serial mode and OWNED_MACS reset. Counted lines: 1196 -> 981. Co-Authored-By: Claude Fable 5.1 --- .../src/stalker-embedded-series.fixture.ts | 104 +++++ apps/web-e2e/src/stalker-portal.fixture.ts | 238 ++++++++++++ apps/web-e2e/src/stalker.e2e.ts | 366 ++---------------- 3 files changed, 380 insertions(+), 328 deletions(-) create mode 100644 apps/web-e2e/src/stalker-embedded-series.fixture.ts create mode 100644 apps/web-e2e/src/stalker-portal.fixture.ts diff --git a/apps/web-e2e/src/stalker-embedded-series.fixture.ts b/apps/web-e2e/src/stalker-embedded-series.fixture.ts new file mode 100644 index 000000000..67096aef6 --- /dev/null +++ b/apps/web-e2e/src/stalker-embedded-series.fixture.ts @@ -0,0 +1,104 @@ +import type { APIRequestContext, Locator, Page } from '@playwright/test'; +import { expect } from './fixtures'; +import { + BACKEND_PROXY, + EMBEDDED_SERIES_MAC, + MOCK_SERVER, +} from './stalker-portal.fixture'; + +/** A VOD row of the embedded-series scenario carrying a `series[]` array. */ +interface EmbeddedSeriesItem { + name: string; + series: unknown[]; +} + +/** + * Finds a VOD item carrying an embedded series[] array in the mock catalog + * and reports how many episodes it currently has. + */ +export async function findEmbeddedSeriesItem( + request: APIRequestContext +): Promise<{ embeddedItem: EmbeddedSeriesItem; episodeCount: number }> { + 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; + + return { embeddedItem, episodeCount }; +} + +/** + * Opens the embedded-series item from its category (the first row is "All") + * and waits until the series detail lists its last episode. Returns the + * item's card in the category grid. + */ +export async function openEmbeddedSeriesItem( + page: Page, + itemName: string, + episodeCount: number +): Promise { + const categories = page.locator('.category-item'); + await expect(categories.first()).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + const card = page.getByText(itemName).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 }); + + return card; +} + +/** + * 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). + */ +export async function releaseExtraEpisodeInSearchResponses( + page: Page +): Promise { + 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) }); + }); +} diff --git a/apps/web-e2e/src/stalker-portal.fixture.ts b/apps/web-e2e/src/stalker-portal.fixture.ts new file mode 100644 index 000000000..08d0f0985 --- /dev/null +++ b/apps/web-e2e/src/stalker-portal.fixture.ts @@ -0,0 +1,238 @@ +import type { Page } from '@playwright/test'; +import { setInputValue } from './e2e-helpers'; +import { expect } from './fixtures'; +import { + getRegisteredProviderUrl, + interceptProviderTargetRegistration, +} from './provider-target-route'; + +/** + * Mock-portal endpoints, scenario MACs and page helpers of `stalker.e2e.ts`. + * + * The scenario MACs declared here belong to that spec alone: it lists them in + * its `OWNED_MACS` and resets them before every test, so a sibling spec that + * reused one would have its mock state cleared mid-run. See the isolation + * notes at the top of `stalker.e2e.ts`. + */ + +const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; +export const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; +const PORTAL_URL = `${MOCK_SERVER}/portal.php`; +/** + * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a + * "simple" portal (no handshake, no token, no watchdog); this shape is the + * authenticated branch, which the mock guards like the real middleware. + */ +export const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; +export const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; + +/** Default scenario MAC — balanced catalog, 8 categories, 40 items */ +export const DEFAULT_MAC = '00:1A:79:00:00:01'; + +/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */ +export const MINIMAL_MAC = '00:1A:79:00:00:03'; + +/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */ +export const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; + +/** Legacy pagination MAC — portal without get_all_channels support */ +export const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; + +/** + * Static-cmd MAC — ITV rows carrying a directly playable `cmd` with + * `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that + * expects no `create_link` call at all. + */ +export const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; + +/** + * The full-portal authentication tests assert state transitions within one + * portal session, so a reset from a concurrent browser project or repeat + * worker would invalidate the assertion itself. Giving every concurrent + * worker slot its own MAC range preserves browser parallelism and also keeps + * `--repeat-each` runs isolated. + */ +export interface StatefulAuthMacs { + authenticatedFlow: string; + loginRequired: string; + tokenReuse: string; + deviceConflict: string; + reauthentication: string; +} + +export function getStatefulAuthMacs({ + parallelIndex, +}: { + parallelIndex: number; +}): StatefulAuthMacs { + if ( + !Number.isSafeInteger(parallelIndex) || + parallelIndex < 0 || + parallelIndex > 255 + ) { + throw new Error( + `Unsupported Playwright parallel index: ${parallelIndex}` + ); + } + + const workerOctet = parallelIndex + .toString(16) + .padStart(2, '0') + .toUpperCase(); + const workerPrefix = `00:1A:79:AE:${workerOctet}`; + + return { + authenticatedFlow: `${workerPrefix}:01`, + loginRequired: `${workerPrefix}:02`, + tokenReuse: `${workerPrefix}:03`, + deviceConflict: `${workerPrefix}:04`, + reauthentication: `${workerPrefix}:05`, + }; +} + +/** + * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for + * it, so no token is ever adopted and content requests fail permanently. + */ +export const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01'; + +/** + * 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. + */ +export async function interceptStalkerRequests(page: Page): Promise { + 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() }); + }); +} + +/** + * 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 + */ +export async function addStalkerPortal( + page: Page, + options: { name?: string; mac?: string } = {} +): Promise { + 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 playlist', + exact: true, + }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/stalker.*vod/); +} + +/** + * Add a Stalker portal through the canonical Ministra URL, which the app + * imports as a FULL portal: handshake, Bearer token and watchdog. + */ +export async function addFullStalkerPortal( + page: Page, + options: { + name?: string; + mac: string; + expectContent?: boolean; + username?: string; + password?: string; + } +): Promise { + const { + name = 'Full Stalker Portal', + mac, + expectContent = true, + username, + password, + } = options; + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); + if (username !== undefined) { + await setInputValue(dialog.locator('input#username'), username); + } + if (password !== undefined) { + await setInputValue(dialog.locator('input#password'), password); + } + + const addButton = dialog.getByRole('button', { + name: 'Add playlist', + exact: true, + }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + + if (expectContent) { + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); + } +} + +export const CONTENT_ACTIONS = [ + 'get_categories', + 'get_genres', + 'get_ordered_list', + 'get_all_channels', +]; + +/** Every portal request in order, with the token it carried. */ +export function recordPortalRequests( + page: Page +): Array<{ action: string; token: string | null }> { + const requests: Array<{ action: string; token: string | null }> = []; + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + requests.push({ action, token: url.searchParams.get('token') }); + }); + + return requests; +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 99bd00708..051acc8af 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1,4 +1,4 @@ -import { type APIRequestContext, type Page } from '@playwright/test'; +import { type APIRequestContext } from '@playwright/test'; import { closeSeriesMenu, expectSeriesSurfacesInBothThemes, @@ -10,14 +10,33 @@ import { verifyStalkerPlaybackCategoryReturn, verifyUncachedStalkerSearch, } from './stalker-category-search.fixture'; +import { + findEmbeddedSeriesItem, + openEmbeddedSeriesItem, + releaseExtraEpisodeInSearchResponses, +} from './stalker-embedded-series.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; import { playFirstItvChannel } from './stalker-itv-playback.fixture'; -import { expect, test } from './fixtures'; import { - getRegisteredProviderUrl, - interceptProviderTargetRegistration, -} from './provider-target-route'; + AUTH_REJECTED_MAC, + BACKEND_PROXY, + CONTENT_ACTIONS, + DEFAULT_MAC, + EMBEDDED_SERIES_MAC, + FULL_PORTAL_URL, + LEGACY_PAGINATION_MAC, + MINIMAL_MAC, + MOCK_SERVER, + STATIC_CMD_MAC, + type StatefulAuthMacs, + addFullStalkerPortal, + addStalkerPortal, + getStatefulAuthMacs, + interceptStalkerRequests, + recordPortalRequests, +} from './stalker-portal.fixture'; +import { expect, test } from './fixtures'; /** * Stalker Portal E2E Tests @@ -58,121 +77,10 @@ import { test.describe.configure({ mode: 'serial' }); -const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; -const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; -const PORTAL_URL = `${MOCK_SERVER}/portal.php`; -/** - * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a - * "simple" portal (no handshake, no token, no watchdog); this shape is the - * authenticated branch, which the mock guards like the real middleware. - */ -const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.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'; - -/** - * Static-cmd MAC — ITV rows carrying a directly playable `cmd` with - * `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that - * expects no `create_link` call at all. - */ -const STATIC_CMD_MAC = '00:1A:79:00:00:0A'; - -/** - * These tests assert state transitions within one portal session, so a reset - * from a concurrent browser project or repeat worker would invalidate the - * assertion itself. Giving every concurrent worker slot its own MAC range - * preserves browser parallelism and also keeps `--repeat-each` runs isolated. - */ -interface StatefulAuthMacs { - authenticatedFlow: string; - loginRequired: string; - tokenReuse: string; - deviceConflict: string; - reauthentication: string; -} - -function getStatefulAuthMacs({ - parallelIndex, -}: { - parallelIndex: number; -}): StatefulAuthMacs { - if ( - !Number.isSafeInteger(parallelIndex) || - parallelIndex < 0 || - parallelIndex > 255 - ) { - throw new Error( - `Unsupported Playwright parallel index: ${parallelIndex}` - ); - } - - const workerOctet = parallelIndex - .toString(16) - .padStart(2, '0') - .toUpperCase(); - const workerPrefix = `00:1A:79:AE:${workerOctet}`; - - return { - authenticatedFlow: `${workerPrefix}:01`, - loginRequired: `${workerPrefix}:02`, - tokenReuse: `${workerPrefix}:03`, - deviceConflict: `${workerPrefix}:04`, - reauthentication: `${workerPrefix}:05`, - }; -} - -/** - * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for - * it, so no token is ever adopted and content requests fail permanently. - */ -const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01'; - // --------------------------------------------------------------------------- // 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 { - 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() }); - }); -} - /** Every MAC this file owns; all are cleared in one batched reset request. */ const OWNED_MACS = [ DEFAULT_MAC, @@ -223,116 +131,6 @@ async function resetMockServer( 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 { - 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 playlist', - exact: true, - }); - await expect(addButton).toBeEnabled({ timeout: 10_000 }); - await addButton.click(); - await expect(dialog).toBeHidden(); - await page.waitForURL(/stalker.*vod/); -} - -/** - * Add a Stalker portal through the canonical Ministra URL, which the app - * imports as a FULL portal: handshake, Bearer token and watchdog. - */ -async function addFullStalkerPortal( - page: Page, - options: { - name?: string; - mac: string; - expectContent?: boolean; - username?: string; - password?: string; - } -): Promise { - const { - name = 'Full Stalker Portal', - mac, - expectContent = true, - username, - password, - } = options; - - await page.getByRole('button', { name: 'Add playlist' }).click(); - const dialog = page.locator('mat-dialog-container'); - await expect(dialog).toBeVisible(); - await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); - - await setInputValue(dialog.locator('input#title'), name); - await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); - await setInputValue(dialog.locator('input#macAddress'), mac); - if (username !== undefined) { - await setInputValue(dialog.locator('input#username'), username); - } - if (password !== undefined) { - await setInputValue(dialog.locator('input#password'), password); - } - - const addButton = dialog.getByRole('button', { - name: 'Add playlist', - exact: true, - }); - await expect(addButton).toBeEnabled({ timeout: 10_000 }); - await addButton.click(); - await expect(dialog).toBeHidden(); - - if (expectContent) { - await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); - } -} - -const CONTENT_ACTIONS = [ - 'get_categories', - 'get_genres', - 'get_ordered_list', - 'get_all_channels', -]; - -/** Every portal request in order, with the token it carried. */ -function recordPortalRequests( - page: Page -): Array<{ action: string; token: string | null }> { - const requests: Array<{ action: string; token: string | null }> = []; - - page.on('request', (request) => { - const url = new URL(request.url()); - if (!url.pathname.endsWith('/stalker')) { - return; - } - const action = url.searchParams.get('action'); - if (!action) { - return; - } - requests.push({ action, token: url.searchParams.get('token') }); - }); - - return requests; -} - // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- @@ -967,16 +765,8 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e 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; + const { embeddedItem, episodeCount } = + await findEmbeddedSeriesItem(request); await addStalkerPortal(page, { name: 'Embedded Series Portal', @@ -985,19 +775,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e // 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 openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount); await page.getByRole('button', { name: 'Add to favorites' }).click(); // Wait for the async favorite persistence before navigating away await expect( @@ -1007,39 +785,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e // 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) }); - }); + await releaseExtraEpisodeInSearchResponses(page); // Open the item from the Favorites view: the stored snapshot renders // first, then the background refresh patches in the new episode @@ -1083,35 +829,15 @@ test('@stalker season watched toggle — embedded series marks and clears every // Reuse the modeled embedded-series flow: find a VOD item carrying an // embedded series[] array, open it from its category, and land on the // series detail with its episode list. - 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; + const { embeddedItem, episodeCount } = + await findEmbeddedSeriesItem(request); await addStalkerPortal(page, { name: 'Embedded Series Watch Portal', mac: EMBEDDED_SERIES_MAC, }); - 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 openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount); await expectSeriesSurfacesInBothThemes(page, testInfo); @@ -1153,35 +879,19 @@ test('@stalker series watched toggle — embedded series marks and clears from t }) => { // Same modeled embedded-series flow as the season test above, driven // through the series-level ⋮ menu instead of the season button. - 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; + const { embeddedItem, episodeCount } = + await findEmbeddedSeriesItem(request); await addStalkerPortal(page, { name: 'Embedded Series Watch Menu Portal', mac: EMBEDDED_SERIES_MAC, }); - 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 }); + const card = await openEmbeddedSeriesItem( + page, + embeddedItem.name, + episodeCount + ); // The series row sits in the hero's "…" menu (data-test-id with a dash — // getByTestId only matches data-testid in this suite; the row renders