Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka

# Conflicts:
#	CLAUDE.md
#	libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
4gray committed 2026-07-24 18:44:22 +02:00
commit 5b2dd3b8b0
425 files changed
+59216 -3432

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: [],
});
+20
View File
@@ -12,6 +12,26 @@
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
},
"packaged-frame-copy-smoke": {
"dependsOn": ["test-packaged-frame-copy-fixtures"],
"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"
}
+187 -1
View File
@@ -1,4 +1,6 @@
import { mkdirSync, readdirSync } from 'fs';
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { createServer } from 'http';
import type { AddressInfo } from 'net';
import { join } from 'path';
import type { Page } from '@playwright/test';
import {
@@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
}
interface RangeServerRequest {
ifRange?: string;
range?: string;
}
interface ThrottledRangeServer {
close: () => Promise<void>;
payload: Buffer;
requests: RangeServerRequest[];
url: string;
}
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
/**
* Serves a payload slowly on the first (full) request so the UI has a wide
* window to pause mid-transfer, and answers Range requests with an immediate
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
*/
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
const payload = Buffer.from(
Array.from({ length: 96 * 1024 }, (_, index) =>
String(index % 10)
).join('')
);
const requests: RangeServerRequest[] = [];
const server = createServer((req, res) => {
const range = req.headers.range;
const ifRange = req.headers['if-range'];
requests.push({
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
range: typeof range === 'string' ? range : undefined,
});
const offset = range
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
: 0;
if (range && Number.isFinite(offset)) {
res.writeHead(206, {
'Content-Length': payload.length - offset,
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
res.end(payload.subarray(offset));
return;
}
res.writeHead(200, {
'Content-Length': payload.length,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
// First 16 KiB immediately, then a trickle: the transfer stays alive
// for tens of seconds unless it is paused or resumed via Range.
let sent = 16 * 1024;
res.write(payload.subarray(0, sent));
const timer = setInterval(() => {
if (sent >= payload.length) {
clearInterval(timer);
res.end();
return;
}
res.write(payload.subarray(sent, sent + 2 * 1024));
sent += 2 * 1024;
}, 150);
res.on('close', () => clearInterval(timer));
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const { port } = server.address() as AddressInfo;
return {
close: () =>
new Promise<void>((resolve) => server.close(() => resolve())),
payload,
requests,
url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`,
};
}
/**
* On a cold profile the renderer can query SQLite while the DB worker is
* still creating tables ("database is locked" / "no such table" on slow CI
@@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => {
await fileServer.close();
}
});
test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const rangeServer = await createThrottledRangeServer();
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Pause Portal',
username: 'user1',
password: 'pass1',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openDownloadsPage(app.mainWindow);
const downloadsDir = join(dataDir, 'e2e-pause-downloads');
mkdirSync(downloadsDir, { recursive: true });
await app.electronApp.evaluate(({ dialog }, folder) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [folder],
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
}, downloadsDir);
await app.mainWindow
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.locator('.downloads__folder-inline-path')
).toContainText('e2e-pause-downloads');
const startResult = await app.mainWindow.evaluate(
async ({ url, folder }) =>
window.electron?.downloadsStart?.({
playlistId: 'e2e-playlist',
xtreamId: 7373,
contentType: 'vod',
title: 'E2E Pause Movie',
url,
downloadFolder: folder,
}),
{ url: rangeServer.url, folder: downloadsDir }
);
expect(startResult?.error ?? null).toBeNull();
const item = app.mainWindow.locator('.downloads__item');
await expect(item).toHaveCount(1, { timeout: 20000 });
await expect(item.locator('.downloads__item-status')).toContainText(
'Downloading',
{ timeout: 20000 }
);
// Pause mid-transfer: the row flips to Paused and only a .part
// file exists on disk (final file absent, progress retained).
await item
.getByRole('button')
.filter({ hasText: 'pause' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Paused',
{ timeout: 20000 }
);
const pausedFiles = readdirSync(downloadsDir);
expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']);
const pausedBytes = statSync(
join(downloadsDir, 'E2E Pause Movie.mp4.part')
).size;
expect(pausedBytes).toBeGreaterThan(0);
expect(pausedBytes).toBeLessThan(rangeServer.payload.length);
// Resume: the runtime must continue via Range/If-Range instead of
// restarting, and the assembled file must match the payload.
await item
.getByRole('button')
.filter({ hasText: 'play_arrow' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Completed',
{ timeout: 30000 }
);
const resumeRequest = rangeServer.requests.find(
(entry) => entry.range
);
expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/);
expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG);
expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']);
const finalFile = readFileSync(
join(downloadsDir, 'E2E Pause Movie.mp4')
);
expect(finalFile.equals(rangeServer.payload)).toBe(true);
} finally {
await closeElectronApp(app);
await rangeServer.close();
}
});
});
@@ -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,7 +175,142 @@ export async function launchElectronApp(
};
}
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
/**
* 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'
);
}
export function resolvePackagedElectronLaunchArgs(
getuid: (() => number) | undefined
): string[] {
const args = ['--ignore-gpu-blocklist'];
if (typeof getuid === 'function' && getuid() === 0) {
args.push('--no-sandbox');
}
return args;
}
/**
* Launch a real packaged Linux executable. Unlike the regular source E2E
* launcher, this deliberately keeps Chromium's GPU path enabled and ignores
* its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and
* CI's llvmpipe WebGL2 context must 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 electronApp = await electron.launch({
executablePath: resolvedExecutablePath,
args: resolvePackagedElectronLaunchArgs(process.getuid),
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;
}
@@ -235,10 +374,7 @@ async function waitForPromiseWithTimeout(
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(
() => resolvePromise(false),
timeoutMs
);
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
@@ -297,11 +433,11 @@ async function waitForAppReady(page: Page): Promise<void> {
} catch (error) {
const diagnostics = await page.evaluate(() => ({
appRootLength:
document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
document.querySelector('app-root')?.innerHTML.trim().length ??
0,
baseHref:
document
.querySelector('base')
?.getAttribute('href') ?? '<missing>',
document.querySelector('base')?.getAttribute('href') ??
'<missing>',
readyState: document.readyState,
title: document.title,
url: location.href,
@@ -357,7 +493,7 @@ export async function importM3uPlaylistFromNativeDialog(
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
await fileInput.evaluate((element, selectedFilePath) => {
(element as HTMLInputElement).dataset.filePathOverride =
(element as HTMLInputElement).dataset['filePathOverride'] =
selectedFilePath;
}, filePath);
await fileInput.setInputFiles(filePath);
@@ -501,15 +637,17 @@ async function clickDialogMethodOption(
label: RegExp,
legacySelector?: string
): Promise<void> {
const optionByRadio = dialog
.getByRole('radio', { name: label })
.first();
const optionByRadio = dialog.getByRole('radio', { name: label }).first();
if ((await optionByRadio.count()) > 0) {
await optionByRadio.click();
return;
}
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
for (const tablistLabel of [
'Source method',
'Playlist category',
'M3U source',
]) {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
.first();
@@ -541,9 +679,7 @@ async function clickDialogMethodOption(
}
if (!legacySelector) {
throw new Error(
`Could not find dialog option matching ${label}.`
);
throw new Error(`Could not find dialog option matching ${label}.`);
}
await dialog.locator(legacySelector).click();
@@ -704,9 +840,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
const attributes = [
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
channel.tvgLanguage
? `tvg-language="${channel.tvgLanguage}"`
: '',
channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '',
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
@@ -889,9 +1023,7 @@ export async function switchUnifiedCollectionContent(
await clickButtonToggleOption(toggleGroup, contentLabel);
}
export async function clearCurrentUnifiedCollection(
page: Page
): Promise<void> {
export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
await page
.getByRole('button', {
name: /Clear .* (favorites|recently viewed)/i,
@@ -1464,6 +1596,15 @@ export async function expectWorkspaceSearchStatus(
).toHaveText(expected);
}
/** The degraded-search hint chip must be absent (e.g. complete local search). */
export async function expectNoWorkspaceSearchStatus(
page: Page
): Promise<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--status')
).toHaveCount(0);
}
async function startPortalDebugCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const electronApi = window.electron as typeof window.electron & {
@@ -0,0 +1,288 @@
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 } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const temporaryDirectories = new Set<string>();
type PackageFixture = {
executablePath: string;
libmpvAliasPath: string;
libmpvSonamePath: 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');
const runtimeLibraryDir = join(nativeDir, 'lib');
const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2');
const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so');
mkdirSync(runtimeLibraryDir, { recursive: true });
writeFileSync(executablePath, '#!/bin/sh\nexit 0\n');
chmodSync(executablePath, 0o755);
writeFileSync(payloadPath, 'packaged payload');
chmodSync(payloadPath, 0o640);
writeFileSync(libmpvSonamePath, 'packaged libmpv');
symlinkSync('libmpv.so.2', libmpvAliasPath);
writeFileSync(
runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: 'libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
if (process.platform !== 'win32') {
symlinkSync(
'payload.bin',
join(packageRoot, 'resources', 'payload-link')
);
}
return {
executablePath,
libmpvAliasPath,
libmpvSonamePath,
nativeDir,
packageRoot,
payloadPath,
runtimeManifestPath,
};
}
function entryExists(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
afterEach(() => {
for (const directory of temporaryDirectories) {
rmSync(directory, { force: true, recursive: true });
}
temporaryDirectories.clear();
});
describe('disposable unpacked package clone', () => {
it('requires a safe versioned libmpv target in the packaged manifest', () => {
const source = createPackageFixture();
assert.equal(
readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname,
'libmpv.so.2'
);
writeFileSync(
source.runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: '../libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
assert.throws(
() => readPackagedRuntimeIdentity(source.nativeDir),
/libmpvSoname/
);
});
it('hides and restores a cloned regular dependency without changing the source package', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2');
const guard = createPackagedEntryGuard(clonedLibmpvPath, {
expectedKind: 'regular-file',
hiddenDirectory: clone.temporaryRoot,
});
try {
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
guard.hide();
assert.equal(entryExists(clonedLibmpvPath), false);
assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true);
assert.equal(
readFileSync(source.libmpvSonamePath, 'utf8'),
'packaged libmpv'
);
guard.restore();
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(entryExists(source.libmpvSonamePath), true);
});
it('hides and restores a cloned symbolic link without dereferencing it', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so');
const guard = createPackagedEntryGuard(clonedAliasPath, {
expectedKind: 'symbolic-link',
hiddenDirectory: clone.temporaryRoot,
});
try {
guard.hide();
assert.equal(entryExists(clonedAliasPath), false);
assert.equal(
lstatSync(source.libmpvAliasPath).isSymbolicLink(),
true
);
assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2');
guard.restore();
assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2');
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
it('hardlinks regular files and preserves modes, symlinks, and 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'
);
assert.equal(existsSync(clonedRuntimeManifestPath), true);
assert.equal(existsSync(source.runtimeManifestPath), true);
} 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);
}
});
});
@@ -0,0 +1,255 @@
import {
chmodSync,
copyFileSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
type Stats,
} 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;
libmpvSoname: string;
platform: string;
profile: string;
runtimeMode: string;
};
export type PackagedEntryKind = 'regular-file' | 'symbolic-link';
export type PackagedEntryGuard = {
hide: () => void;
restore: () => void;
};
export type PackagedEntryGuardOptions = {
expectedKind: PackagedEntryKind;
hiddenDirectory: string;
};
const HARDLINK_COPY_FALLBACK_CODES = new Set([
'EACCES',
'EMLINK',
'ENOSYS',
'ENOTSUP',
'EPERM',
'EXDEV',
]);
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
function entryKindMatches(
stats: Stats,
expectedKind: PackagedEntryKind
): boolean {
return expectedKind === 'regular-file'
? stats.isFile() && !stats.isSymbolicLink()
: stats.isSymbolicLink();
}
function entryExistsByLstat(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
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 createPackagedEntryGuard(
entryPath: string,
options: PackagedEntryGuardOptions
): PackagedEntryGuard {
const guardedEntryPath = resolve(entryPath);
const hiddenDirectory = resolve(options.hiddenDirectory);
const hiddenEntryPath = join(
hiddenDirectory,
`.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}`
);
let hidden = false;
return {
hide() {
const entryStats = lstatSync(guardedEntryPath);
if (!entryKindMatches(entryStats, options.expectedKind)) {
throw new Error(
`Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}`
);
}
const hiddenDirectoryStats = lstatSync(hiddenDirectory);
if (
hiddenDirectoryStats.isSymbolicLink() ||
!hiddenDirectoryStats.isDirectory()
) {
throw new Error(
`Packaged entry stash is not a regular directory: ${hiddenDirectory}`
);
}
if (entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Stale hidden packaged entry exists: ${hiddenEntryPath}`
);
}
renameSync(guardedEntryPath, hiddenEntryPath);
hidden = true;
},
restore() {
if (!hidden) {
return;
}
if (!entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Hidden packaged entry disappeared before restore: ${hiddenEntryPath}`
);
}
if (entryExistsByLstat(guardedEntryPath)) {
throw new Error(
`Refusing to overwrite packaged entry during restore: ${guardedEntryPath}`
);
}
renameSync(hiddenEntryPath, guardedEntryPath);
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}.`
);
}
}
if (
typeof parsed.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname)
) {
throw new Error(
`Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.`
);
}
return parsed as PackagedRuntimeIdentity;
}
@@ -0,0 +1,191 @@
import assert = require('node:assert/strict');
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures';
import './embedded-mpv-frame-copy-packaged-filesystem.tests';
import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures';
import packagedPlaywrightConfig from '../playwright.packaged.config';
const projectRoot = resolve(__dirname, '..');
describe('packaged Electron launch arguments', () => {
it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => {
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 1000),
['--ignore-gpu-blocklist']
);
assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [
'--ignore-gpu-blocklist',
]);
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 0),
['--ignore-gpu-blocklist', '--no-sandbox']
);
});
});
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);
});
it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const manifestIdentityIndex = source.indexOf(
'const runtimeIdentity = readPackagedRuntimeIdentity'
);
const guardIndex = source.indexOf(
'createPackagedEntryGuard(',
manifestIdentityIndex
);
const sonameIndex = source.indexOf(
'runtimeIdentity.libmpvSoname',
guardIndex
);
const hideIndex = source.indexOf('.hide()', sonameIndex);
const fallbackStart = source.indexOf(
'const launchedFallbackApp',
hideIndex
);
const missingReasonIndex = source.indexOf(
"frameCopyUnavailableReason: 'runtime-library-missing'",
fallbackStart
);
assert.ok(manifestIdentityIndex >= 0);
assert.ok(guardIndex > manifestIdentityIndex);
assert.ok(sonameIndex > guardIndex);
assert.ok(hideIndex > sonameIndex);
assert.ok(fallbackStart > hideIndex);
assert.ok(missingReasonIndex > fallbackStart);
assert.doesNotMatch(source, /createRuntimeManifestGuard/);
assert.doesNotMatch(source, /runtimeManifest\.hide/);
});
});
describe('dedicated packaged smoke target', () => {
it('inherits the GL mode from the workflow environment', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/);
});
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.deepEqual(target.dependsOn, [
'test-packaged-frame-copy-fixtures',
]);
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, []);
});
});
@@ -0,0 +1,344 @@
import type {
EmbeddedMpvSession,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { spawnSync } from 'child_process';
import { createServer, type Server } from 'http';
import sharp = require('sharp');
import {
closeElectronApp,
expect,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import type {
DisposablePackagedLinuxApp,
PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
declare global {
interface Window {
__packagedEmbeddedMpvSessions?: EmbeddedMpvSession[];
__packagedEmbeddedMpvUnsubscribe?: () => void;
}
}
export type LocalMediaServer = {
close: () => Promise<void>;
url: 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 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 installEmbeddedMpvSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.mainWindow.evaluate(() => {
window.__packagedEmbeddedMpvUnsubscribe?.();
window.__packagedEmbeddedMpvSessions = [];
window.__packagedEmbeddedMpvUnsubscribe =
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'] = '';
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 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> {
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>;
hiddenRuntimeEntry?: PackagedEntryGuard;
media?: LocalMediaServer;
packageClone?: DisposablePackagedLinuxApp;
}): Promise<void> {
const errors: unknown[] = [];
for (const app of options.apps) {
if (!app) {
continue;
}
try {
await closeAndWaitForExit(app);
} catch (error) {
errors.push(error);
}
}
if (options.hiddenRuntimeEntry) {
try {
options.hiddenRuntimeEntry.restore();
} 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) {
throw new Error(
`Packaged frame-copy smoke cleanup failed: ${errors
.map((error) =>
error instanceof Error ? error.message : String(error)
)
.join('; ')}`
);
}
}
@@ -0,0 +1,311 @@
import {
expect,
launchPackagedElectronApp,
resolvePackagedLinuxExecutable,
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { join } from 'path';
import {
assertNativeFallbackPrerequisites,
cleanupPackagedFrameCopySmoke,
closeAndWaitForExit,
createLocalMediaServer,
getEmbeddedMpvSupport,
getLatestSession,
installEmbeddedMpvSessionCapture,
installFrameCanvasAndSessionCapture,
isMeaningfulNativePlaybackSnapshot,
renderedFrameSignal,
type LocalMediaServer,
} from './embedded-mpv-frame-copy-packaged-fixtures';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
type DisposablePackagedLinuxApp,
type PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
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 sourceExecutablePath = packagedExecutable as string;
assertNativeFallbackPrerequisites();
let packageClone: DisposablePackagedLinuxApp | undefined;
let hiddenRuntimeEntry: PackagedEntryGuard | 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);
hiddenRuntimeEntry = createPackagedEntryGuard(
join(nativeDir, 'lib', runtimeIdentity.libmpvSoname),
{
expectedKind: 'regular-file',
hiddenDirectory: packageClone.temporaryRoot,
}
);
const mediaServer = await createLocalMediaServer();
media = mediaServer;
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',
},
}
);
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: mediaServer.url }
);
await expect
.poll(
() => getLatestSession(launchedFrameCopyApp, created.id),
{
timeout: 15000,
}
)
.toMatchObject({
status: 'paused',
streamUrl: mediaServer.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;
hiddenRuntimeEntry.hide();
expect(readPackagedRuntimeIdentity(nativeDir)).toEqual(
runtimeIdentity
);
const launchedFallbackApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
fallbackApp = launchedFallbackApp;
const fallbackSupport =
await getEmbeddedMpvSupport(launchedFallbackApp);
expect(fallbackSupport).toMatchObject({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'runtime-library-missing',
platform: 'linux',
supported: true,
});
await installEmbeddedMpvSessionCapture(launchedFallbackApp);
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(
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(
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],
hiddenRuntimeEntry,
media,
packageClone,
});
}
});
});
+9 -13
View File
@@ -6,8 +6,8 @@ import {
closeElectronApp,
defaultStalkerMacAddress,
expect,
expectNoWorkspaceSearchStatus,
expectWorkspaceSearchScope,
expectWorkspaceSearchStatus,
fillWorkspaceSearch,
goToDashboard,
importM3uPlaylistFromNativeDialog,
@@ -987,7 +987,7 @@ test.describe('Electron Workspace Search', () => {
}
});
test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({
test('@search @stalker searches the complete channel list on ITV routes', async ({
dataDir,
request,
}) => {
@@ -1030,26 +1030,22 @@ test.describe('Electron Workspace Search', () => {
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
await expectWorkspaceSearchStatus(
app.mainWindow,
'Loaded channels only'
);
// Entering Live TV preloads the COMPLETE channel list (Ministra
// get_all_channels), so search runs locally over every channel:
// no portal-side search request, and no "Loaded channels only"
// degraded hint.
await waitForPortalDebugEvent(app.mainWindow, {
provider: 'stalker',
operation: 'get_ordered_list',
operation: 'get_all_channels',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return (
requestPayload.params?.['type'] === 'itv' &&
String(requestPayload.params?.['category']) ===
sample.categoryId &&
requestPayload.params?.['search'] === itvQuery
);
return requestPayload.params?.['type'] === 'itv';
},
});
await expectNoWorkspaceSearchStatus(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();