mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
test(embedded-mpv): harden packaged Linux smoke
This commit is contained in:
1 parent
5c49deb77a
commit
22079a5986
7 files changed
+698
-115
No files matched your search
@@ -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: [],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
@@ -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<LocalMediaServer> {
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -241,7 +165,7 @@ export function assertNativeFallbackPrerequisites(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function installFrameCanvasAndSessionCapture(
|
||||
export async function installEmbeddedMpvSessionCapture(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
@@ -327,8 +291,9 @@ export async function closeAndWaitForExit(
|
||||
|
||||
export async function cleanupPackagedFrameCopySmoke(options: {
|
||||
apps: Array<LaunchedElectronApp | undefined>;
|
||||
media: LocalMediaServer;
|
||||
runtimeManifest: RuntimeManifestGuard;
|
||||
media?: LocalMediaServer;
|
||||
packageClone?: DisposablePackagedLinuxApp;
|
||||
runtimeManifest?: RuntimeManifestGuard;
|
||||
}): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user