mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
test(embedded-mpv): smoke packaged Linux frame-copy
This commit is contained in:
1 parent
456ba1574b
commit
81918de8ec
3 files changed
+767
-2
No files matched your search
@@ -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<string, string | undefined>;
|
||||
};
|
||||
|
||||
@@ -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<LaunchedElectronApp> {
|
||||
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;
|
||||
|
||||
@@ -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<void>;
|
||||
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<void> {
|
||||
await new Promise<void>((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<void> {
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
|
||||
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<PackagedRuntimeIdentity>;
|
||||
|
||||
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<void> {
|
||||
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<EmbeddedMpvSupport> {
|
||||
return app.mainWindow.evaluate(async () => {
|
||||
return window.electron.getEmbeddedMpvSupport();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestSession(
|
||||
app: LaunchedElectronApp,
|
||||
sessionId: string
|
||||
): Promise<EmbeddedMpvSession | null> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<LaunchedElectronApp | undefined>;
|
||||
media: LocalMediaServer;
|
||||
runtimeManifest: RuntimeManifestGuard;
|
||||
}): Promise<void> {
|
||||
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('; ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user