mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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: [],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user