1166 lines
38 KiB
TypeScript
1166 lines
38 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import {
|
|
accessSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import {
|
|
_electron as electron,
|
|
type ElectronApplication,
|
|
type Page,
|
|
} from '@playwright/test';
|
|
|
|
type ThemeName = 'dark' | 'light';
|
|
type ScreenshotSlug =
|
|
| 'dashboard'
|
|
| 'm3u-live-groups-two-column'
|
|
| 'settings'
|
|
| 'xtream-series-season-open'
|
|
| 'xtream-vod-details';
|
|
|
|
type CaptureTarget = {
|
|
slug: ScreenshotSlug;
|
|
title: string;
|
|
kicker: string;
|
|
};
|
|
|
|
const workspaceRoot = process.cwd();
|
|
const electronMainPath = path.join(
|
|
workspaceRoot,
|
|
'dist/apps/electron-backend/main.js'
|
|
);
|
|
const rendererIndexPath = path.join(workspaceRoot, 'dist/apps/web/index.html');
|
|
const xtreamMockOrigin = 'http://localhost:3211';
|
|
const outputRoot = path.join(
|
|
workspaceRoot,
|
|
'apps/website/public/blog/v0-20/screenshots'
|
|
);
|
|
const heroOutputPath = path.join(
|
|
workspaceRoot,
|
|
'apps/website/public/blog/v0-20/v0-20-hero.png'
|
|
);
|
|
const viewport = { width: 1280, height: 720 };
|
|
const captureTargets: CaptureTarget[] = [
|
|
{
|
|
slug: 'dashboard',
|
|
title: 'Content-first dashboard',
|
|
kicker: 'New in v0.20',
|
|
},
|
|
{
|
|
slug: 'xtream-vod-details',
|
|
title: 'Richer movie details',
|
|
kicker: 'Xtream',
|
|
},
|
|
{
|
|
slug: 'xtream-series-season-open',
|
|
title: 'Series seasons and episodes',
|
|
kicker: 'Xtream',
|
|
},
|
|
{
|
|
slug: 'settings',
|
|
title: 'Reworked settings',
|
|
kicker: 'Customization',
|
|
},
|
|
{
|
|
slug: 'm3u-live-groups-two-column',
|
|
title: 'Two-column live TV',
|
|
kicker: 'M3U',
|
|
},
|
|
];
|
|
let capturedM3uPlaylistId: string | undefined;
|
|
let capturedXtreamPlaylistId: string | undefined;
|
|
|
|
async function main(): Promise<void> {
|
|
assertBuiltRuntimeExists();
|
|
|
|
mkdirSync(outputRoot, { recursive: true });
|
|
const managedMockServer = await ensureXtreamMockServer();
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-v020-shots-'));
|
|
const m3uFixturePath = writeM3uPlaylistFixture(dataDir);
|
|
let app: ElectronApplication | undefined;
|
|
|
|
try {
|
|
app = await launchApp(dataDir);
|
|
const page = await findMainWindow(app);
|
|
await sizeElectronWindow(app);
|
|
await waitForAppReady(page);
|
|
await seedDemoData(page, m3uFixturePath);
|
|
|
|
for (const theme of ['dark', 'light'] as ThemeName[]) {
|
|
await applyTheme(page, theme);
|
|
await captureThemeSet(page, theme);
|
|
}
|
|
|
|
await createHeroImage(page);
|
|
await verifyExpectedAssets();
|
|
} finally {
|
|
await app?.close().catch(() => undefined);
|
|
managedMockServer?.kill('SIGTERM');
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function assertBuiltRuntimeExists(): void {
|
|
if (!fileExists(electronMainPath) || !fileExists(rendererIndexPath)) {
|
|
throw new Error(
|
|
[
|
|
'Electron E2E runtime is missing.',
|
|
`Expected ${electronMainPath}`,
|
|
`Expected ${rendererIndexPath}`,
|
|
'Build it first, for example: pnpm nx run electron-backend:build-e2e',
|
|
].join('\n')
|
|
);
|
|
}
|
|
}
|
|
|
|
async function ensureXtreamMockServer(): Promise<ChildProcess | undefined> {
|
|
if (await isHealthy(`${xtreamMockOrigin}/health`)) {
|
|
return undefined;
|
|
}
|
|
|
|
const child = spawn(
|
|
path.join(workspaceRoot, 'node_modules/.bin/tsx'),
|
|
['apps/xtream-mock-server/src/main.ts'],
|
|
{
|
|
cwd: workspaceRoot,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: 'development',
|
|
PORT: '3211',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}
|
|
);
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
process.stdout.write(`[xtream-mock] ${chunk}`);
|
|
});
|
|
child.stderr?.on('data', (chunk) => {
|
|
process.stderr.write(`[xtream-mock] ${chunk}`);
|
|
});
|
|
|
|
await waitForHealthy(`${xtreamMockOrigin}/health`, 20_000);
|
|
return child;
|
|
}
|
|
|
|
function buildM3uPlaylist(): string {
|
|
const channels = [
|
|
['Newsroom', 'Aurora Local', 'aurora-local'],
|
|
['Newsroom', 'Civic Pulse', 'civic-pulse'],
|
|
['Sports', 'Fieldside One', 'fieldside-one'],
|
|
['Sports', 'Motion Arena', 'motion-arena'],
|
|
['Kids', 'Horizon Kids', 'horizon-kids'],
|
|
['Kids', 'Story Lantern', 'story-lantern'],
|
|
['Culture', 'Atlas Culture', 'atlas-culture'],
|
|
['Culture', 'Night Music', 'night-music'],
|
|
];
|
|
const stream = `${xtreamMockOrigin}/live/marketing/marketing/52000.m3u8`;
|
|
const lines = ['#EXTM3U'];
|
|
|
|
channels.forEach(([group, title, slug], index) => {
|
|
lines.push(
|
|
`#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${xtreamMockOrigin}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`
|
|
);
|
|
lines.push(stream);
|
|
});
|
|
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function writeM3uPlaylistFixture(dataDir: string): string {
|
|
const filePath = path.join(dataDir, 'v020-demo.m3u');
|
|
writeFileSync(filePath, buildM3uPlaylist(), 'utf8');
|
|
return filePath;
|
|
}
|
|
|
|
function marketingAssetUrl(
|
|
kind: 'backdrop' | 'episode' | 'logo' | 'poster' | 'season',
|
|
title: string,
|
|
size: string
|
|
): string {
|
|
return `${xtreamMockOrigin}/assets/marketing/${kind}/${slugifyForFile(
|
|
title
|
|
)}.svg?size=${encodeURIComponent(size)}`;
|
|
}
|
|
|
|
async function launchApp(dataDir: string): Promise<ElectronApplication> {
|
|
return electron.launch({
|
|
args: [electronMainPath],
|
|
env: {
|
|
...process.env,
|
|
ELECTRON_IS_DEV: '0',
|
|
IPTVNATOR_E2E_DATA_DIR: dataDir,
|
|
NODE_ENV: 'test',
|
|
},
|
|
});
|
|
}
|
|
|
|
async function findMainWindow(app: ElectronApplication): Promise<Page> {
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
|
|
for (const candidate of app.windows()) {
|
|
const title = await candidate.title();
|
|
if (!title.includes('DevTools')) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return app.firstWindow();
|
|
}
|
|
|
|
async function sizeElectronWindow(app: ElectronApplication): Promise<void> {
|
|
await app.evaluate(({ BrowserWindow }, size) => {
|
|
const win = BrowserWindow.getAllWindows().find(
|
|
(candidate) => !candidate.webContents.getTitle().includes('DevTools')
|
|
);
|
|
win?.setSize(size.width, size.height);
|
|
win?.center();
|
|
}, viewport);
|
|
}
|
|
|
|
async function waitForAppReady(page: Page): Promise<void> {
|
|
await page.waitForLoadState('domcontentloaded');
|
|
await page.waitForSelector('app-root', { timeout: 45_000 });
|
|
await page.waitForFunction(
|
|
() => (document.querySelector('app-root')?.innerHTML.trim().length ?? 0) > 0,
|
|
{ timeout: 45_000 }
|
|
);
|
|
}
|
|
|
|
async function seedDemoData(page: Page, m3uPath: string): Promise<void> {
|
|
await addXtreamPortal(page);
|
|
await addM3uPlaylist(page, m3uPath);
|
|
await seedDashboardActivity(page);
|
|
}
|
|
|
|
async function addXtreamPortal(page: Page): Promise<void> {
|
|
await openAddPlaylistDialog(page);
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
|
|
await clickDialogOption(dialog, /^xtream$/i);
|
|
await dialog.locator('#title').fill('v0.20 Fictional Xtream');
|
|
await dialog.locator('#serverUrl').fill(xtreamMockOrigin);
|
|
await dialog.locator('#username').fill('marketing');
|
|
await dialog.locator('#password').fill('marketing');
|
|
await dialog.getByRole('button', { name: /^(add|add playlist)$/i }).last().click();
|
|
await dialog.waitFor({ state: 'detached', timeout: 30_000 });
|
|
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, {
|
|
timeout: 45_000,
|
|
});
|
|
capturedXtreamPlaylistId = playlistIdFromUrl(page.url(), 'xtreams');
|
|
await page.locator('.category-content-layout, app-content-card').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 45_000,
|
|
});
|
|
}
|
|
|
|
async function addM3uPlaylist(page: Page, m3uPath: string): Promise<void> {
|
|
await openAddPlaylistDialog(page);
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
|
|
await clickDialogOption(dialog, /^m3u$/i);
|
|
await clickDialogOption(dialog, /add\s+via\s+file\s+upload/i);
|
|
|
|
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
|
|
await fileInput.evaluate((element, selectedFilePath) => {
|
|
(element as HTMLInputElement).dataset.filePathOverride =
|
|
selectedFilePath;
|
|
}, m3uPath);
|
|
await fileInput.setInputFiles(m3uPath);
|
|
await dialog
|
|
.getByRole('button', { name: /add playlist/i })
|
|
.last()
|
|
.click({ timeout: 15_000 });
|
|
await dialog.waitFor({ state: 'detached', timeout: 30_000 });
|
|
await page.waitForURL(/\/workspace\/playlists\/[^/]+\/all/, {
|
|
timeout: 45_000,
|
|
});
|
|
capturedM3uPlaylistId = playlistIdFromUrl(page.url(), 'playlists');
|
|
try {
|
|
await page.locator('[data-test-id="channel-item"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 60_000,
|
|
});
|
|
} catch (error) {
|
|
await writeDebugScreenshot(page, 'm3u-import');
|
|
const diagnostics = await page.evaluate(() => ({
|
|
bodyText: document.body.innerText.slice(0, 2000),
|
|
title: document.title,
|
|
url: location.href,
|
|
}));
|
|
|
|
console.error(
|
|
`[capture] M3U import did not show channels: ${JSON.stringify(
|
|
diagnostics,
|
|
null,
|
|
2
|
|
)}`
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function seedDashboardActivity(page: Page): Promise<void> {
|
|
const playlistId = requireCapturedPlaylistId('xtreams');
|
|
const items = [
|
|
{
|
|
xtreamId: 62000,
|
|
type: 'movie' as const,
|
|
backdropUrl: marketingAssetUrl('backdrop', 'Crimson Skylark', '1280x720'),
|
|
favorite: true,
|
|
recent: true,
|
|
},
|
|
{
|
|
xtreamId: 62001,
|
|
type: 'movie' as const,
|
|
backdropUrl: marketingAssetUrl('backdrop', 'The Voltage Guard', '1280x720'),
|
|
favorite: true,
|
|
recent: true,
|
|
},
|
|
{
|
|
xtreamId: 62002,
|
|
type: 'movie' as const,
|
|
backdropUrl: marketingAssetUrl('backdrop', 'Midnight Mantle', '1280x720'),
|
|
favorite: true,
|
|
recent: false,
|
|
},
|
|
{
|
|
xtreamId: 72000,
|
|
type: 'series' as const,
|
|
backdropUrl: marketingAssetUrl('backdrop', 'Skyline Sentinels', '1280x720'),
|
|
favorite: true,
|
|
recent: true,
|
|
},
|
|
{
|
|
xtreamId: 72001,
|
|
type: 'series' as const,
|
|
backdropUrl: marketingAssetUrl('backdrop', 'The Aegis Club', '1280x720'),
|
|
favorite: true,
|
|
recent: false,
|
|
},
|
|
];
|
|
|
|
await page.evaluate(
|
|
async ({ items, playlistId }) => {
|
|
const electronApi = (window as typeof window & {
|
|
electron?: {
|
|
dbAddFavorite?: (
|
|
contentId: number,
|
|
playlistId: string,
|
|
backdropUrl?: string
|
|
) => Promise<unknown>;
|
|
dbAddRecentItem?: (
|
|
contentId: number,
|
|
playlistId: string,
|
|
backdropUrl?: string
|
|
) => Promise<unknown>;
|
|
dbGetContentByXtreamId?: (
|
|
xtreamId: number,
|
|
playlistId: string,
|
|
contentType?: 'live' | 'movie' | 'series'
|
|
) => Promise<{ id: number } | null>;
|
|
};
|
|
}).electron;
|
|
|
|
if (!electronApi?.dbGetContentByXtreamId) {
|
|
throw new Error('Electron database bridge is unavailable.');
|
|
}
|
|
|
|
for (const item of items) {
|
|
const content = await electronApi.dbGetContentByXtreamId(
|
|
item.xtreamId,
|
|
playlistId,
|
|
item.type
|
|
);
|
|
|
|
if (!content?.id) {
|
|
throw new Error(
|
|
`Could not find imported Xtream content ${item.xtreamId}.`
|
|
);
|
|
}
|
|
|
|
if (item.favorite) {
|
|
await electronApi.dbAddFavorite?.(
|
|
content.id,
|
|
playlistId,
|
|
item.backdropUrl
|
|
);
|
|
}
|
|
|
|
if (item.recent) {
|
|
await electronApi.dbAddRecentItem?.(
|
|
content.id,
|
|
playlistId,
|
|
item.backdropUrl
|
|
);
|
|
}
|
|
}
|
|
},
|
|
{ items, playlistId }
|
|
);
|
|
}
|
|
|
|
async function openAddPlaylistDialog(page: Page): Promise<void> {
|
|
await page.getByRole('button', { name: /add playlist/i }).first().click();
|
|
await page.locator('mat-dialog-container').last().waitFor({
|
|
state: 'visible',
|
|
timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
async function clickDialogOption(dialog: ReturnType<Page['locator']>, label: RegExp): Promise<void> {
|
|
const tab = dialog.getByRole('tab', { name: label }).first();
|
|
if ((await tab.count()) > 0) {
|
|
await tab.click();
|
|
return;
|
|
}
|
|
|
|
await dialog.getByRole('button', { name: label }).first().click();
|
|
}
|
|
|
|
async function applyTheme(page: Page, theme: ThemeName): Promise<void> {
|
|
await openSettings(page);
|
|
const testId = theme === 'dark' ? 'DARK_THEME' : 'LIGHT_THEME';
|
|
const themeButton = page.locator(`[data-test-id="${testId}"]`).first();
|
|
|
|
await themeButton.scrollIntoViewIfNeeded();
|
|
await themeButton.click();
|
|
|
|
const saveButton = page.locator('[data-test-id="save-settings"]').first();
|
|
if (await saveButton.isEnabled()) {
|
|
await saveButton.click();
|
|
await waitForTransientUiToSettle(page);
|
|
}
|
|
|
|
await page.waitForFunction(
|
|
(expectedTheme) =>
|
|
document.body.classList.contains('dark-theme') ===
|
|
(expectedTheme === 'dark'),
|
|
theme,
|
|
{ timeout: 10_000 }
|
|
);
|
|
}
|
|
|
|
async function openSettings(page: Page): Promise<void> {
|
|
await page.locator('a[href$="/workspace/settings"]').first().click();
|
|
await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 });
|
|
await page.locator('[data-test-id="settings-container"]').waitFor({
|
|
state: 'visible',
|
|
timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
async function captureThemeSet(page: Page, theme: ThemeName): Promise<void> {
|
|
await captureCurrentView(page, theme, 'settings');
|
|
await openDashboard(page);
|
|
await captureCurrentView(page, theme, 'dashboard');
|
|
await openXtreamVodDetails(page);
|
|
await captureCurrentView(page, theme, 'xtream-vod-details');
|
|
await openXtreamSeriesSeason(page);
|
|
await captureCurrentView(page, theme, 'xtream-series-season-open');
|
|
await openM3uGroupsView(page);
|
|
await captureCurrentView(page, theme, 'm3u-live-groups-two-column');
|
|
}
|
|
|
|
async function openDashboard(page: Page): Promise<void> {
|
|
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
|
|
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
|
|
await page.locator('[data-test-id="dashboard-page"]').waitFor({
|
|
state: 'visible',
|
|
timeout: 20_000,
|
|
});
|
|
await waitForTransientUiToSettle(page);
|
|
await page.locator('[data-test-id="dashboard-hero"]').waitFor({
|
|
state: 'visible',
|
|
timeout: 30_000,
|
|
});
|
|
|
|
for (const testId of [
|
|
'dashboard-recently-watched-rail',
|
|
'dashboard-global-favorites-rail',
|
|
'dashboard-recent-sources-rail',
|
|
'dashboard-xtream-recently-added-rail',
|
|
]) {
|
|
await page.locator(`[data-test-id="${testId}"]`).waitFor({
|
|
state: 'visible',
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function waitForTransientUiToSettle(page: Page): Promise<void> {
|
|
await page
|
|
.locator('.mat-mdc-snack-bar-container')
|
|
.first()
|
|
.waitFor({ state: 'detached', timeout: 10_000 })
|
|
.catch(() => undefined);
|
|
await page.evaluate(() => {
|
|
document
|
|
.querySelectorAll('.mat-mdc-snack-bar-container, simple-snack-bar')
|
|
.forEach((element) => {
|
|
const overlayPane = element.closest('.cdk-overlay-pane');
|
|
(overlayPane ?? element).remove();
|
|
});
|
|
});
|
|
await page.waitForTimeout(250);
|
|
}
|
|
|
|
async function openXtreamVodDetails(page: Page): Promise<void> {
|
|
const xtreamId = requireCapturedPlaylistId('xtreams');
|
|
|
|
await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/vod`);
|
|
await clickCategoryByName(page, 'Hero Premieres');
|
|
await clickFirstGridCard(page);
|
|
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, {
|
|
timeout: 30_000,
|
|
});
|
|
await page.locator('app-content-hero').waitFor({
|
|
state: 'visible',
|
|
timeout: 30_000,
|
|
});
|
|
await page.waitForTimeout(700);
|
|
}
|
|
|
|
async function openXtreamSeriesSeason(page: Page): Promise<void> {
|
|
const xtreamId = requireCapturedPlaylistId('xtreams');
|
|
|
|
await clickHrefSuffix(page, `/workspace/xtreams/${xtreamId}/series`);
|
|
await clickCategoryByName(page, 'Hero Teams');
|
|
await clickFirstGridCard(page);
|
|
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/, {
|
|
timeout: 30_000,
|
|
});
|
|
await page.locator('app-season-container').waitFor({
|
|
state: 'visible',
|
|
timeout: 30_000,
|
|
});
|
|
await page.locator('.season-card').first().click();
|
|
await page.locator('.episode-card, .episode-list-item').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 20_000,
|
|
});
|
|
await page.waitForTimeout(700);
|
|
}
|
|
|
|
async function openM3uGroupsView(page: Page): Promise<void> {
|
|
const playlistId = requireCapturedPlaylistId('playlists');
|
|
|
|
await switchPlaylistFromHeader(page, 'v020-demo');
|
|
await page.waitForURL(new RegExp(`/workspace/playlists/${playlistId}/`), {
|
|
timeout: 20_000,
|
|
});
|
|
await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`);
|
|
await page.waitForURL(/\/workspace\/playlists\/[^/]+\/groups/, {
|
|
timeout: 20_000,
|
|
});
|
|
await page.locator('.group-nav-item').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 20_000,
|
|
});
|
|
await page.locator('.group-nav-item').first().click();
|
|
await page.locator('[data-test-id="channel-item"]').first().click();
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
|
|
async function switchPlaylistFromHeader(
|
|
page: Page,
|
|
title: string
|
|
): Promise<void> {
|
|
await page.locator('app-playlist-switcher .playlist-switcher-trigger').click();
|
|
|
|
const item = page
|
|
.locator('.cdk-overlay-pane .playlist-item')
|
|
.filter({ hasText: title })
|
|
.first();
|
|
|
|
await item.waitFor({ state: 'visible', timeout: 20_000 });
|
|
await item.click();
|
|
await page.locator('.cdk-overlay-pane .playlist-item').first().waitFor({
|
|
state: 'detached',
|
|
timeout: 10_000,
|
|
});
|
|
}
|
|
|
|
async function clickCategoryByName(
|
|
page: Page,
|
|
categoryName: string
|
|
): Promise<void> {
|
|
const category = page
|
|
.locator('app-workspace-context-panel .category-item')
|
|
.filter({ hasText: categoryName })
|
|
.first();
|
|
|
|
try {
|
|
await category.waitFor({ state: 'visible', timeout: 30_000 });
|
|
} catch (error) {
|
|
await writeDebugScreenshot(page, `category-${slugifyForFile(categoryName)}`);
|
|
const diagnostics = await page.evaluate(() => ({
|
|
bodyText: document.body.innerText.slice(0, 2500),
|
|
categories: Array.from(
|
|
document.querySelectorAll(
|
|
'app-workspace-context-panel .category-item'
|
|
)
|
|
).map((element) => ({
|
|
id: element.getAttribute('data-category-id'),
|
|
text: (element.textContent ?? '').trim(),
|
|
})),
|
|
title: document.title,
|
|
url: location.href,
|
|
}));
|
|
|
|
console.error(
|
|
`[capture] Category ${categoryName} was not visible: ${JSON.stringify(
|
|
diagnostics,
|
|
null,
|
|
2
|
|
)}`
|
|
);
|
|
throw error;
|
|
}
|
|
await category.click();
|
|
await page.waitForTimeout(600);
|
|
}
|
|
|
|
async function clickFirstGridCard(page: Page): Promise<void> {
|
|
const card = page.locator('.category-content-layout mat-card').first();
|
|
|
|
await card.waitFor({ state: 'visible', timeout: 30_000 });
|
|
await card.click();
|
|
}
|
|
|
|
function requireCapturedPlaylistId(
|
|
provider: 'playlists' | 'xtreams'
|
|
): string {
|
|
const id =
|
|
provider === 'playlists'
|
|
? capturedM3uPlaylistId
|
|
: capturedXtreamPlaylistId;
|
|
|
|
if (!id) {
|
|
throw new Error(`Could not resolve captured ${provider} playlist id.`);
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
function playlistIdFromUrl(url: string, provider: 'playlists' | 'xtreams'): string {
|
|
const match = url.match(new RegExp(`/workspace/${provider}/([^/]+)`));
|
|
|
|
if (!match?.[1]) {
|
|
throw new Error(`Could not resolve ${provider} playlist id from URL ${url}`);
|
|
}
|
|
|
|
return match[1];
|
|
}
|
|
|
|
async function clickHrefSuffix(page: Page, suffix: string): Promise<void> {
|
|
const link = page.locator(`a[href$="${suffix}"]`).first();
|
|
|
|
await link.waitFor({ state: 'attached', timeout: 20_000 });
|
|
await link.click({ force: true });
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
async function captureCurrentView(
|
|
page: Page,
|
|
theme: ThemeName,
|
|
slug: ScreenshotSlug
|
|
): Promise<void> {
|
|
const originalPath = screenshotPath('original', theme, slug);
|
|
|
|
mkdirSync(path.dirname(originalPath), { recursive: true });
|
|
await prepareForScreenshot(page);
|
|
await page.screenshot({
|
|
path: originalPath,
|
|
type: 'png',
|
|
fullPage: slug === 'dashboard',
|
|
scale: 'css',
|
|
});
|
|
|
|
await createDesignedCopy(
|
|
page,
|
|
originalPath,
|
|
screenshotPath('designed', theme, slug),
|
|
{
|
|
...captureTargets.find((target) => target.slug === slug)!,
|
|
theme,
|
|
}
|
|
);
|
|
}
|
|
|
|
async function prepareForScreenshot(page: Page): Promise<void> {
|
|
await page.mouse.move(viewport.width - 32, viewport.height - 32);
|
|
await page.keyboard.press('Escape').catch(() => undefined);
|
|
await waitForTransientUiToSettle(page);
|
|
await page
|
|
.locator('.mat-mdc-tooltip, .mdc-tooltip, .mat-tooltip-panel')
|
|
.first()
|
|
.waitFor({ state: 'detached', timeout: 1_500 })
|
|
.catch(() => undefined);
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
async function writeDebugScreenshot(page: Page, name: string): Promise<void> {
|
|
const debugPath = path.join(outputRoot, 'debug', `${name}.png`);
|
|
mkdirSync(path.dirname(debugPath), { recursive: true });
|
|
await page.screenshot({ path: debugPath, fullPage: true, type: 'png' });
|
|
}
|
|
|
|
function screenshotPath(
|
|
variant: 'designed' | 'original',
|
|
theme: ThemeName,
|
|
slug: ScreenshotSlug
|
|
): string {
|
|
return path.join(outputRoot, variant, theme, `${slug}.png`);
|
|
}
|
|
|
|
async function createDesignedCopy(
|
|
page: Page,
|
|
inputPath: string,
|
|
outputPath: string,
|
|
options: CaptureTarget & { theme: ThemeName }
|
|
): Promise<void> {
|
|
mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
|
|
const canvasWidth = 1920;
|
|
const canvasHeight = 1080;
|
|
const screenshotWidth = 1460;
|
|
const screenshotHeight = 822;
|
|
const left = 360;
|
|
const top = 170;
|
|
const palette =
|
|
options.theme === 'dark'
|
|
? {
|
|
bg1: '#090b12',
|
|
bg2: '#172235',
|
|
accent: '#49c6e5',
|
|
text: '#f8fafc',
|
|
muted: '#a7b4c8',
|
|
}
|
|
: {
|
|
bg1: '#eef2f7',
|
|
bg2: '#dce7ef',
|
|
accent: '#2563eb',
|
|
text: '#111827',
|
|
muted: '#475569',
|
|
};
|
|
const dataUrl = await page.evaluate(
|
|
async ({
|
|
inputDataUrl,
|
|
options,
|
|
palette,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
screenshotWidth,
|
|
screenshotHeight,
|
|
left,
|
|
top,
|
|
}) => {
|
|
const image = await loadCanvasImage(inputDataUrl);
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
|
|
if (!context) {
|
|
throw new Error('Canvas 2D context is unavailable.');
|
|
}
|
|
|
|
canvas.width = canvasWidth;
|
|
canvas.height = canvasHeight;
|
|
|
|
const background = context.createLinearGradient(
|
|
0,
|
|
0,
|
|
canvasWidth,
|
|
canvasHeight
|
|
);
|
|
background.addColorStop(0, palette.bg1);
|
|
background.addColorStop(1, palette.bg2);
|
|
context.fillStyle = background;
|
|
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
context.globalAlpha = 0.16;
|
|
context.fillStyle = palette.accent;
|
|
context.beginPath();
|
|
context.arc(1710, 140, 260, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.globalAlpha = 0.12;
|
|
context.beginPath();
|
|
context.arc(160, 930, 340, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.globalAlpha = 0.28;
|
|
context.strokeStyle = palette.accent;
|
|
context.lineWidth = 4;
|
|
context.beginPath();
|
|
context.moveTo(0, 810);
|
|
context.bezierCurveTo(420, 650, 720, 1040, 1220, 840);
|
|
context.bezierCurveTo(1460, 744, 1720, 610, 1920, 720);
|
|
context.stroke();
|
|
context.globalAlpha = 1;
|
|
|
|
context.fillStyle = palette.accent;
|
|
context.font = '800 28px Arial, Helvetica, sans-serif';
|
|
context.fillText(options.kicker.toUpperCase(), 110, 150);
|
|
context.fillStyle = palette.text;
|
|
context.font = '900 72px Arial, Helvetica, sans-serif';
|
|
context.fillText(options.title, 110, 220);
|
|
context.fillStyle = palette.muted;
|
|
context.font = '600 28px Arial, Helvetica, sans-serif';
|
|
context.fillText(
|
|
'IPTVnator v0.20 release preview using fictional demo content',
|
|
110,
|
|
278
|
|
);
|
|
|
|
context.save();
|
|
context.shadowColor =
|
|
options.theme === 'dark'
|
|
? 'rgba(0, 0, 0, 0.42)'
|
|
: 'rgba(0, 0, 0, 0.18)';
|
|
context.shadowBlur = 76;
|
|
context.shadowOffsetY = 34;
|
|
context.globalAlpha = options.theme === 'dark' ? 0.78 : 0.9;
|
|
context.fillStyle = options.theme === 'dark' ? '#020617' : '#ffffff';
|
|
drawRoundRect(
|
|
context,
|
|
left - 18,
|
|
top - 18,
|
|
screenshotWidth + 36,
|
|
screenshotHeight + 36,
|
|
34
|
|
);
|
|
context.fill();
|
|
context.restore();
|
|
|
|
context.save();
|
|
drawRoundRect(context, left, top, screenshotWidth, screenshotHeight, 18);
|
|
context.clip();
|
|
context.drawImage(
|
|
image,
|
|
0,
|
|
0,
|
|
image.naturalWidth,
|
|
image.naturalHeight,
|
|
left,
|
|
top,
|
|
screenshotWidth,
|
|
screenshotHeight
|
|
);
|
|
context.restore();
|
|
|
|
context.strokeStyle = hexToRgba(palette.accent, 0.35);
|
|
context.lineWidth = 2;
|
|
drawRoundRect(
|
|
context,
|
|
left - 18,
|
|
top - 18,
|
|
screenshotWidth + 36,
|
|
screenshotHeight + 36,
|
|
34
|
|
);
|
|
context.stroke();
|
|
|
|
return canvas.toDataURL('image/png');
|
|
|
|
function loadCanvasImage(src: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const candidate = new Image();
|
|
candidate.onload = () => resolve(candidate);
|
|
candidate.onerror = () =>
|
|
reject(new Error('Could not load screenshot image.'));
|
|
candidate.src = src;
|
|
});
|
|
}
|
|
|
|
function drawRoundRect(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
radius: number
|
|
): void {
|
|
context.beginPath();
|
|
context.moveTo(x + radius, y);
|
|
context.lineTo(x + width - radius, y);
|
|
context.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
context.lineTo(x + width, y + height - radius);
|
|
context.quadraticCurveTo(
|
|
x + width,
|
|
y + height,
|
|
x + width - radius,
|
|
y + height
|
|
);
|
|
context.lineTo(x + radius, y + height);
|
|
context.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
context.lineTo(x, y + radius);
|
|
context.quadraticCurveTo(x, y, x + radius, y);
|
|
context.closePath();
|
|
}
|
|
|
|
function hexToRgba(hex: string, alpha: number): string {
|
|
const normalized = hex.replace('#', '');
|
|
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
|
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
|
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
}
|
|
},
|
|
{
|
|
inputDataUrl: fileToDataUrl(inputPath),
|
|
options,
|
|
palette,
|
|
canvasWidth,
|
|
canvasHeight,
|
|
screenshotWidth,
|
|
screenshotHeight,
|
|
left,
|
|
top,
|
|
}
|
|
);
|
|
|
|
writeDataUrlPng(outputPath, dataUrl);
|
|
}
|
|
|
|
async function createHeroImage(page: Page): Promise<void> {
|
|
const dashboard = screenshotPath('original', 'dark', 'dashboard');
|
|
const vod = screenshotPath('original', 'dark', 'xtream-vod-details');
|
|
const series = screenshotPath('original', 'light', 'xtream-series-season-open');
|
|
mkdirSync(path.dirname(heroOutputPath), { recursive: true });
|
|
|
|
const dataUrl = await page.evaluate(
|
|
async ({ dashboard, vod, series }) => {
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
|
|
if (!context) {
|
|
throw new Error('Canvas 2D context is unavailable.');
|
|
}
|
|
|
|
canvas.width = 1600;
|
|
canvas.height = 900;
|
|
|
|
const background = context.createLinearGradient(0, 0, 1600, 900);
|
|
background.addColorStop(0, '#06111f');
|
|
background.addColorStop(0.52, '#111827');
|
|
background.addColorStop(1, '#233b54');
|
|
context.fillStyle = background;
|
|
context.fillRect(0, 0, 1600, 900);
|
|
|
|
context.globalAlpha = 0.18;
|
|
context.fillStyle = '#49c6e5';
|
|
context.beginPath();
|
|
context.arc(1380, 120, 240, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.globalAlpha = 0.15;
|
|
context.fillStyle = '#f2a65a';
|
|
context.beginPath();
|
|
context.arc(170, 790, 290, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.globalAlpha = 1;
|
|
|
|
context.fillStyle = '#49c6e5';
|
|
context.font = '800 30px Arial, Helvetica, sans-serif';
|
|
context.fillText('IPTVNATOR V0.20', 86, 138);
|
|
context.fillStyle = '#f8fafc';
|
|
context.font = '900 78px Arial, Helvetica, sans-serif';
|
|
context.fillText('Unified UI.', 86, 224);
|
|
context.fillText('Content-first.', 86, 306);
|
|
context.fillStyle = '#b9c4d6';
|
|
context.font = '600 28px Arial, Helvetica, sans-serif';
|
|
context.fillText(
|
|
'Dashboard, search, favorites, history, and settings reworked across M3U, Xtream, and Stalker.',
|
|
88,
|
|
366
|
|
);
|
|
|
|
await drawFramedImage(context, dashboard, 650, 130, 780, 438, 26, '#020617');
|
|
await drawFramedImage(context, vod, 500, 505, 520, 292, 22, '#020617');
|
|
await drawFramedImage(context, series, 990, 565, 430, 242, 20, '#f8fafc');
|
|
|
|
return canvas.toDataURL('image/png');
|
|
|
|
async function drawFramedImage(
|
|
context: CanvasRenderingContext2D,
|
|
src: string,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
radius: number,
|
|
frameColor: string
|
|
): Promise<void> {
|
|
const image = await loadCanvasImage(src);
|
|
|
|
context.save();
|
|
context.shadowColor = 'rgba(0, 0, 0, 0.45)';
|
|
context.shadowBlur = 60;
|
|
context.shadowOffsetY = 28;
|
|
context.globalAlpha = frameColor === '#f8fafc' ? 0.94 : 0.82;
|
|
context.fillStyle = frameColor;
|
|
drawRoundRect(context, x, y, width, height, radius);
|
|
context.fill();
|
|
context.restore();
|
|
|
|
context.save();
|
|
drawRoundRect(context, x, y, width, height, radius);
|
|
context.clip();
|
|
context.drawImage(
|
|
image,
|
|
0,
|
|
0,
|
|
image.naturalWidth,
|
|
image.naturalHeight,
|
|
x,
|
|
y,
|
|
width,
|
|
height
|
|
);
|
|
context.restore();
|
|
}
|
|
|
|
function loadCanvasImage(src: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const candidate = new Image();
|
|
candidate.onload = () => resolve(candidate);
|
|
candidate.onerror = () =>
|
|
reject(new Error('Could not load screenshot image.'));
|
|
candidate.src = src;
|
|
});
|
|
}
|
|
|
|
function drawRoundRect(
|
|
context: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
radius: number
|
|
): void {
|
|
context.beginPath();
|
|
context.moveTo(x + radius, y);
|
|
context.lineTo(x + width - radius, y);
|
|
context.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
context.lineTo(x + width, y + height - radius);
|
|
context.quadraticCurveTo(
|
|
x + width,
|
|
y + height,
|
|
x + width - radius,
|
|
y + height
|
|
);
|
|
context.lineTo(x + radius, y + height);
|
|
context.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
context.lineTo(x, y + radius);
|
|
context.quadraticCurveTo(x, y, x + radius, y);
|
|
context.closePath();
|
|
}
|
|
},
|
|
{
|
|
dashboard: fileToDataUrl(dashboard),
|
|
vod: fileToDataUrl(vod),
|
|
series: fileToDataUrl(series),
|
|
}
|
|
);
|
|
|
|
writeDataUrlPng(heroOutputPath, dataUrl);
|
|
}
|
|
|
|
async function verifyExpectedAssets(): Promise<void> {
|
|
const expected = [
|
|
heroOutputPath,
|
|
...(['dark', 'light'] as ThemeName[]).flatMap((theme) =>
|
|
captureTargets.flatMap((target) => [
|
|
screenshotPath('original', theme, target.slug),
|
|
screenshotPath('designed', theme, target.slug),
|
|
])
|
|
),
|
|
];
|
|
|
|
for (const filePath of expected) {
|
|
if (!fileExists(filePath)) {
|
|
throw new Error(`Expected screenshot asset was not created: ${filePath}`);
|
|
}
|
|
|
|
const dimensions = readPngDimensions(filePath);
|
|
if (!dimensions.width || !dimensions.height) {
|
|
throw new Error(`Screenshot asset has invalid dimensions: ${filePath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function isHealthy(url: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(url);
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForHealthy(url: string, timeoutMs: number): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
if (await isHealthy(url)) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
function fileExists(filePath: string): boolean {
|
|
try {
|
|
accessSync(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function fileToDataUrl(filePath: string): string {
|
|
return `data:image/png;base64,${readFileSync(filePath).toString('base64')}`;
|
|
}
|
|
|
|
function writeDataUrlPng(filePath: string, dataUrl: string): void {
|
|
const marker = 'data:image/png;base64,';
|
|
|
|
if (!dataUrl.startsWith(marker)) {
|
|
throw new Error(`Expected PNG data URL for ${filePath}`);
|
|
}
|
|
|
|
writeFileSync(filePath, Buffer.from(dataUrl.slice(marker.length), 'base64'));
|
|
}
|
|
|
|
function readPngDimensions(filePath: string): { height: number; width: number } {
|
|
const buffer = readFileSync(filePath);
|
|
const pngSignature = '89504e470d0a1a0a';
|
|
|
|
if (buffer.subarray(0, 8).toString('hex') !== pngSignature) {
|
|
throw new Error(`Expected PNG signature in ${filePath}`);
|
|
}
|
|
|
|
return {
|
|
width: buffer.readUInt32BE(16),
|
|
height: buffer.readUInt32BE(20),
|
|
};
|
|
}
|
|
|
|
function slugifyForFile(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|