diff --git a/apps/electron-backend-e2e/playwright.packaged.config.ts b/apps/electron-backend-e2e/playwright.packaged.config.ts new file mode 100644 index 000000000..d5f67f26a --- /dev/null +++ b/apps/electron-backend-e2e/playwright.packaged.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; +import baseConfig from './playwright.config'; + +export default defineConfig({ + ...baseConfig, + outputDir: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke', + reporter: [ + ['list'], + [ + 'html', + { + outputFolder: + '../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke', + }, + ], + [ + 'json', + { + outputFile: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json', + }, + ], + ], + timeout: 120000, + webServer: [], +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1e886ba34..9be0f5864 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,25 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "packaged-frame-copy-smoke": { + "executor": "nx:run-commands", + "cache": false, + "outputs": [ + "{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke", + "{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke" + ], + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts" + } + }, + "test-packaged-frame-copy-fixtures": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 2d398b381..f45a92454 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -242,6 +242,14 @@ export function getPackagedLinuxNativeDir(executablePath: string): string { ); } +export function resolvePackagedElectronLaunchArgs( + getuid: (() => number) | undefined +): string[] { + return typeof getuid === 'function' && getuid() === 0 + ? ['--no-sandbox'] + : []; +} + /** * Launch a real packaged Linux executable. Unlike the regular source E2E * launcher, this deliberately keeps Chromium's GPU path enabled: the @@ -273,17 +281,9 @@ export async function launchPackagedElectronApp( ); } - 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, + args: resolvePackagedElectronLaunchArgs(process.getuid), env: { ...process.env, IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts new file mode 100644 index 000000000..ce1e954df --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts @@ -0,0 +1,203 @@ +import { + chmodSync, + copyFileSync, + existsSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + statSync, + symlinkSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { basename, dirname, join, resolve } from 'path'; +import { getPackagedLinuxNativeDir } from './electron-test-fixtures'; + +export type DisposablePackagedLinuxApp = { + cleanup: () => void; + executablePath: string; + nativeDir: string; + packageRoot: string; + temporaryRoot: string; +}; + +export type DisposablePackagedLinuxAppOptions = { + linkFile?: (existingPath: string, newPath: string) => void; +}; + +export type PackagedRuntimeIdentity = { + arch: string; + platform: string; + profile: string; + runtimeMode: string; +}; + +export type RuntimeManifestGuard = { + hide: () => void; + restore: () => void; +}; + +const HARDLINK_COPY_FALLBACK_CODES = new Set([ + 'EACCES', + 'EMLINK', + 'ENOSYS', + 'ENOTSUP', + 'EPERM', + 'EXDEV', +]); + +function clonePackagedEntry( + sourcePath: string, + destinationPath: string, + linkFile: (existingPath: string, newPath: string) => void +): void { + const sourceStats = lstatSync(sourcePath); + + if (sourceStats.isDirectory()) { + mkdirSync(destinationPath); + for (const entry of readdirSync(sourcePath)) { + clonePackagedEntry( + join(sourcePath, entry), + join(destinationPath, entry), + linkFile + ); + } + chmodSync(destinationPath, sourceStats.mode & 0o7777); + return; + } + + if (sourceStats.isSymbolicLink()) { + symlinkSync(readlinkSync(sourcePath), destinationPath); + return; + } + + if (!sourceStats.isFile()) { + throw new Error( + `Unsupported packaged runtime entry type at ${sourcePath}.` + ); + } + + try { + linkFile(sourcePath, destinationPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) { + throw error; + } + copyFileSync(sourcePath, destinationPath); + chmodSync(destinationPath, sourceStats.mode & 0o7777); + } +} + +export function createDisposablePackagedLinuxApp( + executablePath: string, + options: DisposablePackagedLinuxAppOptions = {} +): DisposablePackagedLinuxApp { + const sourceExecutablePath = resolve(executablePath); + const sourcePackageRoot = dirname(sourceExecutablePath); + const temporaryRoot = mkdtempSync( + join(tmpdir(), 'iptvnator-packaged-frame-copy-') + ); + const packageRoot = join(temporaryRoot, basename(sourcePackageRoot)); + let cleaned = false; + + try { + clonePackagedEntry( + sourcePackageRoot, + packageRoot, + options.linkFile ?? linkSync + ); + } catch (error) { + rmSync(temporaryRoot, { force: true, recursive: true }); + throw error; + } + + const clonedExecutablePath = join( + packageRoot, + basename(sourceExecutablePath) + ); + return { + cleanup() { + if (cleaned) { + return; + } + rmSync(temporaryRoot, { force: true, recursive: true }); + cleaned = true; + }, + executablePath: clonedExecutablePath, + nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath), + packageRoot, + temporaryRoot, + }; +} + +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; +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts new file mode 100644 index 000000000..089a5a6d2 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts @@ -0,0 +1,318 @@ +import assert = require('node:assert/strict'); +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures'; +import { + createDisposablePackagedLinuxApp, + createRuntimeManifestGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; +import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures'; +import packagedPlaywrightConfig from '../playwright.packaged.config'; + +const projectRoot = resolve(__dirname, '..'); +const temporaryDirectories = new Set(); + +type PackageFixture = { + executablePath: string; + nativeDir: string; + packageRoot: string; + payloadPath: string; + runtimeManifestPath: string; +}; + +function createPackageFixture(): PackageFixture { + const packageRoot = mkdtempSync( + join(tmpdir(), 'iptvnator-packaged-fixture-source-') + ); + temporaryDirectories.add(packageRoot); + const executablePath = join(packageRoot, 'IPTVnator'); + const nativeDir = join( + packageRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const payloadPath = join(packageRoot, 'resources', 'payload.bin'); + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + + mkdirSync(nativeDir, { recursive: true }); + writeFileSync(executablePath, '#!/bin/sh\nexit 0\n'); + chmodSync(executablePath, 0o755); + writeFileSync(payloadPath, 'packaged payload'); + chmodSync(payloadPath, 0o640); + writeFileSync( + runtimeManifestPath, + JSON.stringify({ + arch: 'x64', + platform: 'linux', + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + + if (process.platform !== 'win32') { + symlinkSync( + 'payload.bin', + join(packageRoot, 'resources', 'payload-link') + ); + } + + return { + executablePath, + nativeDir, + packageRoot, + payloadPath, + runtimeManifestPath, + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories) { + rmSync(directory, { force: true, recursive: true }); + } + temporaryDirectories.clear(); +}); + +describe('packaged Electron launch arguments', () => { + it('disables the Chromium sandbox only when running as root', () => { + const originalCi = process.env['CI']; + process.env['CI'] = 'true'; + + try { + assert.deepEqual( + resolvePackagedElectronLaunchArgs(() => 1000), + [] + ); + assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), []); + assert.deepEqual( + resolvePackagedElectronLaunchArgs(() => 0), + ['--no-sandbox'] + ); + } finally { + if (originalCi === undefined) { + delete process.env['CI']; + } else { + process.env['CI'] = originalCi; + } + } + }); +}); + +describe('disposable unpacked package clone', () => { + it('hardlinks regular files, preserves modes and symlinks, and never hides the source manifest', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + try { + assert.notEqual(clone.packageRoot, source.packageRoot); + assert.equal( + statSync(clone.executablePath).mode & 0o777, + statSync(source.executablePath).mode & 0o777 + ); + + const clonedPayloadPath = join( + clone.packageRoot, + 'resources', + 'payload.bin' + ); + assert.equal( + statSync(clonedPayloadPath).ino, + statSync(source.payloadPath).ino + ); + assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640); + + if (process.platform !== 'win32') { + const clonedLinkPath = join( + clone.packageRoot, + 'resources', + 'payload-link' + ); + assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true); + assert.equal(readlinkSync(clonedLinkPath), 'payload.bin'); + } + + const clonedRuntimeManifestPath = join( + clone.nativeDir, + 'embedded-mpv-runtime.json' + ); + const guard = createRuntimeManifestGuard(clone.nativeDir); + guard.hide(); + assert.equal(existsSync(clonedRuntimeManifestPath), false); + assert.equal(existsSync(source.runtimeManifestPath), true); + guard.restore(); + } finally { + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + + assert.equal(existsSync(clone.temporaryRoot), false); + assert.equal(existsSync(source.runtimeManifestPath), true); + }); + + it('copies a regular file when hardlinking is unavailable', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath, { + linkFile() { + throw Object.assign(new Error('cross-device hardlink'), { + code: 'EXDEV', + }); + }, + }); + temporaryDirectories.add(clone.temporaryRoot); + + try { + assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755); + const clonedPayloadPath = join( + clone.packageRoot, + 'resources', + 'payload.bin' + ); + assert.equal( + readFileSync(clonedPayloadPath, 'utf8'), + readFileSync(source.payloadPath, 'utf8') + ); + assert.notEqual( + statSync(clonedPayloadPath).ino, + statSync(source.payloadPath).ino + ); + assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640); + } finally { + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + }); +}); + +describe('native-view playback proof', () => { + it('requires the loaded URL, a playing or paused state, and positive duration', () => { + const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m'; + const baseSnapshot = { + durationSeconds: 2, + status: 'playing', + streamUrl: expectedUrl, + }; + + assert.equal( + isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl), + true + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, status: 'paused' }, + `${expectedUrl}#ignored` + ), + true + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, durationSeconds: null }, + expectedUrl + ), + false + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, status: 'idle' }, + expectedUrl + ), + false + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` }, + expectedUrl + ), + false + ); + }); + + it('loads the fixture and observes playback before disposing the native session', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + const fallbackStart = source.indexOf('const launchedFallbackApp'); + const captureIndex = source.indexOf( + 'installEmbeddedMpvSessionCapture', + fallbackStart + ); + const loadIndex = source.indexOf( + 'loadEmbeddedMpvPlayback', + fallbackStart + ); + const proofIndex = source.indexOf( + 'isMeaningfulNativePlaybackSnapshot', + fallbackStart + ); + const exitCodeIndex = source.indexOf( + 'electronApp.process().exitCode', + fallbackStart + ); + const disposeIndex = source.indexOf( + 'disposeEmbeddedMpvSession', + fallbackStart + ); + + assert.ok(fallbackStart >= 0); + assert.ok(captureIndex > fallbackStart); + assert.ok(loadIndex > captureIndex); + assert.ok(proofIndex > loadIndex); + assert.ok(exitCodeIndex > proofIndex); + assert.ok(disposeIndex > exitCodeIndex); + }); +}); + +describe('dedicated packaged smoke target', () => { + it('does not build the backend or start portal mock servers', () => { + const project = JSON.parse( + readFileSync(join(projectRoot, 'project.json'), 'utf8') + ) as { + targets?: Record< + string, + { + cache?: boolean; + dependsOn?: unknown; + options?: { command?: string }; + } + >; + }; + const target = project.targets?.['packaged-frame-copy-smoke']; + const packagedConfig = readFileSync( + join(projectRoot, 'playwright.packaged.config.ts'), + 'utf8' + ); + + if (!target) { + throw new Error('The packaged frame-copy smoke target is missing.'); + } + assert.equal(target.cache, false); + assert.equal(target.dependsOn, undefined); + assert.match( + target.options?.command ?? '', + /playwright\.packaged\.config\.ts/ + ); + assert.match( + target.options?.command ?? '', + /embedded-mpv-frame-copy-packaged\.e2e\.ts/ + ); + assert.match(packagedConfig, /timeout:\s*120000/); + assert.match(packagedConfig, /webServer:\s*\[\]/); + assert.deepEqual(packagedPlaywrightConfig.webServer, []); + }); +}); 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 index 340067f06..dc11e2266 100644 --- 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 @@ -4,14 +4,16 @@ import type { } 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'; +import type { + DisposablePackagedLinuxApp, + RuntimeManifestGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; declare global { interface Window { @@ -25,18 +27,6 @@ export type LocalMediaServer = { 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; @@ -157,72 +147,6 @@ export async function createLocalMediaServer(): Promise { }; } -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( @@ -241,7 +165,7 @@ export function assertNativeFallbackPrerequisites(): void { } } -export async function installFrameCanvasAndSessionCapture( +export async function installEmbeddedMpvSessionCapture( app: LaunchedElectronApp ): Promise { await app.mainWindow.evaluate(() => { @@ -251,7 +175,14 @@ export async function installFrameCanvasAndSessionCapture( window.electron.onEmbeddedMpvSessionUpdate?.((session) => { window.__packagedEmbeddedMpvSessions?.push(session); }); + }); +} +export async function installFrameCanvasAndSessionCapture( + app: LaunchedElectronApp +): Promise { + await installEmbeddedMpvSessionCapture(app); + await app.mainWindow.evaluate(() => { document.querySelector('canvas[data-embedded-mpv-frame]')?.remove(); const canvas = document.createElement('canvas'); canvas.dataset['embeddedMpvFrame'] = ''; @@ -290,6 +221,39 @@ export async function getLatestSession( }, sessionId); } +export function isMeaningfulNativePlaybackSnapshot( + snapshot: { + durationSeconds: number | null; + error?: string; + status: string; + streamUrl: string; + } | null, + expectedUrl: string +): boolean { + if ( + !snapshot || + snapshot.error || + !['paused', 'playing'].includes(snapshot.status) || + typeof snapshot.durationSeconds !== 'number' || + !Number.isFinite(snapshot.durationSeconds) || + snapshot.durationSeconds <= 0 + ) { + return false; + } + + const normalizeUrl = (value: string): string => { + try { + const url = new URL(value); + url.hash = ''; + return url.href; + } catch { + return value.trim(); + } + }; + + return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl); +} + export async function renderedFrameSignal( app: LaunchedElectronApp ): Promise { @@ -327,8 +291,9 @@ export async function closeAndWaitForExit( export async function cleanupPackagedFrameCopySmoke(options: { apps: Array; - media: LocalMediaServer; - runtimeManifest: RuntimeManifestGuard; + media?: LocalMediaServer; + packageClone?: DisposablePackagedLinuxApp; + runtimeManifest?: RuntimeManifestGuard; }): Promise { const errors: unknown[] = []; @@ -343,16 +308,28 @@ export async function cleanupPackagedFrameCopySmoke(options: { } } - try { - options.runtimeManifest.restore(); - } catch (error) { - errors.push(error); + if (options.runtimeManifest) { + try { + options.runtimeManifest.restore(); + } catch (error) { + errors.push(error); + } } - try { - await options.media.close(); - } catch (error) { - errors.push(error); + if (options.media) { + try { + await options.media.close(); + } catch (error) { + errors.push(error); + } + } + + if (options.packageClone) { + try { + options.packageClone.cleanup(); + } catch (error) { + errors.push(error); + } } if (errors.length > 0) { 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 index a6d300652..d0a0953cf 100644 --- 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 @@ -1,6 +1,5 @@ import { expect, - getPackagedLinuxNativeDir, launchPackagedElectronApp, resolvePackagedLinuxExecutable, test, @@ -11,13 +10,21 @@ import { cleanupPackagedFrameCopySmoke, closeAndWaitForExit, createLocalMediaServer, - createRuntimeManifestGuard, getEmbeddedMpvSupport, getLatestSession, + installEmbeddedMpvSessionCapture, installFrameCanvasAndSessionCapture, - readPackagedRuntimeIdentity, + isMeaningfulNativePlaybackSnapshot, renderedFrameSignal, + type LocalMediaServer, } from './embedded-mpv-frame-copy-packaged-fixtures'; +import { + createDisposablePackagedLinuxApp, + createRuntimeManifestGuard, + readPackagedRuntimeIdentity, + type DisposablePackagedLinuxApp, + type RuntimeManifestGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; const PACKAGED_FRAME_COPY_REQUIRED_ENV = 'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY'; @@ -58,16 +65,24 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { `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; + const sourceExecutablePath = packagedExecutable as string; assertNativeFallbackPrerequisites(); - const nativeDir = getPackagedLinuxNativeDir(executablePath); - const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir); - const runtimeManifest = createRuntimeManifestGuard(nativeDir); - const media = await createLocalMediaServer(); + let packageClone: DisposablePackagedLinuxApp | undefined; + let runtimeManifest: RuntimeManifestGuard | undefined; + let media: LocalMediaServer | undefined; let frameCopyApp: LaunchedElectronApp | undefined; let fallbackApp: LaunchedElectronApp | undefined; try { + packageClone = + createDisposablePackagedLinuxApp(sourceExecutablePath); + const executablePath = packageClone.executablePath; + const nativeDir = packageClone.nativeDir; + const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir); + runtimeManifest = createRuntimeManifestGuard(nativeDir); + const mediaServer = await createLocalMediaServer(); + media = mediaServer; + expect(runtimeIdentity).toMatchObject({ arch: 'x64', platform: 'linux', @@ -116,7 +131,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { isLive: false, }); }, - { sessionId: created.id, streamUrl: media.url } + { sessionId: created.id, streamUrl: mediaServer.url } ); await expect @@ -128,7 +143,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { ) .toMatchObject({ status: 'paused', - streamUrl: media.url, + streamUrl: mediaServer.url, videoHeight: 36, videoWidth: 64, }); @@ -229,6 +244,7 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { supported: true, }); + await installEmbeddedMpvSessionCapture(launchedFallbackApp); const nativeSession = await launchedFallbackApp.mainWindow.evaluate( async () => { return window.electron.createEmbeddedMpvSession( @@ -240,23 +256,46 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { ); expect(nativeSession.id).toMatch(/^embedded-mpv-/); await launchedFallbackApp.mainWindow.evaluate( - (sessionId) => - window.electron.disposeEmbeddedMpvSession(sessionId), - nativeSession.id + async ({ sessionId, streamUrl }) => { + await window.electron.loadEmbeddedMpvPlayback(sessionId, { + streamUrl, + title: 'Native-view generated Y4M fixture', + isLive: false, + }); + }, + { sessionId: nativeSession.id, streamUrl: mediaServer.url } ); await expect - .poll(() => launchedFallbackApp.mainWindow.title()) - .toContain('IPTVnator'); + .poll( + async () => + isMeaningfulNativePlaybackSnapshot( + await getLatestSession( + launchedFallbackApp, + nativeSession.id + ), + mediaServer.url + ), + { timeout: 15000 } + ) + .toBe(true); expect( launchedFallbackApp.electronApp.process().exitCode ).toBeNull(); expect( launchedFallbackApp.electronApp.process().signalCode ).toBeNull(); + await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => { + await window.electron.disposeEmbeddedMpvSession(sessionId); + window.__packagedEmbeddedMpvUnsubscribe?.(); + }, nativeSession.id); + await expect + .poll(() => launchedFallbackApp.mainWindow.title()) + .toContain('IPTVnator'); } finally { await cleanupPackagedFrameCopySmoke({ apps: [frameCopyApp, fallbackApp], media, + packageClone, runtimeManifest, }); }