Merge branch 'master' into perf/j3-epg-ribbon-window
No files matched your search
@@ -29,6 +29,11 @@ import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
import {
|
||||
addCurrentDetailToFavorites,
|
||||
goBackFromDetail,
|
||||
toggleFavoriteForChannel,
|
||||
} from './dashboard-e2e-flows';
|
||||
|
||||
test.describe('Dashboard Activation', () => {
|
||||
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
|
||||
@@ -305,38 +310,6 @@ function dashboardRailCardByTitle(
|
||||
.first();
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
await backButton.click({ timeout: 5000 });
|
||||
} catch {
|
||||
await backButton.evaluate((button: HTMLButtonElement) =>
|
||||
button.click()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// By accessible name, not class: the Xtream movie detail's favorite control is
|
||||
// an icon-only button that carries its label in aria-label, while series and
|
||||
// Stalker details still use the labeled variant. This matches both.
|
||||
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
const addButton = page
|
||||
.getByRole('button', { name: /add to favorites/i })
|
||||
.first();
|
||||
|
||||
await expect(addButton).toBeVisible({ timeout: 20000 });
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /remove from favorites/i }).first()
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectInlineCollectionDetail(
|
||||
page: Page,
|
||||
params: {
|
||||
@@ -383,20 +356,3 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
await expect(episodeCard).toBeVisible({ timeout: 20000 });
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
const item = page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
||||
/star/
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from './electron-test-fixtures';
|
||||
|
||||
// Steps that put content on the dashboard: favourites from the Live TV list
|
||||
// and from a detail page, and the way back from that detail page.
|
||||
|
||||
export async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: the header's Back is route-level in browse and
|
||||
// watch alike (closing the player is the bar's own Close button).
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
await backButton.click({ timeout: 5000 });
|
||||
} catch {
|
||||
await backButton.evaluate((button: HTMLButtonElement) =>
|
||||
button.click()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// By accessible name, not class: the Xtream movie detail's favorite control is
|
||||
// an icon-only button that carries its label in aria-label, while series and
|
||||
// Stalker details still use the labeled variant. This matches both.
|
||||
export async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
const addButton = page
|
||||
.getByRole('button', { name: /add to favorites/i })
|
||||
.first();
|
||||
|
||||
await expect(addButton).toBeVisible({ timeout: 20000 });
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /remove from favorites/i }).first()
|
||||
).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
const item = page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: title })
|
||||
.first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
||||
/star/
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import sharp = require('sharp');
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamLiveFixture,
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
getXtreamTitle,
|
||||
} from './portal-mock-fixtures';
|
||||
import { applyTheme, measureBackdropTextContrast } from './theme-contrast';
|
||||
import {
|
||||
addCurrentDetailToFavorites,
|
||||
goBackFromDetail,
|
||||
toggleFavoriteForChannel,
|
||||
} from './dashboard-e2e-flows';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The dashboard hero's text must stay legible over any artwork, in both
|
||||
// themes and in the narrow layout where the slide spans the whole width.
|
||||
// Every mock image is replaced by a black-and-white checkerboard, the worst
|
||||
// case for either theme's scrim; series images fail, so the favourited
|
||||
// series falls back to the generated gradient. Each slide kind (16:9
|
||||
// backdrop, blurred poster, no artwork, live channel) is measured from the
|
||||
// screen at a wide and a narrow content width.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SlideKind = 'backdrop' | 'poster' | 'fallback' | 'live';
|
||||
|
||||
const widths = { wide: 1280, narrow: 760 } as const;
|
||||
const minimumContrast = 4.5;
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
/** A full TMDB slide has a rating and a two-line overview; the mock has no
|
||||
* TMDB, so the measurement adds both, styled by the hero's own rules. */
|
||||
const sampleOverview =
|
||||
'A long synopsis that wraps onto a second line, so the body text of a ' +
|
||||
'fully enriched slide is measured where it really sits over the artwork.';
|
||||
|
||||
async function busyArtwork(): Promise<Buffer> {
|
||||
const width = 1280;
|
||||
const height = 720;
|
||||
const square = 40;
|
||||
const pixels = Buffer.alloc(width * height * 3);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const white =
|
||||
(Math.floor(x / square) + Math.floor(y / square)) % 2 === 0;
|
||||
pixels.fill(
|
||||
white ? 255 : 0,
|
||||
(y * width + x) * 3,
|
||||
(y * width + x) * 3 + 3
|
||||
);
|
||||
}
|
||||
}
|
||||
return sharp(pixels, { raw: { width, height, channels: 3 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** Serves the checkerboard for every mock image except series artwork,
|
||||
* which fails: an image the page has already shown is reused from memory,
|
||||
* so the failure has to be in place before the series is first opened. */
|
||||
async function routeArtwork(page: Page): Promise<void> {
|
||||
const image = await busyArtwork();
|
||||
await page.route(
|
||||
(url) => url.hostname === 'picsum.photos',
|
||||
(route) =>
|
||||
/\/seed\/series-/.test(route.request().url())
|
||||
? route.fulfill({ status: 404, body: '' })
|
||||
: route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: image,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function slideKinds(page: Page): Promise<SlideKind[]> {
|
||||
return page.locator('.hero__backdrop').evaluateAll((backdrops) =>
|
||||
backdrops.map((backdrop): SlideKind => {
|
||||
if (backdrop.classList.contains('hero__backdrop--live')) {
|
||||
return 'live';
|
||||
}
|
||||
if (!backdrop.querySelector('.hero__backdrop-image')) {
|
||||
return 'fallback';
|
||||
}
|
||||
return backdrop.classList.contains('hero__backdrop--poster')
|
||||
? 'poster'
|
||||
: 'backdrop';
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function showSlide(page: Page, index: number): Promise<Locator> {
|
||||
const dot = page.getByTestId('dashboard-hero-dot').nth(index);
|
||||
await dot.click();
|
||||
await expect(dot).toHaveAttribute('aria-current', 'true');
|
||||
// The backdrop crossfade and the slide's entrance have finished (not the
|
||||
// image's slow zoom, which never changes what is under the text).
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
[
|
||||
document.querySelector('.hero__backdrop--active'),
|
||||
document.querySelector('.hero__content'),
|
||||
].every(
|
||||
(element) =>
|
||||
element &&
|
||||
element
|
||||
.getAnimations()
|
||||
.every(
|
||||
(animation) => animation.playState !== 'running'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
return page.getByTestId('dashboard-hero-slide');
|
||||
}
|
||||
|
||||
/** Sum of non-input layout shifts while the hero runs through every slide
|
||||
* on its own, at a shortened interval. */
|
||||
async function rotationLayoutShift(page: Page): Promise<number> {
|
||||
const hero = page.getByTestId('dashboard-hero');
|
||||
const dots = page.getByTestId('dashboard-hero-dot');
|
||||
const count = await dots.count();
|
||||
await page.mouse.move(1, 1);
|
||||
await hero.evaluate((element) => {
|
||||
(element as HTMLElement).style.setProperty(
|
||||
'--hero-rotation-ms',
|
||||
'600ms'
|
||||
);
|
||||
const shifts: number[] = [];
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries() as (PerformanceEntry & {
|
||||
value: number;
|
||||
hadRecentInput: boolean;
|
||||
})[]) {
|
||||
if (!entry.hadRecentInput) {
|
||||
shifts.push(entry.value);
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'layout-shift' });
|
||||
(window as unknown as { __heroShifts: number[] }).__heroShifts = shifts;
|
||||
});
|
||||
// Back to the first slide after one full cycle, then a quiet moment.
|
||||
const first = await dots.evaluateAll((all) =>
|
||||
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
|
||||
);
|
||||
for (let step = 1; step <= count; step++) {
|
||||
await expect(dots.nth((first + step) % count)).toHaveAttribute(
|
||||
'aria-current',
|
||||
'true',
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
return page.evaluate(() =>
|
||||
(window as unknown as { __heroShifts: number[] }).__heroShifts.reduce(
|
||||
(sum, value) => sum + value,
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
|
||||
async function enrichSlide(slide: Locator): Promise<void> {
|
||||
await slide.evaluate((content, overview) => {
|
||||
const chip = content.querySelector('.hero__pill');
|
||||
if (chip) {
|
||||
const rating = chip.cloneNode() as HTMLElement;
|
||||
rating.classList.add('meta-chip--rating');
|
||||
rating.textContent = '★ 7.4';
|
||||
chip.before(rating);
|
||||
}
|
||||
// The overview takes the title's view encapsulation attribute, so the
|
||||
// hero's `.hero__description` rule styles it.
|
||||
const title = content.querySelector('.hero__title');
|
||||
const scope = Array.from(title?.attributes ?? []).find((attribute) =>
|
||||
attribute.name.startsWith('_ngcontent')
|
||||
);
|
||||
const actions = content.querySelector('.hero__actions');
|
||||
if (scope && actions && !content.querySelector('.hero__description')) {
|
||||
const description = document.createElement('p');
|
||||
description.setAttribute(scope.name, '');
|
||||
description.className = 'hero__description';
|
||||
description.textContent = overview;
|
||||
actions.before(description);
|
||||
}
|
||||
}, sampleOverview);
|
||||
await expect(slide.locator('.hero__description')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** Every piece of slide text a viewer reads, one element per colour. */
|
||||
function slideTexts(slide: Locator): Record<string, Locator> {
|
||||
return {
|
||||
eyebrow: slide.locator('.hero__eyebrow > span:not(.hero__eyebrow-sep)'),
|
||||
title: slide.locator('.hero__title'),
|
||||
pill: slide.locator('.hero__pill'),
|
||||
programme: slide.locator('.hero__programme'),
|
||||
description: slide.locator('.hero__description'),
|
||||
button: slide.locator('.hero__button > span'),
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('Dashboard hero legibility', () => {
|
||||
test('keeps slide text at 4.5:1 over any artwork in both themes and widths', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const live = await fetchXtreamLiveFixture(request, xtreamCredentials);
|
||||
const vod = await fetchXtreamVodFixture(request, xtreamCredentials);
|
||||
const series = await fetchXtreamSeriesFixture(
|
||||
request,
|
||||
xtreamCredentials
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
const results: string[] = [];
|
||||
|
||||
try {
|
||||
await routeArtwork(page);
|
||||
await page.setViewportSize({ width: widths.wide, height: 800 });
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
|
||||
// Live slide: a favourite channel with a programme on air.
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, live.categoryName);
|
||||
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
|
||||
// Backdrop slide: favouriting from the detail page stores the
|
||||
// movie's 16:9 backdrop.
|
||||
await page
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(page, vod.categoryName);
|
||||
await clickFirstGridListCard(page);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
await goBackFromDetail(page);
|
||||
// No-artwork slide: series images fail, poster and backdrop.
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(page, series.categoryName);
|
||||
await clickFirstGridListCard(page);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
// Poster-only slides come from the Xtream "recently added" list.
|
||||
await expect
|
||||
.poll(async () => [...new Set(await slideKinds(page))].sort(), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toEqual(['backdrop', 'fallback', 'live', 'poster']);
|
||||
|
||||
// One stable page heading; the rotating slide title is an h2.
|
||||
await expect(page.locator('h1')).toHaveCount(1);
|
||||
await expect(page.getByTestId('dashboard-page-heading')).toHaveText(
|
||||
'Dashboard'
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('dashboard-hero-slide')
|
||||
.locator('h2.hero__title')
|
||||
).toHaveCount(1);
|
||||
|
||||
// An unattended rotation, counted like the launch journey's
|
||||
// settled layout-shift counter (non-input shifts only). Slides of
|
||||
// different heights still resize the hero by a few pixels and
|
||||
// move the rails below (0.005 here, 0.013 before this change);
|
||||
// a scrim or heading that reflowed the slide would add lines.
|
||||
const shift = await rotationLayoutShift(page);
|
||||
results.push(`rotation layout shift ${shift.toFixed(3)}`);
|
||||
expect(shift).toBeLessThan(0.02);
|
||||
|
||||
await page.getByTestId('dashboard-hero-pause').click();
|
||||
const kinds = await slideKinds(page);
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
for (const [layout, width] of Object.entries(widths)) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
// The narrow layout is the dashboard container's
|
||||
// ≤720px query, not the window width.
|
||||
const narrow = await page
|
||||
.locator('.hero__content')
|
||||
.evaluate(
|
||||
(content) =>
|
||||
getComputedStyle(content).maxWidth === 'none'
|
||||
);
|
||||
expect(narrow).toBe(layout === 'narrow');
|
||||
for (const [index, kind] of kinds.entries()) {
|
||||
const slide = await showSlide(page, index);
|
||||
await enrichSlide(slide);
|
||||
await page.mouse.move(1, 1);
|
||||
const name = `${theme}-${layout}-${kind}`;
|
||||
const shot = testInfo.outputPath(`hero-${name}.png`);
|
||||
await page
|
||||
.getByTestId('dashboard-hero')
|
||||
.screenshot({ path: shot });
|
||||
await testInfo.attach(name, {
|
||||
path: shot,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
for (const [part, texts] of Object.entries(
|
||||
slideTexts(slide)
|
||||
)) {
|
||||
for (const text of await texts.all()) {
|
||||
const ratio = await measureBackdropTextContrast(
|
||||
page,
|
||||
text
|
||||
);
|
||||
results.push(
|
||||
`${name} ${part} ${ratio.toFixed(2)}`
|
||||
);
|
||||
expect
|
||||
.soft(ratio, `${name} ${part}`)
|
||||
.toBeGreaterThanOrEqual(minimumContrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const report = testInfo.outputPath('contrast.txt');
|
||||
writeFileSync(report, results.join('\n'));
|
||||
await testInfo.attach('contrast', {
|
||||
path: report,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
// The "Episodes" heading must also stay on one line: the detail pane is far
|
||||
// narrower than the window beside the rail and category panel, and the
|
||||
// heading used to wrap beside its actions.
|
||||
//
|
||||
// Pages without a Back of their own (the list a detail returns to) get the
|
||||
// header's history fallback while an in-app previous page exists; on a
|
||||
// phone it yields to the drawer toggle, and with nowhere to go the slot is
|
||||
// empty rather than a disabled arrow.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780, 375];
|
||||
@@ -45,6 +50,16 @@ function headerBack(page: Page): Locator {
|
||||
return page.getByTestId('workspace-header-back');
|
||||
}
|
||||
|
||||
/**
|
||||
* The generic history Back. A detail's own Back advertises Escape in browse;
|
||||
* the fallback runs no page handler, so it advertises none.
|
||||
*/
|
||||
async function expectHistoryBack(page: Page): Promise<void> {
|
||||
await expect(headerBack(page)).toBeVisible();
|
||||
await expect(headerBack(page)).toHaveAccessibleName('Back');
|
||||
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
|
||||
}
|
||||
|
||||
/** Line boxes of the heading's text; 1 means it did not wrap. */
|
||||
function headingLineCount(page: Page): Promise<number> {
|
||||
return page
|
||||
@@ -228,12 +243,15 @@ async function startFirstEpisode(page: Page): Promise<void> {
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
/** The header Back leaves the detail and is gone from the list it opens. */
|
||||
/**
|
||||
* The header Back leaves the detail; the list it opens keeps only the
|
||||
* history fallback (it was itself reached by navigation).
|
||||
*/
|
||||
async function expectHeaderBackReturnsToList(page: Page): Promise<void> {
|
||||
await headerBack(page).click();
|
||||
await expect(page).not.toHaveURL(detailUrlPattern);
|
||||
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
await expectHistoryBack(page);
|
||||
}
|
||||
|
||||
test.describe('Portal detail header Back', () => {
|
||||
@@ -248,7 +266,7 @@ test.describe('Portal detail header Back', () => {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
await expectHistoryBack(page);
|
||||
|
||||
const detailUrl = await openFirstSeries(page);
|
||||
await expectBackInHeader(page, 'browse');
|
||||
@@ -287,4 +305,45 @@ test.describe('Portal detail header Back', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron falls back to history where no page offers Back', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await page.waitForURL(/\/workspace\//);
|
||||
const startUrl = page.url();
|
||||
// The first page of the session has nowhere to go back to: the
|
||||
// slot is empty, not a disabled arrow.
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
const listUrl = page.url();
|
||||
await expectHistoryBack(page);
|
||||
|
||||
// On a phone the list's drawer toggle keeps the slot: it is the
|
||||
// only way into the categories.
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
await expect(page.getByTestId('context-drawer-toggle')).toBeVisible();
|
||||
await expect(headerBack(page)).toBeHidden();
|
||||
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await headerBack(page).click();
|
||||
await expect(page).toHaveURL(startUrl);
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
|
||||
// Forward history is not offered; browser Forward still works and
|
||||
// brings the fallback back.
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(listUrl);
|
||||
await expectHistoryBack(page);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable playwright/expect-expect -- These are Node assertion-based repository contract tests. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// docs/architecture/zoneless-migration.md lists every component that still
|
||||
// opts out of OnPush. This keeps the checklist and the code in step: a new
|
||||
// Eager component fails here, and so does a converted one left unticked.
|
||||
|
||||
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||
const checklistPath = 'docs/architecture/zoneless-migration.md';
|
||||
const sourceRoots = ['apps', 'libs'];
|
||||
const skippedDirectories = new Set([
|
||||
'node_modules',
|
||||
'dist',
|
||||
'coverage',
|
||||
'test-stubs',
|
||||
]);
|
||||
// Test-only files follow the repository's `.spec` / `.test` naming, with an
|
||||
// optional suffix of one or more segments (`.spec-stubs.ts`,
|
||||
// `.test-helpers.ts`, `.test-data-stubs.ts`).
|
||||
const testOnlyFile = /(\.(spec|test)(-\w+)*|^test-setup)\.ts$/;
|
||||
|
||||
function listProductionSources(directory: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (skippedDirectories.has(entry.name)) continue;
|
||||
if (entry.name.endsWith('-e2e')) continue;
|
||||
files.push(...listProductionSources(path));
|
||||
} else if (
|
||||
entry.name.endsWith('.ts') &&
|
||||
!entry.name.endsWith('.d.ts') &&
|
||||
!testOnlyFile.test(entry.name)
|
||||
) {
|
||||
files.push(relative(workspaceRoot, path).split(sep).join('/'));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readSources(): Map<string, string> {
|
||||
const sources = new Map<string, string>();
|
||||
for (const root of sourceRoots) {
|
||||
for (const file of listProductionSources(join(workspaceRoot, root))) {
|
||||
sources.set(file, readFileSync(join(workspaceRoot, file), 'utf8'));
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function readEagerChecklist(): { open: string[]; done: string[] } {
|
||||
const markdown = readFileSync(join(workspaceRoot, checklistPath), 'utf8');
|
||||
const section = markdown.split(/^## Eager components$/m)[1];
|
||||
assert.ok(
|
||||
section,
|
||||
`${checklistPath} must have an "Eager components" section`
|
||||
);
|
||||
const body = section.split(/^## /m)[0];
|
||||
const open: string[] = [];
|
||||
const done: string[] = [];
|
||||
for (const match of body.matchAll(/^- \[( |x)\] `([^`]+\.ts)`/gm)) {
|
||||
(match[1] === 'x' ? done : open).push(match[2]);
|
||||
}
|
||||
return { open: open.sort(), done: done.sort() };
|
||||
}
|
||||
|
||||
const sources = readSources();
|
||||
|
||||
test('the zoneless checklist lists exactly the components that are still Eager', () => {
|
||||
const eager = [...sources]
|
||||
.filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager'))
|
||||
.map(([file]) => file)
|
||||
.sort();
|
||||
const { open } = readEagerChecklist();
|
||||
|
||||
assert.deepEqual(
|
||||
eager,
|
||||
open,
|
||||
`Production files with ChangeDetectionStrategy.Eager must match the unticked entries in ${checklistPath}. ` +
|
||||
'Do not add Eager components; tick an entry when its component is converted.'
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard skips test-only file names and keeps production ones', () => {
|
||||
for (const name of [
|
||||
'player.component.spec.ts',
|
||||
'serial-details.test-stubs.ts',
|
||||
'dashboard.spec-stubs.ts',
|
||||
'rail.test-data-stubs.ts',
|
||||
'test-setup.ts',
|
||||
]) {
|
||||
assert.ok(testOnlyFile.test(name), `${name} is test-only`);
|
||||
}
|
||||
for (const name of [
|
||||
'player.component.ts',
|
||||
'spec-utils.ts',
|
||||
'contest.ts',
|
||||
'latest-setup.ts',
|
||||
'testing.service.ts',
|
||||
]) {
|
||||
assert.ok(!testOnlyFile.test(name), `${name} ships`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ticked checklist entries name files that exist', () => {
|
||||
for (const file of readEagerChecklist().done) {
|
||||
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no production component uses the deprecated Default strategy alias', () => {
|
||||
for (const [file, text] of sources) {
|
||||
assert.doesNotMatch(text, /ChangeDetectionStrategy\.Default\b/, file);
|
||||
}
|
||||
});
|
||||
@@ -638,6 +638,18 @@ test.describe('Electron Workspace Search', () => {
|
||||
await expect(
|
||||
xtreamSearchResultCards(app.mainWindow).first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// The search page's Back is the header's leading button, not an
|
||||
// arrow beside its title, and it returns to the dashboard.
|
||||
const headerBack = app.mainWindow.getByTestId(
|
||||
'workspace-header-back'
|
||||
);
|
||||
await expect(headerBack).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Back', exact: true })
|
||||
).toHaveCount(1);
|
||||
await headerBack.click();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -198,3 +198,98 @@ export async function expectSkeletonContrast(
|
||||
).toBeGreaterThanOrEqual(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
/** Contrast of an element's own text against whatever is painted behind it
|
||||
* (images, gradient scrims, translucent chips), read back from the screen.
|
||||
* The text is made transparent for the capture, so its `text-shadow` stays
|
||||
* in the backdrop it is meant to support; its CSS colour (with alpha and
|
||||
* ancestor opacity) is then composited over every pixel under its line
|
||||
* boxes. Returns the worst ratio, so one dark patch of artwork under one
|
||||
* letter fails it. */
|
||||
export async function measureBackdropTextContrast(
|
||||
page: Page,
|
||||
text: Locator
|
||||
): Promise<number> {
|
||||
await expect(text).toBeVisible();
|
||||
const probe = await text.evaluate((element) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = getComputedStyle(element).color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
let alpha = a / 255;
|
||||
for (
|
||||
let node: Element | null = element;
|
||||
node;
|
||||
node = node.parentElement
|
||||
) {
|
||||
alpha *= Number(getComputedStyle(node).opacity);
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const box = range.getBoundingClientRect();
|
||||
const style = (element as HTMLElement).style;
|
||||
const previous = style.getPropertyValue('color');
|
||||
style.setProperty('color', 'transparent', 'important');
|
||||
(element as HTMLElement).dataset['contrastPrevious'] = previous;
|
||||
return {
|
||||
color: [r, g, b, alpha],
|
||||
// Whole pixels inside the line boxes, clear of glyph edges that
|
||||
// spill past them.
|
||||
clip: {
|
||||
x: Math.ceil(box.left),
|
||||
y: Math.ceil(box.top),
|
||||
width: Math.max(1, Math.floor(box.width) - 1),
|
||||
height: Math.max(1, Math.floor(box.height) - 1),
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({ clip: probe.clip })
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const [fr, fg, fb, fa] = probe.color;
|
||||
const luminance = (r: number, g: number, b: number) =>
|
||||
[r, g, b]
|
||||
.map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045
|
||||
? s / 12.92
|
||||
: ((s + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(sum, value, index) =>
|
||||
sum + value * [0.2126, 0.7152, 0.0722][index],
|
||||
0
|
||||
);
|
||||
let worst = Infinity;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [br, bg, bb] = [data[i], data[i + 1], data[i + 2]];
|
||||
const front = luminance(
|
||||
fr * fa + br * (1 - fa),
|
||||
fg * fa + bg * (1 - fa),
|
||||
fb * fa + bb * (1 - fa)
|
||||
);
|
||||
const back = luminance(br, bg, bb);
|
||||
worst = Math.min(
|
||||
worst,
|
||||
(Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05)
|
||||
);
|
||||
}
|
||||
return worst;
|
||||
} finally {
|
||||
await text.evaluate((element) => {
|
||||
const style = (element as HTMLElement).style;
|
||||
const previous = (element as HTMLElement).dataset[
|
||||
'contrastPrevious'
|
||||
];
|
||||
style.removeProperty('color');
|
||||
if (previous) style.setProperty('color', previous);
|
||||
delete (element as HTMLElement).dataset['contrastPrevious'];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -187,3 +187,69 @@ test.describe('Custom window controls', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('macOS traffic lights', () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin',
|
||||
'Only macOS draws the native traffic lights over the rail'
|
||||
);
|
||||
|
||||
test('@electron the first rail link starts with the content area, clear of the lights', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await expect(page.locator('.app-rail')).toHaveClass(/is-macos/);
|
||||
const firstLink = page.locator('.app-rail a').first();
|
||||
await expect(firstLink).toBeVisible();
|
||||
|
||||
const [linkBox, contentBox] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.locator('.workspace-content').boundingBox(),
|
||||
]);
|
||||
// Aligned with the content area, where the dashboard hero starts.
|
||||
expect(
|
||||
Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100))
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
|
||||
);
|
||||
expect(lights, 'native window button position').toBeTruthy();
|
||||
const lightsY = lights?.y ?? Number.NaN;
|
||||
// The buttons are about 14pt tall; keep a visible gap below them,
|
||||
// measured in window pixels (CSS pixels times the zoom factor).
|
||||
const linkTopInWindowPixels = async (): Promise<number> => {
|
||||
const [box, zoom] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.evaluate(() => window.outerWidth / window.innerWidth),
|
||||
]);
|
||||
return (box?.y ?? Number.NaN) * zoom;
|
||||
};
|
||||
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
|
||||
lightsY + 14 + 16
|
||||
);
|
||||
|
||||
// App zoom scales CSS pixels but not the native buttons: at the
|
||||
// smallest zoom the inset must still clear them.
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.evaluate(() =>
|
||||
window.electron.adjustZoomLevel('out')
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => window.outerWidth / window.innerWidth)
|
||||
)
|
||||
.toBeLessThan(0.6);
|
||||
await expect
|
||||
.poll(linkTopInWindowPixels)
|
||||
.toBeGreaterThanOrEqual(lightsY + 14 + 8);
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The Linux native-view support check end to end in the main process: the
|
||||
* real IPC handlers, native service and login shell PATH lookup. Only the
|
||||
* process boundary is faked: the shell (`readPath`), `mpv --version`
|
||||
* (`spawnSync`) and Electron. `process.platform` is forced here, so the
|
||||
* Linux branch runs on every host.
|
||||
*/
|
||||
import {
|
||||
EMBEDDED_MPV_PREPARE,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
type EmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const mockSpawnSync = jest.fn();
|
||||
const mockIpcHandle = jest.fn();
|
||||
|
||||
jest.mock('child_process', () => ({ spawnSync: mockSpawnSync }));
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
commandLine: { getSwitchValue: () => '' },
|
||||
},
|
||||
ipcMain: { handle: mockIpcHandle },
|
||||
powerSaveBlocker: {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
isStarted: jest.fn(),
|
||||
},
|
||||
screen: { getDisplayMatching: jest.fn() },
|
||||
}));
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: { mainWindow: null },
|
||||
}));
|
||||
jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
readEmbeddedMpvSessionOptions: () => ({
|
||||
extraOptions: [],
|
||||
autoReconnect: true,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../services/embedded-mpv-frame-copy-platform.util', () => ({
|
||||
...jest.requireActual('../services/embedded-mpv-frame-copy-platform.util'),
|
||||
getFrameCopyRuntimeAvailability: () => ({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
}),
|
||||
isFrameCopyRuntimeUsable: () => false,
|
||||
}));
|
||||
|
||||
const INHERITED_PATH = '/usr/bin:/bin';
|
||||
const LOGIN_SHELL_ONLY_DIR = '/home/user/.local/bin';
|
||||
const LOGIN_SHELL_PATH = `${LOGIN_SHELL_ONLY_DIR}:${INHERITED_PATH}`;
|
||||
/** Budget of the lookup; the shell in these tests never answers within it. */
|
||||
const LOOKUP_BUDGET_MS = 5;
|
||||
|
||||
type SupportHandler = (event: unknown) => Promise<EmbeddedMpvSupport>;
|
||||
|
||||
async function flushLookup(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('Embedded MPV support and a slow login shell (Linux native-view)', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = {
|
||||
PATH: process.env.PATH,
|
||||
DISPLAY: process.env.DISPLAY,
|
||||
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY,
|
||||
};
|
||||
let answerShell: (path: string) => void;
|
||||
|
||||
function handlerFor(channel: string): SupportHandler {
|
||||
const registration = mockIpcHandle.mock.calls.find(
|
||||
([registered]) => registered === channel
|
||||
);
|
||||
if (!registration) {
|
||||
throw new Error(`Missing ipcMain handler for ${channel}`);
|
||||
}
|
||||
return registration[1] as SupportHandler;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockIpcHandle.mockReset();
|
||||
mockSpawnSync.mockReset();
|
||||
// mpv is installed where only the login shell PATH reaches it.
|
||||
mockSpawnSync.mockImplementation(() => ({
|
||||
status: (process.env.PATH ?? '')
|
||||
.split(':')
|
||||
.includes(LOGIN_SHELL_ONLY_DIR)
|
||||
? 0
|
||||
: 1,
|
||||
}));
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.PATH = INHERITED_PATH;
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
||||
|
||||
const { scheduleDeferredFixPath } =
|
||||
await import('../startup/login-shell-path');
|
||||
const { embeddedMpvNativeService } =
|
||||
await import('../services/embedded-mpv-native.service');
|
||||
await import('./embedded-mpv.events');
|
||||
// The addon is normally a vendored .node file; with it in place,
|
||||
// mpv on PATH is the only thing support depends on.
|
||||
(
|
||||
embeddedMpvNativeService as unknown as {
|
||||
addon: { isSupported(): boolean };
|
||||
}
|
||||
).addon = { isSupported: () => true };
|
||||
|
||||
scheduleDeferredFixPath(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answerShell = resolve;
|
||||
}),
|
||||
LOOKUP_BUDGET_MS
|
||||
);
|
||||
// The lookup starts on the next tick; only then can it be answered.
|
||||
await flushLookup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Let the lookup finish, so no test leaves a pending shell behind.
|
||||
answerShell(INHERITED_PATH);
|
||||
await flushLookup();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
|
||||
'%s reports a missing mpv as inconclusive until the shell answers',
|
||||
async (channel) => {
|
||||
const check = handlerFor(channel);
|
||||
|
||||
// The lookup runs out of budget: this probe sees the inherited
|
||||
// PATH, where mpv is missing.
|
||||
await expect(check({})).resolves.toMatchObject({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
// Asked again meanwhile: the cached answer is still not final.
|
||||
await expect(check({})).resolves.toMatchObject({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
answerShell(LOGIN_SHELL_PATH);
|
||||
await flushLookup();
|
||||
|
||||
const settled = await check({});
|
||||
expect(settled.supported).toBe(true);
|
||||
expect(settled.inconclusive).toBeUndefined();
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports a missing mpv as final once the shell answered without it', async () => {
|
||||
const support = handlerFor(EMBEDDED_MPV_SUPPORT);
|
||||
await expect(support({})).resolves.toMatchObject({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
|
||||
answerShell(INHERITED_PATH);
|
||||
await flushLookup();
|
||||
|
||||
const settled = await support({});
|
||||
expect(settled.supported).toBe(false);
|
||||
expect(settled.reason).toContain('mpv executable');
|
||||
expect(settled.inconclusive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a missing mpv as final when the shell answered in time', async () => {
|
||||
answerShell(INHERITED_PATH);
|
||||
await flushLookup();
|
||||
|
||||
const answer = await handlerFor(EMBEDDED_MPV_SUPPORT)({});
|
||||
expect(answer.supported).toBe(false);
|
||||
expect(answer.inconclusive).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const mockEmbeddedMpvService = {
|
||||
getSupport: jest.fn(),
|
||||
willProbeLinuxMpvExecutable: jest.fn(() => false),
|
||||
forgetLinuxMpvExecutableProbe: jest.fn(),
|
||||
markLinuxMpvExecutableProbeProvisional: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
};
|
||||
const mockSessionOptions = {
|
||||
@@ -26,9 +27,7 @@ jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
}));
|
||||
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
|
||||
let settleLookup: () => void = () => undefined;
|
||||
const mockLookupSettled = new Promise<void>((resolve) => {
|
||||
settleLookup = resolve;
|
||||
});
|
||||
let mockLookupSettled = Promise.resolve();
|
||||
jest.mock('../startup/login-shell-path', () => ({
|
||||
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
|
||||
whenLoginShellPathSettled: () => mockLookupSettled,
|
||||
@@ -69,6 +68,16 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
});
|
||||
|
||||
describe('support checks and the login shell PATH', () => {
|
||||
beforeEach(() => {
|
||||
// A lookup of its own per test: the pending re-probe of one test
|
||||
// must not answer for the next.
|
||||
mockLookupSettled = new Promise<void>((resolve) => {
|
||||
settleLookup = resolve;
|
||||
});
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe.mockClear();
|
||||
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockWaitForLoginShellPath.mockClear();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
|
||||
@@ -110,6 +119,10 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
// The probe saw the login shell PATH: its answer is final.
|
||||
expect(
|
||||
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -125,6 +138,17 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
await expect(
|
||||
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
|
||||
).resolves.toEqual({ supported: false });
|
||||
// The service is told before it probes, so the answer of this
|
||||
// very check is already marked as not final.
|
||||
const { markLinuxMpvExecutableProbeProvisional, getSupport } =
|
||||
mockEmbeddedMpvService;
|
||||
expect(
|
||||
markLinuxMpvExecutableProbeProvisional
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
markLinuxMpvExecutableProbeProvisional.mock
|
||||
.invocationCallOrder[0]
|
||||
).toBeLessThan(getSupport.mock.invocationCallOrder[0]);
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
@@ -136,6 +160,33 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still re-probes when the check on the inherited PATH throws', async () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
true
|
||||
);
|
||||
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
|
||||
mockEmbeddedMpvService.getSupport.mockImplementation(() => {
|
||||
throw new Error('probe failed');
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
|
||||
).rejects.toThrow('probe failed');
|
||||
// Otherwise the provisional state would outlive the lookup.
|
||||
settleLookup();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not wait when no probe runs, nor for session calls', async () => {
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: true,
|
||||
|
||||
@@ -80,13 +80,14 @@ async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
|
||||
) {
|
||||
return check();
|
||||
}
|
||||
// The lookup ran out of budget, so this probe sees the inherited PATH.
|
||||
// Once the shell does answer, the result is probed again.
|
||||
const result = check();
|
||||
// The lookup ran out of budget, so this probe sees the inherited PATH: a
|
||||
// missing mpv is answered as inconclusive, never as a verdict the
|
||||
// renderer may persist. Once the shell does answer, it is probed again.
|
||||
getService().markLinuxMpvExecutableProbeProvisional();
|
||||
void whenLoginShellPathSettled().then(() =>
|
||||
getService().forgetLinuxMpvExecutableProbe()
|
||||
);
|
||||
return result;
|
||||
return check();
|
||||
}
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
|
||||
|
||||
@@ -414,6 +414,56 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a missing mpv as inconclusive only while its probe is provisional', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockReturnValue({ status: 1 });
|
||||
mockRuntimeUsable();
|
||||
|
||||
// The login shell has not answered: mpv is looked up on the
|
||||
// inherited PATH.
|
||||
service.markLinuxMpvExecutableProbeProvisional();
|
||||
expect(service.getSupport()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
})
|
||||
);
|
||||
expect(service.prepareAddon()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
})
|
||||
);
|
||||
|
||||
// It answered: the next probe is a verdict again.
|
||||
service.forgetLinuxMpvExecutableProbe();
|
||||
const settled = service.getSupport();
|
||||
expect(settled.supported).toBe(false);
|
||||
expect(settled.reason).toContain('mpv executable');
|
||||
expect(settled.inconclusive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps every other answer final while the mpv probe is provisional', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
mockRuntimeUsable();
|
||||
service.markLinuxMpvExecutableProbeProvisional();
|
||||
|
||||
const found = service.getSupport();
|
||||
expect(found.supported).toBe(true);
|
||||
expect(found.inconclusive).toBeUndefined();
|
||||
|
||||
// mpv is there, the addon is not: the PATH cannot change that.
|
||||
addon.isSupported.mockReturnValue(false);
|
||||
const unsupported = service.getSupport();
|
||||
expect(unsupported.supported).toBe(false);
|
||||
expect(unsupported.inconclusive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('predicts no probe for the frame-copy engine or native Wayland', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
|
||||
@@ -174,6 +174,12 @@ export class EmbeddedMpvNativeService {
|
||||
private powerBlockerId: number | null = null;
|
||||
private readonly loadAddonModule = createRequire(__filename);
|
||||
private cachedLinuxMpvExecutableReason: string | null | undefined;
|
||||
/**
|
||||
* True while `mpv --version` runs, or was cached, on the inherited PATH
|
||||
* because the login shell had not answered: a missing mpv is then no
|
||||
* verdict yet.
|
||||
*/
|
||||
private linuxMpvExecutableProbeIsProvisional = false;
|
||||
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
|
||||
private sessionOptionsDirectory: string | null = null;
|
||||
/**
|
||||
@@ -401,6 +407,16 @@ export class EmbeddedMpvNativeService {
|
||||
*/
|
||||
forgetLinuxMpvExecutableProbe(): void {
|
||||
this.cachedLinuxMpvExecutableReason = undefined;
|
||||
this.linuxMpvExecutableProbeIsProvisional = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares that the probe sees the inherited PATH, the login shell one
|
||||
* not having arrived. Until `forgetLinuxMpvExecutableProbe()`, a missing
|
||||
* mpv is reported as `inconclusive`, so no caller settles on it.
|
||||
*/
|
||||
markLinuxMpvExecutableProbeProvisional(): void {
|
||||
this.linuxMpvExecutableProbeIsProvisional = true;
|
||||
}
|
||||
|
||||
getSupport(): EmbeddedMpvSupport {
|
||||
@@ -458,6 +474,9 @@ export class EmbeddedMpvNativeService {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: missingLinuxMpvExecutableReason,
|
||||
...(this.linuxMpvExecutableProbeIsProvisional
|
||||
? { inconclusive: true }
|
||||
: {}),
|
||||
...this.getFrameCopySupportDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,17 +406,21 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
|
||||
)
|
||||
)
|
||||
.toBe(0.25);
|
||||
// M3U has no browse Back target, so the header shows no arrow in either
|
||||
// state; the now-playing bar's own Close button returns to browse.
|
||||
// M3U registers no Back target in either state: the header's only arrow
|
||||
// is the history fallback to the dashboard the import started from,
|
||||
// which claims no Escape. The now-playing bar's own Close button
|
||||
// returns to browse.
|
||||
const shell = detail(page).locator('app-portal-detail-shell');
|
||||
const headerBack = page.locator('[data-test-id="workspace-header-back"]');
|
||||
await expect(headerBack).toHaveCount(0);
|
||||
await expect(headerBack).toHaveCount(1);
|
||||
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
|
||||
await shell
|
||||
.locator('app-portal-inline-player')
|
||||
.getByRole('button', { name: 'Close player', exact: true })
|
||||
.click();
|
||||
await expect(inlineVideo(page)).toHaveCount(0);
|
||||
await expect(headerBack).toHaveCount(0);
|
||||
await expect(headerBack).toHaveCount(1);
|
||||
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
|
||||
// The hero keeps its own inset (32px, or 20px in a pane narrower than
|
||||
// 760px) in both states.
|
||||
expect(
|
||||
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
* by default so the content keeps the full viewport width, opened from
|
||||
* the header toggle (winning over the persisted desktop inline width),
|
||||
* and closed again by picking a category or tapping the backdrop.
|
||||
* 4. The settings section list scrolls instead of painting over the
|
||||
* Back footer — now inside the open drawer.
|
||||
* 4. Settings keeps its drawer toggle beside the header Back (the drawer
|
||||
* holds the sections), and the section list scrolls inside the drawer.
|
||||
* On a portal list, the header's history Back yields to the toggle.
|
||||
* 5. On a 640x360 landscape phone the live route keeps the channel
|
||||
* sidebar at least 72px tall and the player container inside the
|
||||
* viewport.
|
||||
@@ -107,17 +108,25 @@ test.describe('portrait phone 375x812', () => {
|
||||
await expectRailLinksInsideTopBar(page);
|
||||
});
|
||||
|
||||
test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({
|
||||
test('@mobile settings keeps the drawer toggle beside the header Back and the section list inside the drawer', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/workspace/settings');
|
||||
|
||||
// Back is the header's leading button. The drawer holds the section
|
||||
// list, so its toggle stays beside Back instead of giving way.
|
||||
const back = page.locator('[data-test-id="workspace-header-back"]');
|
||||
const toggle = page.locator('[data-test-id="context-drawer-toggle"]');
|
||||
await expect(back).toBeVisible();
|
||||
await expect(toggle).toBeVisible();
|
||||
expect((await boxOf(back)).x).toBeLessThan((await boxOf(toggle)).x);
|
||||
|
||||
// The phone context panel is an off-canvas drawer: hidden until the
|
||||
// header toggle opens it, so the settings content owns the pane.
|
||||
const panel = page.locator('.context-panel--settings');
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
await page.locator('[data-test-id="context-drawer-toggle"]').click();
|
||||
await toggle.click();
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Narrower than the viewport so the backdrop stays tappable, and
|
||||
@@ -126,15 +135,13 @@ test.describe('portrait phone 375x812', () => {
|
||||
expect(panelBox.width).toBeGreaterThanOrEqual(300);
|
||||
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
|
||||
|
||||
const footer = panel.locator('.settings-panel-footer');
|
||||
await expect(footer.locator('.settings-back-button')).toBeVisible();
|
||||
|
||||
// Before #1326 the section list kept its full content height and
|
||||
// painted over the footer whenever the panel was shorter than its
|
||||
// sections; now the list scrolls and ends above the footer.
|
||||
// No footer Back any more; the list scrolls and ends inside the
|
||||
// panel instead of painting past it (#1326).
|
||||
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
|
||||
const listBox = await boxOf(panel.locator('.settings-sections-list'));
|
||||
const footerBox = await boxOf(footer);
|
||||
expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
||||
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
|
||||
panelBox.y + panelBox.height + 1
|
||||
);
|
||||
|
||||
// Tapping the backdrop (right of the drawer) closes it.
|
||||
await page
|
||||
@@ -163,7 +170,18 @@ test.describe('xtream portal routes on a phone', () => {
|
||||
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The import navigated here from the dashboard, so the header offers
|
||||
// the history Back on wide screens...
|
||||
const back = page.locator('[data-test-id="workspace-header-back"]');
|
||||
await expect(back).toBeVisible();
|
||||
|
||||
await page.setViewportSize(PHONE);
|
||||
// ...and on a phone it yields to the drawer toggle, the list's only
|
||||
// way into its categories.
|
||||
await expect(back).toBeHidden();
|
||||
await expect(
|
||||
page.locator('[data-test-id="context-drawer-toggle"]')
|
||||
).toBeVisible();
|
||||
|
||||
// Hidden by default — the content owns the full pane. This is the
|
||||
// successor to the #1326 stacked layout, which left the content
|
||||
|
||||
@@ -7,7 +7,12 @@ async function openSettings(page: Page) {
|
||||
// The bare settings URL redirects to the default section page.
|
||||
await page.waitForURL(/\/workspace\/settings\/general$/);
|
||||
await expect(page.locator('.settings-container')).toBeVisible();
|
||||
await expect(page.locator('.settings-back-button')).toBeVisible();
|
||||
await expect(settingsBack(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Settings' Back is the workspace header's leading button. */
|
||||
function settingsBack(page: Page) {
|
||||
return page.locator('[data-test-id="workspace-header-back"]');
|
||||
}
|
||||
|
||||
/** Settings render one section page at a time — open it via the rail. */
|
||||
@@ -36,7 +41,12 @@ test.describe('Settings', () => {
|
||||
|
||||
test('@settings @web Check settings page', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
await page.locator('.settings-back-button').click();
|
||||
// The context panel no longer carries a Back of its own.
|
||||
await expect(
|
||||
page.locator('app-workspace-settings-context-panel button')
|
||||
).toHaveCount(0);
|
||||
await settingsBack(page).click();
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
});
|
||||
|
||||
test('@settings @web Change video player', async ({ page }) => {
|
||||
|
||||
@@ -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<Locator> {
|
||||
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<void> {
|
||||
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) });
|
||||
});
|
||||
}
|
||||
@@ -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<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() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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 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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<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() });
|
||||
});
|
||||
}
|
||||
|
||||
/** 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<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 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<void> {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
} from './xtream-series-playback.fixture';
|
||||
|
||||
/**
|
||||
* Pages reached from a detail or the header search draw no Back arrow of
|
||||
* their own: they register the workspace header's leading Back, which keeps
|
||||
* their previous return behaviour (history Back).
|
||||
* Contract: docs/architecture/workspace-shell.md, "Header Back".
|
||||
*/
|
||||
|
||||
const headerBack = (page: Page) =>
|
||||
page.locator(
|
||||
'app-workspace-shell-header [data-test-id="workspace-header-back"]'
|
||||
);
|
||||
|
||||
/** The one Back button on the page, wherever it lives. */
|
||||
const anyBack = (page: Page) =>
|
||||
page.getByRole('button', { name: 'Back', exact: true });
|
||||
|
||||
async function expectOnlyHeaderBack(page: Page): Promise<void> {
|
||||
await expect(headerBack(page)).toBeVisible();
|
||||
await expect(anyBack(page)).toHaveCount(1);
|
||||
// None of these pages handles Escape.
|
||||
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${MOCK_SERVER}/reset`);
|
||||
await page.goto('/');
|
||||
await interceptXtreamRequests(page);
|
||||
await addXtreamPortal(page);
|
||||
});
|
||||
|
||||
test('@web @xtream the in-portal search page returns through the header Back', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page
|
||||
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
// The rail link's tooltip would otherwise sit over the header's leading
|
||||
// button for as long as the pointer rests on the link.
|
||||
await page.mouse.move(640, 400);
|
||||
|
||||
// Enter on the dashboard opens the active portal's search page.
|
||||
const search = page.locator(
|
||||
'app-workspace-shell-header .search-field input[type="search"]'
|
||||
);
|
||||
await search.fill('Movie');
|
||||
await search.press('Enter');
|
||||
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/search\?q=Movie$/);
|
||||
await expect(page.locator('app-search-layout')).toBeVisible();
|
||||
|
||||
await expectOnlyHeaderBack(page);
|
||||
await headerBack(page).click();
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
});
|
||||
|
||||
for (const { name, path, selector } of [
|
||||
{
|
||||
name: 'Discover',
|
||||
path: 'discover?type=movie&genre=18&genreLabel=Drama',
|
||||
selector: 'app-discover-view',
|
||||
},
|
||||
{ name: 'actor', path: 'actor/287', selector: 'app-actor-view' },
|
||||
]) {
|
||||
test(`@web @xtream the ${name} page returns through the header Back`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const listUrl = page.url();
|
||||
const portalUrl = listUrl.replace(/\/vod.*$/, '');
|
||||
|
||||
await page.goto(`${portalUrl}/${path}`);
|
||||
await expect(page.locator(selector)).toBeAttached();
|
||||
|
||||
await expectOnlyHeaderBack(page);
|
||||
await headerBack(page).click();
|
||||
await page.waitForURL(listUrl);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
|
||||
EmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
|
||||
|
||||
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
|
||||
/** A slow login shell: mpv was looked up before its PATH arrived. */
|
||||
const INCONCLUSIVE: EmbeddedMpvSupport = {
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'mpv executable missing',
|
||||
inconclusive: true,
|
||||
};
|
||||
|
||||
describe('SettingsEmbeddedMpvFacade', () => {
|
||||
const originalElectron = window.electron;
|
||||
let getEmbeddedMpvSupport: jest.Mock;
|
||||
let facade: SettingsEmbeddedMpvFacade;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
getEmbeddedMpvSupport = jest.fn();
|
||||
window.electron = {
|
||||
platform: 'linux',
|
||||
getEmbeddedMpvSupport,
|
||||
} as unknown as typeof window.electron;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SettingsEmbeddedMpvFacade,
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
facade = TestBed.inject(SettingsEmbeddedMpvFacade);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
window.electron = originalElectron;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('offers Embedded MPV once an inconclusive answer turns into supported', async () => {
|
||||
getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(INCONCLUSIVE)
|
||||
.mockResolvedValue(SUPPORTED);
|
||||
|
||||
await facade.load();
|
||||
expect(facade.supported()).toBe(false);
|
||||
|
||||
// The page stays open; nobody calls load() again.
|
||||
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
|
||||
expect(facade.supported()).toBe(true);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops asking once the settings page is closed', async () => {
|
||||
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
|
||||
await facade.load();
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a failed probe as unsupported', async () => {
|
||||
getEmbeddedMpvSupport.mockRejectedValue(new Error('addon load failed'));
|
||||
|
||||
await facade.load();
|
||||
|
||||
expect(facade.support()).toEqual({
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'addon load failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
watchEmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Probes the desktop backend for embedded MPV support so the playback
|
||||
@@ -9,6 +18,7 @@ import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
|
||||
@Injectable()
|
||||
export class SettingsEmbeddedMpvFacade {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private stopSupportWatch: (() => void) | undefined;
|
||||
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
|
||||
@@ -24,6 +34,14 @@ export class SettingsEmbeddedMpvFacade {
|
||||
() => this.support()?.engine === 'frame-copy'
|
||||
);
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.stopSupportWatch?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves with the first answer. An inconclusive one keeps being asked
|
||||
* for while the page is open, so the option appears without reopening it.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (!this.runtime.isElectron) {
|
||||
this.support.set({
|
||||
@@ -43,15 +61,27 @@ export class SettingsEmbeddedMpvFacade {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.support.set(await window.electron.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron.platform,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
this.stopSupportWatch?.();
|
||||
await new Promise<void>((answered) => {
|
||||
this.stopSupportWatch = watchEmbeddedMpvSupport(
|
||||
() => window.electron.getEmbeddedMpvSupport(),
|
||||
(support) => {
|
||||
this.support.set(support);
|
||||
answered();
|
||||
},
|
||||
(error) => {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
answered();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,7 +66,9 @@ export class SettingsSearchFacade {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.settingsSearch.ensureEmbeddedMpvSupportLoaded();
|
||||
inject(DestroyRef).onDestroy(
|
||||
this.settingsSearch.followEmbeddedMpvSupport()
|
||||
);
|
||||
|
||||
effect(() => {
|
||||
if (!this.isSearching()) {
|
||||
|
||||
@@ -95,6 +95,21 @@ describe('SettingsComponent search', () => {
|
||||
expect(query('app-settings-general-section')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('follows Embedded MPV support only while the page is open', () => {
|
||||
const stopFollowing = jest.fn();
|
||||
const follow = jest
|
||||
.spyOn(settingsSearch, 'followEmbeddedMpvSupport')
|
||||
.mockReturnValue(stopFollowing);
|
||||
|
||||
const page = TestBed.createComponent(SettingsComponent);
|
||||
expect(follow).toHaveBeenCalledTimes(1);
|
||||
expect(stopFollowing).not.toHaveBeenCalled();
|
||||
|
||||
// Closing the page ends it: nothing shows these rows any more.
|
||||
page.destroy();
|
||||
expect(stopFollowing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows an empty state when nothing matches', () => {
|
||||
setSettingsSearchQuery('zzzz-no-such-setting');
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -157,8 +157,9 @@ html {
|
||||
--app-cta-fg: #f5f6f8;
|
||||
--app-cta-hover-bg: #2a2f3a;
|
||||
--app-cta-meta-fg: rgba(245, 246, 248, 0.68);
|
||||
// Star rating chip: amber on white needs a deeper tone than gold on dark.
|
||||
--app-rating-color: #a16207;
|
||||
// Star rating chip: a deep amber, since the chip's 12px text needs
|
||||
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
|
||||
--app-rating-color: #7a4a00;
|
||||
|
||||
.dark-theme {
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
|
||||
|
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 471 KiB After Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 476 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 440 KiB After Width: | Height: | Size: 407 KiB |
@@ -52,7 +52,7 @@ to the self-hosted browser version.
|
||||
steps={[
|
||||
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add M3U playlist".',
|
||||
'Choose "M3U URL" and paste the link your provider gave you, or choose "M3U file" and pick the file, or choose "Raw m3u text" and paste the playlist itself.',
|
||||
'Give the playlist a title. For a URL the title is optional; IPTVnator falls back to the file name.',
|
||||
'For a URL, optionally fill in a playlist title; without one, IPTVnator falls back to the file name. A file keeps its file name and pasted text is titled "Imported as text"; you can rename either later.',
|
||||
'Click "Add playlist". The channels are parsed, grouped by their group-title attribute, and the playlist opens.',
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: Back Up Your Playlists and Move to a New Computer
|
||||
description: Export and restore an IPTVnator playlist backup, understand which favorites and playback state it carries, and check what needs to be moved separately when changing computers.
|
||||
pubDate: 2026-09-27
|
||||
pubDate: 2026-10-04
|
||||
author: 4gray
|
||||
heroImage: /iptvnator/blog/feature-guides/screenshots/guide-backup-dark.png
|
||||
tags:
|
||||
- guide
|
||||
- troubleshooting
|
||||
draft: true
|
||||
draft: false
|
||||
faq:
|
||||
- q: Is a playlist backup a complete copy of the app?
|
||||
a: No. It contains playlist definitions, supported playlist-specific user state and global EPG source addresses. It does not include the full settings profile, cached catalog and EPG data, or downloaded media.
|
||||
@@ -38,7 +38,7 @@ by source type, so check the table below before relying on it as your only copy
|
||||
1. On the computer with the library you want to keep, open **Settings → Backup**.
|
||||
2. Under **Import/export playlist backups**, choose **Export**.
|
||||
3. Save the JSON file. The suggested name includes the export date, for example
|
||||
`iptvnator-playlist-backup-2026-09-27.json`.
|
||||
`iptvnator-playlist-backup-2026-10-04.json`.
|
||||
4. Keep a copy outside the app's own data folder. If you are moving computers, transfer
|
||||
it through a location you control.
|
||||
|
||||
@@ -46,9 +46,9 @@ Export the receiving computer's library too if it already has sources and favori
|
||||
care about. Restoring a snapshot can replace saved state for a matching source; preserving
|
||||
both backups gives you a record of each library before the import.
|
||||
|
||||

|
||||

|
||||
|
||||
*Settings → Backup contains both actions: Export data creates the backup; Import data restores it.*
|
||||
*Settings → Backup contains both actions: Export creates the backup; Import restores it.*
|
||||
|
||||
## What travels in the file
|
||||
|
||||
@@ -108,6 +108,7 @@ importing snapshots can make it harder to tell which state you are examining.
|
||||
|
||||
## Related
|
||||
|
||||
- [Choose between Stable and Nightly updates](/iptvnator/blog/stable-nightly-updates-guide/)
|
||||
- [Add an M3U playlist and EPG](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
|
||||
- [Connect an Xtream account](/iptvnator/blog/xtream-codes-setup-guide/)
|
||||
- [Connect a Stalker portal](/iptvnator/blog/stalker-portal-setup-guide/)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Stable or Nightly? How IPTVnator Updates Work
|
||||
description: Choose an update channel in the desktop app, understand what happens when you switch back to Stable, and find the build details that make a nightly bug report useful.
|
||||
pubDate: 2026-09-27
|
||||
pubDate: 2026-10-04
|
||||
author: 4gray
|
||||
heroImage: /iptvnator/blog/feature-guides/screenshots/guide-update-channel-dark.png
|
||||
tags:
|
||||
- guide
|
||||
draft: true
|
||||
draft: false
|
||||
faq:
|
||||
- q: Which update channel should I use?
|
||||
a: Stable is the default and suits everyday viewing. Nightly is for trying changes before the next stable release and helping test them. Nightly builds can introduce regressions.
|
||||
@@ -103,6 +103,7 @@ the update or only after refreshing a playlist.
|
||||
|
||||
## Related
|
||||
|
||||
- [Back up your playlists before updating](/iptvnator/blog/playlist-backup-restore-guide/)
|
||||
- [Stable releases](https://github.com/4gray/iptvnator/releases)
|
||||
- [Nightly builds](https://github.com/4gray/iptvnator-nightly/releases)
|
||||
- [Desktop installation options](/iptvnator/download/)
|
||||
|
||||
@@ -14,11 +14,11 @@ faq:
|
||||
- q: Which portal URL should I enter?
|
||||
a: Whatever your provider gave you, as long as it starts with http or https. A bare host, an address ending in /c, portal.php, or server/load.php all work. IPTVnator probes the usual endpoints behind that address and keeps the one that answers, so you rarely need to guess the exact path.
|
||||
- q: The provider's MAC address is written with dashes or in lowercase. Does it matter?
|
||||
a: No. IPTVnator normalizes hyphens, dots, spaces and lowercase letters into the canonical 00:1A:79:XX:XX:XX form when you leave the field, and again when you click Add. The bytes stay the same, so the portal sees the address it registered.
|
||||
a: No. IPTVnator normalizes hyphens, dots, spaces and lowercase letters into the canonical 00:1A:79:XX:XX:XX form when you leave the field, and again when you click Add playlist. The bytes stay the same, so the portal sees the address it registered.
|
||||
- q: Should I turn on "Generate device IDs from the MAC address"?
|
||||
a: Only for a MAC the portal has never seen, or one you already use in StbEmu with generated IDs. A portal pins the first device ID it receives to the MAC and refuses any other one afterwards, so enabling it for an existing box with a different ID locks the account out. If your provider did not mention device IDs, leave the option off.
|
||||
- q: Why does IPTVnator ask for a username and password on some portals?
|
||||
a: A few Ministra portals require a login on top of the MAC address. IPTVnator reports "This portal requires a login and password" when it sees that; fill in the two optional fields and click Add again. Portals that never ask for it ignore the fields.
|
||||
a: A few Ministra portals require a login on top of the MAC address. IPTVnator reports "This portal requires a login and password" under the portal URL when it sees that; fill in the two optional fields and click Add playlist again. Portals that never ask for it ignore the fields.
|
||||
- q: Does IPTVnator show a program guide and catch-up for portal channels?
|
||||
a: Yes. It loads the portal's seven-day schedule once per session and shows the current program in the channel list and a timeline under the player. Channels the portal marks as archived can be played from the timeline. On the desktop app, XMLTV sources configured in the settings act as a fallback.
|
||||
- q: Where can I see when my portal subscription expires?
|
||||
@@ -65,8 +65,8 @@ it, because some reseller panels are configured differently.
|
||||
steps={[
|
||||
'Open the Add playlist dialog from the dashboard, the header playlist switcher, or the command palette entry "Add Stalker portal".',
|
||||
'Choose the "Stalker portal" method.',
|
||||
'Fill in a title of your choice, the portal URL and the MAC address. Leave the optional fields empty unless your provider gave you values for them.',
|
||||
'Click "Add". IPTVnator probes the portal, authenticates with your MAC address, and opens the new source. "Validating portal..." can take a few seconds on slow servers.',
|
||||
'Fill in a playlist title of your choice, the portal URL in the "Server URL" field and the MAC address. Leave the optional fields empty unless your provider gave you values for them.',
|
||||
'Click "Add playlist". IPTVnator probes the portal, authenticates with your MAC address, and opens the new source. "Validating portal…" can take a few seconds on slow servers.',
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -75,10 +75,10 @@ it, because some reseller panels are configured differently.
|
||||
<Alert type="success" title="Paste the provider message instead">
|
||||
The Auto-detect method in the same dialog reads a pasted email or chat message. A MAC
|
||||
address is its strongest signal, so a message with a portal link and a MAC address
|
||||
pre-fills the Stalker form for you. You still click Add yourself.
|
||||
pre-fills the Stalker form for you. You still click Add playlist yourself.
|
||||
</Alert>
|
||||
|
||||
## What happens when you click Add
|
||||
## What happens when you click Add playlist
|
||||
|
||||
Providers write portal addresses in a dozen shapes, and the same host can run a strict
|
||||
Ministra middleware or a tolerant reseller panel. Guessing from the URL used to break real
|
||||
@@ -94,10 +94,11 @@ setups, so IPTVnator no longer guesses:
|
||||
that address, and if a provider migrates the portal, IPTVnator re-probes once on the
|
||||
first failure and repairs the stored connection.
|
||||
|
||||
On a full portal the profile also carries your subscription facts, and the dialog confirms
|
||||
the import with the expiry date. A portal that cannot be reached during the import is still
|
||||
added when the address looks like a panel, so a temporarily offline provider does not block
|
||||
you; the next successful request completes the setup.
|
||||
On a full portal the profile also carries your subscription facts, and IPTVnator confirms
|
||||
the import with "Portal validated" and the expiry date. A portal that cannot be reached
|
||||
during the import is still added when the address looks like a panel, with a notice that it
|
||||
was added without validation, so a temporarily offline provider does not block you; the next
|
||||
successful request completes the setup.
|
||||
|
||||
## After the import
|
||||
|
||||
@@ -146,7 +147,8 @@ behind your back, and that matters because of how portals treat device IDs:
|
||||
later one. A later empty value locks the MAC out for good.
|
||||
- **Generate device IDs from the MAC address** produces the same pair StbEmu generates, so a
|
||||
MAC you already use there keeps working. It is offered at import only, writes the values
|
||||
into the visible fields, and re-derives them if you correct the MAC before clicking Add.
|
||||
into the visible fields, and re-derives them if you correct the MAC before clicking Add
|
||||
playlist.
|
||||
- Once a device ID is stored, the playlist details dialog warns before you change or clear
|
||||
it, because the portal will not accept the new value.
|
||||
|
||||
@@ -155,10 +157,13 @@ exactly those values.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
When the portal refuses the import, the dialog stays open and shows the reason under the
|
||||
portal URL, followed by the portal's own words when it sent any. Editing a field clears it.
|
||||
|
||||
- **The portal requires a login and password**: fill in the optional username and password
|
||||
fields and click Add again. Some Ministra portals demand a login on top of the MAC address.
|
||||
fields and click Add playlist again. Some Ministra portals demand a login on top of the MAC address.
|
||||
- **The portal rejected the login and password**: the credentials are wrong or the
|
||||
subscription lapsed; the portal's own message is shown below the headline.
|
||||
subscription lapsed; the portal's own message follows the headline.
|
||||
- **The portal has a different device ID registered for this MAC address**: another box or
|
||||
emulator authenticated first. Restore the device ID you used there, or ask the provider to
|
||||
reset the device for this MAC.
|
||||
|
||||
@@ -66,18 +66,21 @@ credentials. IPTVnator can read them out of the link for you, as shown below.
|
||||
'Open the Add playlist dialog. You will find the button on the dashboard, in the header playlist switcher, and in the command palette under "Add Xtream Codes playlist".',
|
||||
'Choose the "Xtream credentials" method.',
|
||||
'Fill in a playlist title of your choice, the server URL, the username and the password.',
|
||||
'Click "Test Connection". IPTVnator asks the portal for the account status and shows the result under the form.',
|
||||
'Click "Add". IPTVnator downloads the categories for live TV, movies and series and opens the new source.',
|
||||
'Click "Test HTTPS and HTTP". IPTVnator asks the portal for the account status and shows the result under the server URL. If an `https://` address refuses the connection or does not speak HTTPS, the test retries the same address over plain HTTP once, which is why the dialog warns that your username and password may travel unencrypted.',
|
||||
'Click "Add playlist". IPTVnator downloads the categories for live TV, movies and series and opens the new source.',
|
||||
]}
|
||||
/>
|
||||
|
||||

|
||||
|
||||
The connection test reports one of four states. **Connection successful! Portal is active** is
|
||||
the one you want. **Portal subscription has expired** and **Portal is inactive** mean the
|
||||
portal answered but refuses the account; check the subscription with your provider. **Could
|
||||
not connect to the portal** means no answer at all: a typo in the server URL, a wrong port, a
|
||||
missing `http://` or `https://`, or a host that is down.
|
||||
**Connected. The portal is active.** is the result you want; **Connected using HTTP** means the
|
||||
same over an unencrypted connection, after the HTTPS attempt failed and the server URL was
|
||||
switched to `http://`. **The subscription has expired** and **The account is inactive or the
|
||||
credentials are incorrect** mean the portal answered but refuses the account: check the
|
||||
credentials, then the subscription with your provider. **Could not reach the server** means no
|
||||
answer at all: a typo in the server URL, a wrong port, or a host that is down. The remaining
|
||||
messages name what went wrong instead: a failed secure connection, an HTTP error status, or a
|
||||
server that answered without valid account information.
|
||||
|
||||
<Alert type="success" title="Paste the whole link">
|
||||
If you paste a complete `get.php` or `player_api.php` link into the Server URL field while the
|
||||
|
||||