mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
559 lines
22 KiB
TypeScript
559 lines
22 KiB
TypeScript
import {
|
|
expect,
|
|
launchPackagedElectronApp,
|
|
resolvePackagedLinuxExecutable,
|
|
test,
|
|
type LaunchedElectronApp,
|
|
} from './electron-test-fixtures';
|
|
import { readFileSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import {
|
|
assertNativeFallbackPrerequisites,
|
|
cleanupPackagedFrameCopySmoke,
|
|
closeAndWaitForExit,
|
|
createLocalMediaServer,
|
|
createWavFixture,
|
|
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 () => {
|
|
// Decode audio without requiring a sound device on CI.
|
|
await window.electron.updateSettings({
|
|
embeddedMpvExtraOptions: 'ao=null',
|
|
});
|
|
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: '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);
|
|
|
|
// Relative seek steps (arrow keys, ±10 s buttons) must reach mpv
|
|
// as `seek <delta> relative+exact` through the real preload →
|
|
// main-process → helper `seek-by` path, and a burst issued without
|
|
// waiting for snapshots has to accumulate. Absolute targets
|
|
// computed from the stale renderer snapshot collapsed such bursts
|
|
// onto one target (about one second of progress per press).
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (sessionId) => {
|
|
const seekBy = window.electron.seekEmbeddedMpvBy;
|
|
if (!seekBy) {
|
|
throw new Error(
|
|
'seekEmbeddedMpvBy is missing from the preload bridge'
|
|
);
|
|
}
|
|
await Promise.all([
|
|
seekBy(sessionId, 2),
|
|
seekBy(sessionId, 2),
|
|
seekBy(sessionId, 2),
|
|
]);
|
|
},
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.positionSeconds,
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBe(6);
|
|
// Stepping back past the start clamps at zero instead of failing.
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.seekEmbeddedMpvBy?.(sessionId, -60),
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.positionSeconds,
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBe(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');
|
|
|
|
// Advanced subtitle support (#1408): exercise the real preload
|
|
// IPC, main-process validation, and the helper's `sub-add`
|
|
// protocol command against the packaged runtime. The native file
|
|
// dialog cannot be automated, so the path-based IPC is driven
|
|
// directly with a fixture file; mpv parses and auto-selects it,
|
|
// and the helper's track-list observation must report it back.
|
|
const subtitlePath = join(dataDir, 'packaged-smoke.srt');
|
|
writeFileSync(
|
|
subtitlePath,
|
|
'1\n00:00:00,000 --> 00:00:10,000\nPackaged subtitle smoke\n'
|
|
);
|
|
const withSubtitle = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, path }) =>
|
|
window.electron.addEmbeddedMpvSubtitle?.(sessionId, path),
|
|
{ sessionId: created.id, path: subtitlePath }
|
|
);
|
|
expect(withSubtitle?.id).toBe(created.id);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const session = await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
);
|
|
return session?.subtitleTracks?.length ?? 0;
|
|
},
|
|
{ timeout: 15000 }
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
// Delay and style ride the same session-guarded IPC; mpv does not
|
|
// echo the values in the snapshot, so a session reply for the live
|
|
// session (instead of a rejection) is the observable contract.
|
|
const delayReply = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.setEmbeddedMpvSubtitleDelay?.(
|
|
sessionId,
|
|
1.5
|
|
),
|
|
created.id
|
|
);
|
|
expect(delayReply?.id).toBe(created.id);
|
|
const styleReply = await launchedFrameCopyApp.mainWindow.evaluate(
|
|
(sessionId) =>
|
|
window.electron.setEmbeddedMpvSubtitleStyle?.(sessionId, {
|
|
sizePercent: 150,
|
|
color: '#ffe94f',
|
|
}),
|
|
created.id
|
|
);
|
|
expect(styleReply?.id).toBe(created.id);
|
|
|
|
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');
|
|
// These values must originate in real libmpv property observations,
|
|
// survive helper JSON/preload mapping, and change with the source.
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
)
|
|
)?.stats
|
|
)
|
|
.toMatchObject({
|
|
videoCodec: expect.any(String),
|
|
container: expect.any(String),
|
|
droppedFrames: expect.any(Number),
|
|
});
|
|
const originalStats = (await getLatestSession(
|
|
launchedFrameCopyApp,
|
|
created.id
|
|
))!.stats!;
|
|
const alternateMedia = await createLocalMediaServer({
|
|
body: readFileSync(
|
|
join(
|
|
__dirname,
|
|
'../../web-e2e/src/fixtures/playback/episode.webm'
|
|
)
|
|
),
|
|
resourcePath: '/stream-stats-switch.webm',
|
|
contentType: 'video/webm',
|
|
});
|
|
media = {
|
|
url: mediaServer.url,
|
|
close: async () => {
|
|
await Promise.all([
|
|
mediaServer.close(),
|
|
alternateMedia.close(),
|
|
]);
|
|
},
|
|
};
|
|
const audioMedia = await createLocalMediaServer({
|
|
body: createWavFixture(),
|
|
resourcePath: '/stream-stats-audio.wav',
|
|
contentType: 'audio/wav',
|
|
});
|
|
const videoMedia = media;
|
|
media = {
|
|
url: videoMedia.url,
|
|
close: async () => {
|
|
await Promise.all([videoMedia.close(), audioMedia.close()]);
|
|
},
|
|
};
|
|
// mpv uses its built-in demuxer name "mkv" for WebM.
|
|
for (const [streamUrl, videoCodec, container] of [
|
|
[alternateMedia.url, 'vp8', 'mkv'],
|
|
[
|
|
mediaServer.url,
|
|
originalStats.videoCodec,
|
|
originalStats.container,
|
|
],
|
|
] as const) {
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, streamUrl }) => {
|
|
await window.electron.loadEmbeddedMpvPlayback(
|
|
sessionId,
|
|
{
|
|
streamUrl,
|
|
title: 'Stream stats source switch',
|
|
isLive: false,
|
|
}
|
|
);
|
|
},
|
|
{ sessionId: created.id, streamUrl }
|
|
);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
getLatestSession(launchedFrameCopyApp, created.id),
|
|
{ timeout: 15000 }
|
|
)
|
|
.toMatchObject({
|
|
streamUrl,
|
|
stats: { videoCodec, container },
|
|
});
|
|
}
|
|
|
|
// Audio-only replacements must not retain the previous video size.
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async ({ sessionId, streamUrl }) => {
|
|
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
|
|
streamUrl,
|
|
title: 'Audio-only statistics',
|
|
isLive: false,
|
|
});
|
|
},
|
|
{ sessionId: created.id, streamUrl: audioMedia.url }
|
|
);
|
|
await expect
|
|
.poll(
|
|
() => getLatestSession(launchedFrameCopyApp, created.id),
|
|
{ timeout: 15000 }
|
|
)
|
|
.toMatchObject({
|
|
streamUrl: audioMedia.url,
|
|
videoWidth: 0,
|
|
videoHeight: 0,
|
|
stats: {
|
|
audioCodec: 'pcm_s16le',
|
|
audioChannels: expect.any(String),
|
|
audioSampleRateHz: 8000,
|
|
},
|
|
});
|
|
// Revoking the only audio track emits MPV_FORMAT_NONE without a
|
|
// START_FILE reset. Previously these values stayed cached forever.
|
|
await launchedFrameCopyApp.mainWindow.evaluate(
|
|
async (sessionId) => {
|
|
await window.electron.setEmbeddedMpvAudioTrack(
|
|
sessionId,
|
|
-1
|
|
);
|
|
},
|
|
created.id
|
|
);
|
|
await expect
|
|
.poll(async () => {
|
|
const stats = (
|
|
await getLatestSession(launchedFrameCopyApp, created.id)
|
|
)?.stats;
|
|
return {
|
|
codec: stats?.audioCodec ?? null,
|
|
channels: stats?.audioChannels ?? null,
|
|
sampleRate: stats?.audioSampleRateHz ?? null,
|
|
};
|
|
})
|
|
.toEqual({ codec: null, channels: null, sampleRate: null });
|
|
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
});
|