From 81918de8ec3276784be42f2480a58a90c7fe0ccf Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 17 Jul 2026 20:49:00 +0200 Subject: [PATCH] test(embedded-mpv): smoke packaged Linux frame-copy --- .../src/electron-test-fixtures.ts | 138 ++++++- ...bedded-mpv-frame-copy-packaged-fixtures.ts | 367 ++++++++++++++++++ .../embedded-mpv-frame-copy-packaged.e2e.ts | 264 +++++++++++++ 3 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index c0439b37f..2d398b381 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -9,14 +9,18 @@ import { } from '@playwright/test'; import { createServer, Server } from 'http'; import { + accessSync, + constants as fsConstants, existsSync, mkdtempSync, + readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -70,7 +74,7 @@ type ElectronFixtures = { dataDir: string; }; -type LaunchElectronAppOptions = { +export type LaunchElectronAppOptions = { env?: Record; }; @@ -171,6 +175,136 @@ export async function launchElectronApp( }; } +/** + * Resolve the x64 unpacked Linux executable produced by electron-builder. + * An explicit path wins so CI can point at an AppImage/Flatpak extraction + * without relying on electron-builder's local output directory names. + */ +export function resolvePackagedLinuxExecutable( + explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE'] +): string | undefined { + if (explicitPath?.trim()) { + return resolve(explicitPath.trim()); + } + + const executablesRoot = join(workspaceRoot, 'dist', 'executables'); + if (!existsSync(executablesRoot)) { + return undefined; + } + + const unpackedDirectories = readdirSync(executablesRoot, { + withFileTypes: true, + }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith('linux') && + entry.name.endsWith('-unpacked') && + !entry.name.includes('arm') + ) + .sort((left, right) => { + const leftPriority = left.name === 'linux-unpacked' ? 0 : 1; + const rightPriority = right.name === 'linux-unpacked' ? 0 : 1; + return ( + leftPriority - rightPriority || + left.name.localeCompare(right.name) + ); + }); + + for (const directory of unpackedDirectories) { + for (const executableName of ['IPTVnator', 'iptvnator']) { + const candidate = join( + executablesRoot, + directory.name, + executableName + ); + try { + accessSync(candidate, fsConstants.X_OK); + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Keep looking for the next unpacked x64 layout. + } + } + } + + return undefined; +} + +export function getPackagedLinuxNativeDir(executablePath: string): string { + return join( + dirname(resolve(executablePath)), + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); +} + +/** + * Launch a real packaged Linux executable. Unlike the regular source E2E + * launcher, this deliberately keeps Chromium's GPU path enabled: the + * frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1 and needs WebGL2 to prove + * that the shared-memory frame reaches the renderer canvas. + */ +export async function launchPackagedElectronApp( + executablePath: string, + dataDir: string, + options: LaunchElectronAppOptions = {} +): Promise { + if (process.platform !== 'linux') { + throw new Error( + 'The packaged embedded-MPV launcher is available on Linux only.' + ); + } + + const resolvedExecutablePath = resolve(executablePath); + try { + accessSync(resolvedExecutablePath, fsConstants.X_OK); + if (!statSync(resolvedExecutablePath).isFile()) { + throw new Error('not a regular file'); + } + } catch (error) { + throw new Error( + `Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const args: string[] = []; + if ( + process.env['CI'] || + (typeof process.getuid === 'function' && process.getuid() === 0) + ) { + args.push('--no-sandbox'); + } + + const electronApp = await electron.launch({ + executablePath: resolvedExecutablePath, + args, + env: { + ...process.env, + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: + process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1', + ...options.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + }); + attachElectronProcessDiagnostics(electronApp); + + const mainWindow = await findMainWindow(electronApp); + await waitForAppReady(mainWindow); + + return { + electronApp, + mainWindow, + }; +} + function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void { if (!process.env['CI']) { return; diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts new file mode 100644 index 000000000..340067f06 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts @@ -0,0 +1,367 @@ +import type { + EmbeddedMpvSession, + EmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; +import { spawnSync } from 'child_process'; +import { createServer, type Server } from 'http'; +import { existsSync, readFileSync, renameSync, statSync } from 'fs'; +import { join } from 'path'; +import sharp = require('sharp'); +import { + closeElectronApp, + expect, + type LaunchedElectronApp, +} from './electron-test-fixtures'; + +declare global { + interface Window { + __packagedEmbeddedMpvSessions?: EmbeddedMpvSession[]; + __packagedEmbeddedMpvUnsubscribe?: () => void; + } +} + +export type LocalMediaServer = { + close: () => Promise; + url: string; +}; + +export type RuntimeManifestGuard = { + hide: () => void; + restore: () => void; +}; + +export type PackagedRuntimeIdentity = { + arch: string; + platform: string; + profile: string; + runtimeMode: string; +}; + +function createTwoSecondY4mFixture(): Buffer { + const width = 64; + const height = 36; + const framesPerSecond = 10; + const frameCount = framesPerSecond * 2; + const yPlaneBytes = width * height; + const chromaPlaneBytes = (width / 2) * (height / 2); + const chunks: Buffer[] = [ + Buffer.from( + `YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`, + 'ascii' + ), + ]; + + for (let index = 0; index < frameCount; index += 1) { + const evenFrame = index % 2 === 0; + chunks.push( + Buffer.from('FRAME\n', 'ascii'), + Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150), + Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44), + Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21) + ); + } + + return Buffer.concat(chunks); +} + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const onError = (error: Error) => { + server.off('listening', onListening); + rejectPromise(error); + }; + const onListening = () => { + server.off('error', onError); + resolvePromise(); + }; + + server.once('error', onError); + server.once('listening', onListening); + server.listen(0, '127.0.0.1'); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} + +export async function createLocalMediaServer(): Promise { + const body = createTwoSecondY4mFixture(); + const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m'; + const server = createServer((request, response) => { + const pathname = (request.url ?? '').split('?')[0]; + if (pathname !== resourcePath) { + response.writeHead(404).end(); + return; + } + + const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/); + if (!range) { + response.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + 'Content-Type': 'video/x-yuv4mpeg', + }); + response.end(request.method === 'HEAD' ? undefined : body); + return; + } + + const start = Number(range[1]); + const requestedEnd = range[2] ? Number(range[2]) : body.length - 1; + const end = Math.min(requestedEnd, body.length - 1); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + start > end || + start >= body.length + ) { + response.writeHead(416, { + 'Content-Range': `bytes */${body.length}`, + }); + response.end(); + return; + } + + response.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Type': 'video/x-yuv4mpeg', + }); + response.end( + request.method === 'HEAD' + ? undefined + : body.subarray(start, end + 1) + ); + }); + + await listen(server); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Unable to resolve the local media server address.'); + } + + return { + close: () => closeServer(server), + url: `http://127.0.0.1:${address.port}${resourcePath}`, + }; +} + +export function createRuntimeManifestGuard( + nativeDir: string +): RuntimeManifestGuard { + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + const hiddenRuntimeManifestPath = `${runtimeManifestPath}.e2e-hidden-${process.pid}`; + let hidden = false; + + return { + hide() { + if (!statSync(runtimeManifestPath).isFile()) { + throw new Error( + `Packaged runtime manifest is not a regular file: ${runtimeManifestPath}` + ); + } + if (existsSync(hiddenRuntimeManifestPath)) { + throw new Error( + `Stale hidden runtime manifest exists: ${hiddenRuntimeManifestPath}` + ); + } + renameSync(runtimeManifestPath, hiddenRuntimeManifestPath); + hidden = true; + }, + restore() { + if (!hidden) { + return; + } + if (!existsSync(hiddenRuntimeManifestPath)) { + throw new Error( + `Hidden runtime manifest disappeared before restore: ${hiddenRuntimeManifestPath}` + ); + } + if (existsSync(runtimeManifestPath)) { + throw new Error( + `Refusing to overwrite runtime manifest during restore: ${runtimeManifestPath}` + ); + } + renameSync(hiddenRuntimeManifestPath, runtimeManifestPath); + hidden = false; + }, + }; +} + +export function readPackagedRuntimeIdentity( + nativeDir: string +): PackagedRuntimeIdentity { + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + const parsed = JSON.parse( + readFileSync(runtimeManifestPath, 'utf8') + ) as Partial; + + for (const field of [ + 'arch', + 'platform', + 'profile', + 'runtimeMode', + ] as const) { + if (typeof parsed[field] !== 'string' || !parsed[field]) { + throw new Error( + `Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.` + ); + } + } + + return parsed as PackagedRuntimeIdentity; +} + +export function assertNativeFallbackPrerequisites(): void { + if (!process.env['DISPLAY']) { + throw new Error( + 'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).' + ); + } + + const mpv = spawnSync('mpv', ['--version'], { + stdio: 'ignore', + timeout: 3000, + }); + if (mpv.status !== 0) { + throw new Error( + 'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.' + ); + } +} + +export async function installFrameCanvasAndSessionCapture( + app: LaunchedElectronApp +): Promise { + await app.mainWindow.evaluate(() => { + window.__packagedEmbeddedMpvUnsubscribe?.(); + window.__packagedEmbeddedMpvSessions = []; + window.__packagedEmbeddedMpvUnsubscribe = + window.electron.onEmbeddedMpvSessionUpdate?.((session) => { + window.__packagedEmbeddedMpvSessions?.push(session); + }); + + document.querySelector('canvas[data-embedded-mpv-frame]')?.remove(); + const canvas = document.createElement('canvas'); + canvas.dataset['embeddedMpvFrame'] = ''; + canvas.dataset['testId'] = 'packaged-embedded-mpv-frame'; + Object.assign(canvas.style, { + background: '#000', + height: '180px', + left: '0', + position: 'fixed', + top: '0', + width: '320px', + zIndex: '2147483647', + }); + document.body.append(canvas); + }); +} + +export async function getEmbeddedMpvSupport( + app: LaunchedElectronApp +): Promise { + return app.mainWindow.evaluate(async () => { + return window.electron.getEmbeddedMpvSupport(); + }); +} + +export async function getLatestSession( + app: LaunchedElectronApp, + sessionId: string +): Promise { + return app.mainWindow.evaluate((id) => { + const sessions = + window.__packagedEmbeddedMpvSessions?.filter( + (session) => session.id === id + ) ?? []; + return sessions.at(-1) ?? null; + }, sessionId); +} + +export async function renderedFrameSignal( + app: LaunchedElectronApp +): Promise { + const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame'); + const png = await canvas.screenshot(); + const { data, info } = await sharp(png) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let signal = 0; + + for (let offset = 0; offset < data.length; offset += info.channels) { + if (data[offset] + data[offset + 1] + data[offset + 2] > 30) { + signal += 1; + } + } + + return signal; +} + +export async function closeAndWaitForExit( + app: LaunchedElectronApp +): Promise { + const processHandle = app.electronApp.process(); + await closeElectronApp(app); + await expect + .poll( + () => + processHandle.exitCode !== null || + processHandle.signalCode !== null, + { timeout: 10000 } + ) + .toBe(true); +} + +export async function cleanupPackagedFrameCopySmoke(options: { + apps: Array; + media: LocalMediaServer; + runtimeManifest: RuntimeManifestGuard; +}): Promise { + const errors: unknown[] = []; + + for (const app of options.apps) { + if (!app) { + continue; + } + try { + await closeAndWaitForExit(app); + } catch (error) { + errors.push(error); + } + } + + try { + options.runtimeManifest.restore(); + } catch (error) { + errors.push(error); + } + + try { + await options.media.close(); + } catch (error) { + errors.push(error); + } + + if (errors.length > 0) { + throw new Error( + `Packaged frame-copy smoke cleanup failed: ${errors + .map((error) => + error instanceof Error ? error.message : String(error) + ) + .join('; ')}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts new file mode 100644 index 000000000..a6d300652 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts @@ -0,0 +1,264 @@ +import { + expect, + getPackagedLinuxNativeDir, + launchPackagedElectronApp, + resolvePackagedLinuxExecutable, + test, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { + assertNativeFallbackPrerequisites, + cleanupPackagedFrameCopySmoke, + closeAndWaitForExit, + createLocalMediaServer, + createRuntimeManifestGuard, + getEmbeddedMpvSupport, + getLatestSession, + installFrameCanvasAndSessionCapture, + readPackagedRuntimeIdentity, + renderedFrameSignal, +} from './embedded-mpv-frame-copy-packaged-fixtures'; + +const PACKAGED_FRAME_COPY_REQUIRED_ENV = + 'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY'; +const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY'; +const packagedExecutable = resolvePackagedLinuxExecutable(); +const packagedFrameCopyRequired = isTruthy( + process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV] +); + +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes( + (value ?? '').trim().toLowerCase() + ); +} + +// This smoke drives the public preload API directly so it exercises the real +// packaged main process, helper, frame reader, WebGL pump, and pause controls +// without coupling runtime validation to playlist import/settings UI state. +test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { + test.skip( + process.platform !== 'linux', + 'The packaged frame-copy runtime is Linux-only.' + ); + test.skip( + !packagedExecutable && !packagedFrameCopyRequired, + `Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.` + ); + + test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({ + dataDir, + }) => { + expect( + process.arch, + 'The official Linux frame-copy runtime is x64-only.' + ).toBe('x64'); + expect( + packagedExecutable, + `The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.` + ).toBeTruthy(); + + const executablePath = packagedExecutable as string; + assertNativeFallbackPrerequisites(); + const nativeDir = getPackagedLinuxNativeDir(executablePath); + const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir); + const runtimeManifest = createRuntimeManifestGuard(nativeDir); + const media = await createLocalMediaServer(); + let frameCopyApp: LaunchedElectronApp | undefined; + let fallbackApp: LaunchedElectronApp | undefined; + + try { + expect(runtimeIdentity).toMatchObject({ + arch: 'x64', + platform: 'linux', + runtimeMode: 'bundled', + }); + expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile); + + const launchedFrameCopyApp = await launchPackagedElectronApp( + executablePath, + dataDir, + { + env: { + [FRAME_COPY_OPT_IN_ENV]: '1', + LIBGL_ALWAYS_SOFTWARE: '1', + }, + } + ); + frameCopyApp = launchedFrameCopyApp; + + await expect + .poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp)) + .toMatchObject({ + engine: 'frame-copy', + frameCopyAvailable: true, + platform: 'linux', + supported: true, + }); + + await installFrameCanvasAndSessionCapture(launchedFrameCopyApp); + const created = await launchedFrameCopyApp.mainWindow.evaluate( + async () => { + return window.electron.createEmbeddedMpvSession( + { x: 0, y: 0, width: 320, height: 180 }, + 'Packaged frame-copy smoke', + 0 + ); + } + ); + + await launchedFrameCopyApp.mainWindow.evaluate( + async ({ sessionId, streamUrl }) => { + await window.electron.setEmbeddedMpvPaused(sessionId, true); + await window.electron.loadEmbeddedMpvPlayback(sessionId, { + streamUrl, + title: 'Two-second generated Y4M fixture', + isLive: false, + }); + }, + { sessionId: created.id, streamUrl: media.url } + ); + + await expect + .poll( + () => getLatestSession(launchedFrameCopyApp, created.id), + { + timeout: 15000, + } + ) + .toMatchObject({ + status: 'paused', + streamUrl: media.url, + videoHeight: 36, + videoWidth: 64, + }); + + const attached = await launchedFrameCopyApp.mainWindow.evaluate( + async (sessionId) => { + return window.electron.attachEmbeddedMpvFrameView?.( + sessionId + ); + }, + created.id + ); + expect(attached).toBe(true); + await expect( + launchedFrameCopyApp.mainWindow.getByTestId( + 'packaged-embedded-mpv-frame' + ) + ).toHaveAttribute('width', '320'); + await expect( + launchedFrameCopyApp.mainWindow.getByTestId( + 'packaged-embedded-mpv-frame' + ) + ).toHaveAttribute('height', '180'); + await expect + .poll(() => renderedFrameSignal(launchedFrameCopyApp), { + timeout: 15000, + }) + .toBeGreaterThan(0); + + await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvPaused(sessionId, false), + created.id + ); + await expect + .poll( + async () => + ( + await getLatestSession( + launchedFrameCopyApp, + created.id + ) + )?.status, + { timeout: 10000 } + ) + .toBe('playing'); + + await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvPaused(sessionId, true), + created.id + ); + await expect + .poll( + async () => + ( + await getLatestSession( + launchedFrameCopyApp, + created.id + ) + )?.status, + { timeout: 10000 } + ) + .toBe('paused'); + await launchedFrameCopyApp.mainWindow.evaluate( + async (sessionId) => { + window.electron.detachEmbeddedMpvFrameView?.(); + await window.electron.disposeEmbeddedMpvSession(sessionId); + window.__packagedEmbeddedMpvUnsubscribe?.(); + }, + created.id + ); + + await closeAndWaitForExit(launchedFrameCopyApp); + frameCopyApp = undefined; + + runtimeManifest.hide(); + + const launchedFallbackApp = await launchPackagedElectronApp( + executablePath, + dataDir, + { + env: { + [FRAME_COPY_OPT_IN_ENV]: '1', + LIBGL_ALWAYS_SOFTWARE: '1', + }, + } + ); + fallbackApp = launchedFallbackApp; + const fallbackSupport = + await getEmbeddedMpvSupport(launchedFallbackApp); + + expect(fallbackSupport).toMatchObject({ + engine: 'native', + frameCopyAvailable: false, + frameCopyUnavailableReason: 'runtime-manifest-missing', + platform: 'linux', + supported: true, + }); + + const nativeSession = await launchedFallbackApp.mainWindow.evaluate( + async () => { + return window.electron.createEmbeddedMpvSession( + { x: 0, y: 0, width: 320, height: 180 }, + 'Native-view fallback smoke', + 0 + ); + } + ); + expect(nativeSession.id).toMatch(/^embedded-mpv-/); + await launchedFallbackApp.mainWindow.evaluate( + (sessionId) => + window.electron.disposeEmbeddedMpvSession(sessionId), + nativeSession.id + ); + await expect + .poll(() => launchedFallbackApp.mainWindow.title()) + .toContain('IPTVnator'); + expect( + launchedFallbackApp.electronApp.process().exitCode + ).toBeNull(); + expect( + launchedFallbackApp.electronApp.process().signalCode + ).toBeNull(); + } finally { + await cleanupPackagedFrameCopySmoke({ + apps: [frameCopyApp, fallbackApp], + media, + runtimeManifest, + }); + } + }); +});