import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { parse as parseYaml } from 'yaml'; import { configureLinuxFrameCopyBuild } from './configure-linux-frame-copy-build.mjs'; const workspaceRoot = path.resolve( path.dirname(new URL(import.meta.url).pathname), '..', '..' ); const electronBuilderConfig = JSON.parse( fs.readFileSync(path.join(workspaceRoot, 'electron-builder.json'), 'utf8') ); const buildWorkflow = fs.readFileSync( path.join(workspaceRoot, '.github', 'workflows', 'build-and-make.yaml'), 'utf8' ); const buildWorkflowConfig = parseYaml(buildWorkflow); function workflowStep(name) { const marker = ` - name: ${name}\n`; const start = buildWorkflow.indexOf(marker); assert.notEqual(start, -1, `Missing workflow step: ${name}`); const nextStep = buildWorkflow.indexOf('\n - name:', start + 1); return buildWorkflow.slice( start, nextStep === -1 ? buildWorkflow.length : nextStep ); } function configuredTargets(config) { return config.linux.target.map(({ target, arch }) => ({ target: target.toLowerCase(), arch, })); } test('configures an x64-only system pass with exact package dependencies', () => { const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { profileName: 'system', }); assert.deepEqual(configuredTargets(configured), [ { target: 'deb', arch: ['x64'] }, { target: 'rpm', arch: ['x64'] }, { target: 'pacman', arch: ['x64'] }, ]); assert.deepEqual(configured.deb.fpm, [ '--depends=libmpv2', '--depends=libegl1', '--depends=libgl1', '--depends=libgbm1', ]); assert.deepEqual(configured.rpm.fpm, [ '--depends=mpv-libs', '--depends=libglvnd-egl', '--depends=libglvnd-glx', '--depends=mesa-libgbm', ]); assert.deepEqual(configured.pacman.fpm, [ '--depends=mpv', '--depends=libglvnd', '--depends=mesa', ]); }); test('configures portable and flatpak passes without mixing targets', () => { const portable = configureLinuxFrameCopyBuild(electronBuilderConfig, { profileName: 'portable', }); assert.deepEqual(configuredTargets(portable), [ { target: 'appimage', arch: ['x64', 'armv7l', 'arm64'] }, { target: 'snap', arch: ['x64', 'armv7l'] }, ]); const flatpak = configureLinuxFrameCopyBuild(electronBuilderConfig, { profileName: 'flatpak', }); assert.deepEqual(configuredTargets(flatpak), [ { target: 'flatpak', arch: ['x64'] }, ]); assert.equal(portable.snap.base, 'core22'); assert.equal(portable.snap.confinement, 'strict'); assert.deepEqual(portable.snap.layout, { '/usr/share/libdrm': { bind: '$SNAP/graphics/libdrm', }, '/usr/share/drirc.d': { symlink: '$SNAP/graphics/drirc.d', }, }); assert.deepEqual(portable.snap.plugs, [ 'default', { 'graphics-core22': { interface: 'content', target: '$SNAP/graphics', 'default-provider': 'mesa-core22', }, }, { 'shared-memory': { interface: 'shared-memory', private: true, }, }, ]); }); test('configures a separate marker-only foreign DEB pass without libmpv metadata', () => { const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { foreignDeb: true, }); assert.deepEqual(configuredTargets(configured), [ { target: 'deb', arch: ['armv7l', 'arm64'] }, ]); assert.equal( configured.deb.fpm?.some((entry) => entry.includes('--depends=libmpv2') ) ?? false, false ); assert.equal( configured.directories.output, 'dist/executables-linux-foreign' ); }); test('configures exactly one requested marker-only foreign DEB architecture', () => { for (const foreignArch of ['armv7l', 'arm64']) { const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { foreignDeb: true, foreignArch, }); assert.deepEqual(configuredTargets(configured), [ { target: 'deb', arch: [foreignArch] }, ]); } }); test('CLI writes an exact marker-only foreign DEB architecture', (t) => { const temporaryDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-linux-build-config-') ); t.after(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true }) ); const configPath = path.join(temporaryDirectory, 'electron-builder.json'); fs.writeFileSync( configPath, `${JSON.stringify(electronBuilderConfig, null, 4)}\n` ); const result = spawnSync( process.execPath, [ path.join( workspaceRoot, 'tools', 'packaging', 'configure-linux-frame-copy-build.mjs' ), '--config', configPath, '--foreign-deb', '--foreign-arch', 'arm64', ], { encoding: 'utf8' } ); assert.equal(result.status, 0, result.stderr); assert.deepEqual( configuredTargets(JSON.parse(fs.readFileSync(configPath, 'utf8'))), [{ target: 'deb', arch: ['arm64'] }] ); const missingValue = spawnSync( process.execPath, [ path.join( workspaceRoot, 'tools', 'packaging', 'configure-linux-frame-copy-build.mjs' ), '--config', configPath, '--foreign-deb', '--foreign-arch', ], { encoding: 'utf8' } ); assert.notEqual(missingValue.status, 0); assert.match(missingValue.stderr, /--foreign-arch requires a value/); }); test('does not mutate the shared electron-builder configuration', () => { const before = JSON.stringify(electronBuilderConfig); configureLinuxFrameCopyBuild(electronBuilderConfig, { profileName: 'system', }); assert.equal(JSON.stringify(electronBuilderConfig), before); }); test('rejects an unknown profile and conflicting foreign/profile modes', () => { assert.throws( () => configureLinuxFrameCopyBuild(electronBuilderConfig, { profileName: 'standard', }), /Unsupported Linux frame-copy profile/ ); assert.throws( () => configureLinuxFrameCopyBuild(electronBuilderConfig, { profileName: 'system', foreignDeb: true, }), /must not select a frame-copy profile/ ); assert.throws( () => configureLinuxFrameCopyBuild(electronBuilderConfig, { foreignArch: 'arm64', }), /foreign architecture requires --foreign-deb/ ); assert.throws( () => configureLinuxFrameCopyBuild(electronBuilderConfig, { foreignDeb: true, foreignArch: 'x64', }), /Unsupported marker-only foreign DEB architecture/ ); for (const foreignArch of ['', 64]) { assert.throws( () => configureLinuxFrameCopyBuild(electronBuilderConfig, { foreignDeb: true, foreignArch, }), /Unsupported marker-only foreign DEB architecture/ ); } }); test('rejects duplicate profile targets even when target count looks complete', () => { const malformed = structuredClone(electronBuilderConfig); malformed.linux.target = malformed.linux.target.map((target) => target.target === 'Snap' ? { ...target, target: 'AppImage' } : target ); assert.throws( () => configureLinuxFrameCopyBuild(malformed, { profileName: 'portable', }), /duplicate target "appimage".*missing.*snap/is ); }); test('preserves unrelated fpm dependencies and normalizes only frame-copy dependencies', () => { const customized = structuredClone(electronBuilderConfig); customized.deb = { fpm: [ '--depends=unrelated-runtime', '--depends=mesa', '--depends=libmpv2 >= 2', '--depends=libgl1', ], }; const system = configureLinuxFrameCopyBuild(customized, { profileName: 'system', }); assert.deepEqual(system.deb.fpm, [ '--depends=unrelated-runtime', '--depends=mesa', '--depends=libmpv2', '--depends=libegl1', '--depends=libgl1', '--depends=libgbm1', ]); const foreign = configureLinuxFrameCopyBuild(customized, { foreignDeb: true, }); assert.deepEqual(foreign.deb.fpm, [ '--depends=unrelated-runtime', '--depends=mesa', ]); }); test('Linux CI builds one cached source runtime and packages three isolated profiles', () => { assert.match(buildWorkflow, /^ {4}linux-embedded-mpv-runtime:$/m); for (const profile of ['system', 'portable', 'flatpak']) { assert.match( buildWorkflow, new RegExp(`linux_profile: ${profile}(?:\\s|$)`) ); } assert.doesNotMatch(buildWorkflow, /linux_profile: standard/); assert.match( buildWorkflow, /configure-linux-frame-copy-build\.mjs --profile/ ); assert.match( buildWorkflow, /configure-linux-frame-copy-build\.mjs[\s\S]*--foreign-deb/ ); assert.match( buildWorkflow, /apt-cache policy[\s\S]*meson=1\.7\.2[\s\S]*toolchain-sha256/ ); assert.match( buildWorkflow, /key: \$\{\{ steps\.linux-runtime-cache-key\.outputs\.key \}\}/ ); const cacheStep = workflowStep( 'Restore pinned Linux runtime and immutable source inputs' ); assert.match(cacheStep, /dist\/linux-frame-copy-runtime-source-inputs/); assert.doesNotMatch(cacheStep, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.doesNotMatch(cacheStep, /THIRD_PARTY_NOTICES/); const complianceStep = workflowStep( 'Generate Linux runtime notices and assemble source compliance' ); assert.doesNotMatch(complianceStep, /^\s+if:/m); assert.match( complianceStep, /generate-linux-runtime-notices\.cjs generate/ ); assert.match(complianceStep, /git rev-parse HEAD/); assert.match(complianceStep, /git diff --binary HEAD/); assert.match( complianceStep, /tar[\s\S]*linux-frame-copy-runtime-sources\.tar\.xz/ ); assert.match( complianceStep, /linux-source-archive-contract\.cjs create[\s\S]*source-archive-binding\.json/ ); assert.ok( complianceStep.indexOf( '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' ) < complianceStep.indexOf('linux-source-archive-contract.cjs create') ); assert.match(complianceStep, /source-index\.json/); assert.match(complianceStep, /THIRD_PARTY_NOTICES\.txt/); assert.match(complianceStep, /embedded-mpv-notices\.json/); assert.match( complianceStep, /SOURCE_INPUT_ROOT.*license-inputs[\s\S]*SOURCE_BUNDLE_ROOT.*license-inputs/ ); assert.match( complianceStep, /new Set\(expectedArchiveHashes\)\.size[\s\S]*archives\.length !== expectedArchiveHashes\.length/ ); assert.match(complianceStep, /prepare-linux-runtime-source-snapshot\.cjs/); assert.doesNotMatch( complianceStep, /cp -a "\$\{SOURCE_INPUT_ROOT\}\/git\/\."/ ); assert.ok( complianceStep.indexOf('prepare-linux-runtime-source-snapshot.cjs') < complianceStep.indexOf( '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' ) ); assert.match( complianceStep, /libplacebo-source-record\.json[\s\S]*sourceGitCommit[\s\S]*sourceSubmodules/ ); assert.match( complianceStep, /prepare-linux-runtime-source-snapshot\.cjs assert-vcs-free/ ); assert.ok( complianceStep.indexOf( 'prepare-linux-runtime-source-snapshot.cjs assert-vcs-free' ) < complianceStep.indexOf( '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' ) ); assert.match( buildWorkflow, /hashFiles\([^\n]*prepare-linux-runtime-source-snapshot\.cjs/ ); assert.match( buildWorkflow, /hashFiles\([^\n]*linux-source-archive-contract\.cjs/ ); const buildStep = workflowStep('Build and stage pinned LGPL Linux runtime'); assert.match(buildStep, /generate-linux-runtime-notices\.cjs collect/); assert.match( buildStep, /linux-frame-copy-runtime-source-inputs[\s\S]*archives[\s\S]*git\/libplacebo/ ); assert.ok( buildStep.indexOf('git clean -ffdqx') < buildStep.indexOf( 'cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo"' ) ); assert.doesNotMatch(buildStep, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.match( buildWorkflow, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ ); const makeStep = workflowStep('Make Electron app'); assert.match( makeStep, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ ); assert.match(buildWorkflow, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.match(buildWorkflow, /name: linux-frame-copy-runtime-sources/); assert.match(buildWorkflow, /sourceSha256[\s\S]*source-index\.json/); assert.match( buildWorkflow, /sourceArchive\?\.name[\s\S]*sourceArchive\?\.sha256/ ); assert.match(buildWorkflow, /sourceArchive\?\.schemaVersion/); assert.match(buildWorkflow, /sourceArchive\?\.repositoryRevision/); assert.match(buildWorkflow, /execFileSync\('git', \['rev-parse', 'HEAD'\]/); }); test('keeps non-Linux builds independent from the Linux runtime prerequisite', () => { const crossPlatformJob = buildWorkflowConfig.jobs?.['build-cross-platform']; const linuxJob = buildWorkflowConfig.jobs?.['build-linux']; assert.ok(crossPlatformJob); assert.ok(linuxJob); // The nightly-version job is a cheap, platform-neutral prerequisite; the // Linux runtime build must stay out of the non-Linux dependency list. assert.deepEqual(crossPlatformJob.needs, 'nightly-version'); assert.deepEqual( crossPlatformJob.strategy.matrix.include.map(({ os, arch }) => ({ os, arch, })), [ { os: 'macos', arch: 'x64' }, { os: 'macos', arch: 'arm64' }, { os: 'windows', arch: 'x64' }, ] ); assert.equal(crossPlatformJob.strategy['fail-fast'], false); assert.deepEqual(linuxJob.needs, [ 'linux-embedded-mpv-runtime', 'nightly-version', ]); assert.deepEqual( linuxJob.strategy.matrix.include.map( ({ os, arch, linux_profile: profile }) => ({ os, arch, profile, }) ), [ { os: 'linux', arch: 'x64', profile: 'system' }, { os: 'linux', arch: 'x64', profile: 'portable' }, { os: 'linux', arch: 'x64', profile: 'flatpak' }, ] ); assert.equal( linuxJob.name, 'Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})' ); assert.equal(linuxJob.strategy['fail-fast'], false); assert.deepEqual(linuxJob.steps, crossPlatformJob.steps); // Windows runners default to PowerShell, where the bash-style // "${NIGHTLY_VERSION}" expands to nothing and the version script rejects // the empty value; the step must pin bash on every host. const applyNightlyVersion = crossPlatformJob.steps.find( (step) => step.name === 'Apply nightly version' ); assert.ok(applyNightlyVersion); assert.equal(applyNightlyVersion.shell, 'bash'); assert.match(applyNightlyVersion.run, /--version "\$\{NIGHTLY_VERSION\}"/); assert.deepEqual(buildWorkflowConfig.jobs['create-release'].needs, [ 'nightly-version', 'build-cross-platform', 'build-linux', ]); assert.match(buildWorkflow, /steps:\s+&electron-build-steps/); assert.match(buildWorkflow, /steps:\s+\*electron-build-steps/); }); test('Linux runtime toolchain installs fontconfig generators without network wraps', () => { const cacheKeyStep = workflowStep( 'Resolve Linux runtime toolchain cache key' ); const installStep = workflowStep( 'Install pinned Linux runtime build dependencies' ); assert.match(cacheKeyStep, /\bapt-cache policy[\s\S]*\bgperf\b/); assert.match(installStep, /^\s+gperf\s+\\$/m); }); test('Linux CI verifies every package family and exercises intended environments', () => { const flatpakVerificationStep = workflowStep( 'Verify Flatpak payload, launcher, and sandboxed runtime' ); for (const suffix of [ 'AppImage', 'deb', 'rpm', 'pacman', 'snap', 'flatpak', ]) { assert.match( buildWorkflow, new RegExp(`\\.${suffix.replace('.', '\\.')}(?:['"*:/\\s]|$)`) ); } assert.ok( buildWorkflow.match(/verify-linux-frame-copy-runtime\.mjs/g).length >= 6 ); assert.match(buildWorkflow, /ubuntu:24\.04/); assert.match(buildWorkflow, /fedora:latest/); assert.match(buildWorkflow, /archlinux:latest/); assert.match( buildWorkflow, /snap run iptvnator --embedded-mpv-runtime-probe/ ); assert.doesNotMatch(buildWorkflow, /snap run --shell iptvnator/); assert.match( buildWorkflow, /flatpak run --command=sh com\.fourgray\.iptvnator/ ); assert.match( flatpakVerificationStep, /flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+--env=ELECTRON_ENABLE_LOGGING=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ ); assert.doesNotMatch( flatpakVerificationStep, /HELPER_PATH=.*iptvnator_mpv_helper/ ); assert.doesNotMatch(buildWorkflow, /\bldd\b/); }); test('Flatpak application runtime probe runs under an isolated D-Bus session', () => { const installStep = workflowStep('Install Linux system dependencies'); const flatpakVerificationStep = workflowStep( 'Verify Flatpak payload, launcher, and sandboxed runtime' ); assert.match( flatpakVerificationStep, /xvfb-run -a dbus-run-session -- timeout --verbose -k 10 300 flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+--env=ELECTRON_ENABLE_LOGGING=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ ); assert.match(installStep, /^\s+dbus-daemon\s+\\$/m); assert.match( flatpakVerificationStep, /xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+flatpak run --command=sh com\.fourgray\.iptvnator/ ); }); test('Flatpak CI verifies the direct Zypak ELF and preserves probe status', () => { const flatpakVerificationStep = workflowStep( 'Verify Flatpak payload, launcher, and sandboxed runtime' ); assert.match( flatpakVerificationStep, /LAUNCHER_PATH="\$\(readlink -f \/app\/bin\/iptvnator\)"/ ); assert.match( flatpakVerificationStep, /test -f "\$\{LAUNCHER_PATH\}"[\s\S]*test -x "\$\{LAUNCHER_PATH\}"/ ); assert.match( flatpakVerificationStep, /if \[ -e "\$\{LAUNCHER_PATH\}\.bin" \] \|\| \[ -L "\$\{LAUNCHER_PATH\}\.bin" \]; then/ ); assert.match( flatpakVerificationStep, /ELF_MAGIC="\$\(od -An -tx1 -N4 "\$\{LAUNCHER_PATH\}" \| tr -d "\[:space:\]"\)"[\s\S]*test "\$\{ELF_MAGIC\}" = "7f454c46"/ ); assert.doesNotMatch( flatpakVerificationStep, /grep -q .*readlink -f "\$SCRIPT_PATH"/ ); assert.doesNotMatch( flatpakVerificationStep, /grep -q .*exec "\$SCRIPT_DIR\/iptvnator\.bin"/ ); assert.match( flatpakVerificationStep, /set \+e[\s\S]*PROBE_OUTPUT="\$\([\s\S]*xvfb-run -a dbus-run-session -- timeout --verbose -k 10 300 flatpak run[\s\S]*--embedded-mpv-runtime-probe 2>&1[\s\S]*\)"[\s\S]*PROBE_STATUS=\$\?[\s\S]*set -e/ ); assert.match( flatpakVerificationStep, /PROBE_OUTPUT_LIMIT=16384[\s\S]*"\$\{PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT\}"/ ); assert.match(flatpakVerificationStep, /not an ELF file/); assert.match(flatpakVerificationStep, /Zypak needs to be called directly/); assert.match( flatpakVerificationStep, /if \[ "\$\{PROBE_STATUS\}" -ne 0 \]; then[\s\S]*exit "\$\{PROBE_STATUS\}"/ ); }); test('packaged runtime probes are time-boxed and report a hung app distinctly', () => { const snapStep = workflowStep( 'Verify Snap payloads and strict-confinement runtime' ); const flatpakVerificationStep = workflowStep( 'Verify Flatpak payload, launcher, and sandboxed runtime' ); const probeInvocation = /timeout --verbose -k 10 300 \\\s+snap run iptvnator --embedded-mpv-runtime-probe 2>&1/g; const timeoutFailure = /if probe_timed_out "\$\{(\w+)\}" "\$\{\w+\}"; then\s+echo "::error::[^"]*did not exit within 300 seconds[^"]*"\s+exit 1\s+fi/g; // 124 is SIGTERM; 137 counts only when timeout announced its own KILL. const timeoutClassifier = /probe_timed_out\(\) \{[\s\S]*?\[ "\$\{status\}" -eq 124 \] && return 0\s+\[ "\$\{status\}" -eq 137 \] &&\s+grep -Fq 'sending signal KILL to command' <<<"\$\{output\}"\s+\}/; // Every `snap run` probe is wrapped by GNU timeout and captured, so a // blocked uncaught-exception dialog cannot hold the job until its // 120-minute limit and the output still reaches the log. assert.equal( snapStep.match(/snap run iptvnator --embedded-mpv-runtime-probe/g) .length, 2 ); assert.equal(snapStep.match(probeInvocation).length, 2); assert.match( snapStep, /disconnected_probe="\$\([\s\S]*ELECTRON_ENABLE_LOGGING=1[\s\S]*timeout --verbose -k 10 300[\s\S]*\)"\s+disconnected_status=\$\?\s+set -e\s+printf '%s\\n' "\$\{disconnected_probe\}"/ ); assert.match( snapStep, /hostile_probe="\$\([\s\S]*ELECTRON_ENABLE_LOGGING=1[\s\S]*timeout --verbose -k 10 300[\s\S]*\)"\s+hostile_status=\$\?\s+set -e\s+printf '%s\\n' "\$\{hostile_probe\}"/ ); assert.deepEqual( [...snapStep.matchAll(timeoutFailure)].map(([, variable]) => variable), ['disconnected_status', 'hostile_status'] ); assert.match(snapStep, timeoutClassifier); assert.ok( snapStep.indexOf('probe_timed_out() {') < snapStep.indexOf('disconnected_probe="$('), 'the timeout classifier must be defined before the first probe' ); assert.ok( snapStep.indexOf('if probe_timed_out "${disconnected_status}"') < snapStep.indexOf('test "${disconnected_status}" -eq 1'), 'the disconnected probe must reject a timeout before asserting exit 1' ); assert.match( snapStep, /if \[ "\$\{hostile_status\}" -ne 0 \]; then[\s\S]*exit "\$\{hostile_status\}"/ ); assert.ok( snapStep.indexOf('if probe_timed_out "${hostile_status}"') < snapStep.indexOf('if [ "${hostile_status}" -ne 0 ]'), 'the hostile probe must report a timeout before its generic status failure' ); assert.ok( snapStep.includes( `grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'` ), 'logging must not relax the exact JSON verdict line match' ); assert.equal( flatpakVerificationStep.match( /timeout --verbose -k 10 300 flatpak run/g ).length, 1 ); assert.deepEqual( [...flatpakVerificationStep.matchAll(timeoutFailure)].map( ([, variable]) => variable ), ['PROBE_STATUS'] ); assert.match(flatpakVerificationStep, timeoutClassifier); assert.ok( flatpakVerificationStep.indexOf('probe_timed_out() {') < flatpakVerificationStep.indexOf('PROBE_OUTPUT="$('), 'the timeout classifier must be defined before the Flatpak probe' ); // Oversized output keeps its tail, where a late exception or the // timeout notice lands. assert.match( flatpakVerificationStep, /if \[ "\$\{#PROBE_OUTPUT\}" -le \$\(\(PROBE_OUTPUT_LIMIT \* 2\)\) \]; then\s+printf '%s\\n' "\$\{PROBE_OUTPUT\}"\s+else\s+printf '%s\\n' "\$\{PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT\}"[\s\S]*?printf '%s\\n' "\$\{PROBE_OUTPUT: -PROBE_OUTPUT_LIMIT\}"\s+fi/ ); assert.ok( flatpakVerificationStep.indexOf( 'if probe_timed_out "${PROBE_STATUS}"' ) < flatpakVerificationStep.indexOf('if [ "${PROBE_STATUS}" -ne 0 ]'), 'the timeout verdict must precede the generic non-zero status handling' ); }); test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { const foreignDebStep = workflowStep( 'Make marker-only foreign-architecture DEB packages' ); assert.match(foreignDebStep, /for foreign_arch in armv7l arm64; do/); assert.match( foreignDebStep, /--foreign-deb\s+\\\s+--foreign-arch "\$\{foreign_arch\}"/ ); assert.match(foreignDebStep, /--arch="\$\{foreign_arch\}"/); assert.match( foreignDebStep, /for foreign_arch[\s\S]*cp "\$\{RUNNER_TEMP\}\/electron-builder\.base\.json" electron-builder\.json[\s\S]*configure-linux-frame-copy-build\.mjs/ ); assert.match(foreignDebStep, /rm -rf dist\/executables-linux-foreign/); assert.match( foreignDebStep, /mapfile -t foreign_debs < <\(\s*find dist\/executables-linux-foreign[\s\S]*-name '\*\.deb' -print\s*\)/ ); assert.match(foreignDebStep, /test "\$\{#foreign_debs\[@\]\}" -eq 1/); assert.match(foreignDebStep, /armv7l\) expected_deb_arch=armhf/); assert.match(foreignDebStep, /arm64\) expected_deb_arch=arm64/); assert.match( foreignDebStep, /actual_deb_arch="\$\(dpkg-deb --field "\$\{foreign_debs\[0\]\}" Architecture\)"[\s\S]*test "\$\{actual_deb_arch\}" = "\$\{expected_deb_arch\}"/ ); assert.match( foreignDebStep, /mv "\$\{foreign_debs\[0\]\}" dist\/executables\// ); assert.match(foreignDebStep, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ''/); assert.match(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1'/); assert.doesNotMatch(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0'/); }); test('system package smoke environments install every direct helper runtime dependency', () => { const debStep = workflowStep('Verify DEB payloads and x64 system runtime'); for (const dependency of ['libmpv2', 'libegl1', 'libgl1', 'libgbm1']) { assert.match(debStep, new RegExp(`\\b${dependency}\\b`)); } const rpmStep = workflowStep('Verify RPM payload and x64 system runtime'); for (const dependency of [ 'mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm', ]) { assert.match(rpmStep, new RegExp(`\\b${dependency}\\b`)); } const pacmanStep = workflowStep( 'Verify Pacman payload and x64 system runtime' ); for (const dependency of ['mpv', 'libglvnd', 'mesa']) { assert.match(pacmanStep, new RegExp(`\\b${dependency}\\b`)); } }); test('Snap verifier preserves fail-closed status while exposing captured diagnostics', () => { const snapStep = workflowStep( 'Verify Snap payloads and strict-confinement runtime' ); assert.match(snapStep, /set -euo pipefail/); assert.match(snapStep, /2>&1 \| tee \/dev\/stderr/); assert.doesNotMatch( snapStep, /^\s+printf '%s\\n' "\$\{verification\}"\s*$/m ); assert.ok( snapStep.indexOf('verification="$(') < snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") ); assert.ok( snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") < snapStep.indexOf('sudo snap install --dangerous') ); assert.match( snapStep, /snap list mesa-core22 >\/dev\/null 2>&1 \|\| sudo snap install mesa-core22/ ); assert.match( snapStep, /snap list gnome-3-28-1804 >\/dev\/null 2>&1 \|\| sudo snap install gnome-3-28-1804/ ); assert.ok( snapStep.indexOf('sudo snap install mesa-core22') < snapStep.indexOf('sudo snap install --dangerous') ); assert.ok( snapStep.indexOf('sudo snap install gnome-3-28-1804') < snapStep.indexOf('sudo snap install --dangerous') ); assert.ok( snapStep.indexOf('sudo snap install --dangerous') < snapStep.indexOf('installed_x64=true') ); assert.match( snapStep, /sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22/ ); assert.match( snapStep, /sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804/ ); assert.match( snapStep, /\$2 == "iptvnator:graphics-core22" && \$3 == "mesa-core22:graphics-core22"/ ); assert.match( snapStep, /\$2 == "iptvnator:gnome-3-28-1804" && \$3 == "gnome-3-28-1804:gnome-3-28-1804"/ ); assert.match( snapStep, /\$1 == "shared-memory" && \$2 == "iptvnator:shared-memory" && \$3 == ":shared-memory"/ ); assert.match( snapStep, /sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22/ ); assert.match( snapStep, /\$2 == "iptvnator:graphics-core22" && \$3 == "-" \{ found=1 \} END \{ exit !found \}/ ); assert.match(snapStep, /disconnected_probe="\$\(/); assert.match(snapStep, /disconnected_status=\$\?/); assert.match(snapStep, /test "\$\{disconnected_status\}" -eq 1/); assert.ok( snapStep.includes( `grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'` ) ); const firstGraphicsConnect = snapStep.indexOf( 'sudo snap connect iptvnator:graphics-core22' ); const graphicsDisconnect = snapStep.indexOf( 'sudo snap disconnect iptvnator:graphics-core22' ); const secondGraphicsConnect = snapStep.indexOf( 'sudo snap connect iptvnator:graphics-core22', firstGraphicsConnect + 1 ); assert.ok(firstGraphicsConnect < graphicsDisconnect); assert.ok(graphicsDisconnect < snapStep.indexOf('disconnected_probe="$(')); assert.ok( snapStep.indexOf('test "${disconnected_status}" -eq 1') < secondGraphicsConnect ); const firstRuntimeProbe = snapStep.indexOf( 'snap run iptvnator --embedded-mpv-runtime-probe' ); const successfulRuntimeProbe = snapStep.lastIndexOf( 'snap run iptvnator --embedded-mpv-runtime-probe' ); const graphicsConnectionAssertion = snapStep.indexOf( '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22"' ); const gnomeConnectionAssertion = snapStep.indexOf( '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804"' ); const sharedMemoryConnectionAssertion = snapStep.indexOf( '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory"' ); assert.ok(firstRuntimeProbe < secondGraphicsConnect); assert.ok(firstRuntimeProbe < successfulRuntimeProbe); assert.ok(secondGraphicsConnect < successfulRuntimeProbe); assert.ok(secondGraphicsConnect < graphicsConnectionAssertion); assert.ok(graphicsConnectionAssertion < gnomeConnectionAssertion); assert.ok(gnomeConnectionAssertion < sharedMemoryConnectionAssertion); assert.ok(sharedMemoryConnectionAssertion < successfulRuntimeProbe); assert.match(snapStep, /snap run iptvnator --embedded-mpv-runtime-probe/); for (const traceSelector of [ 'IPTVNATOR_TRACE_PLAYER=1', 'EGL_LOG_LEVEL=debug', 'LIBGL_DEBUG=verbose', ]) { assert.match( snapStep.slice(secondGraphicsConnect), new RegExp(traceSelector) ); } for (const hostileOverride of [ '__EGL_VENDOR_LIBRARY_FILENAMES', 'GBM_BACKEND', 'MESA_LOADER_DRIVER_OVERRIDE', 'LIBVA_DRIVER_NAME', 'VDPAU_DRIVER_PATH', 'VK_DRIVER_FILES', 'VK_ICD_FILENAMES', 'VK_ADD_DRIVER_FILES', 'VK_ADD_LAYER_PATH', 'VK_IMPLICIT_LAYER_PATH', 'VK_ADD_IMPLICIT_LAYER_PATH', 'XDG_CONFIG_HOME', 'XDG_CONFIG_DIRS', 'XDG_DATA_HOME', 'XDG_DATA_DIRS', ]) { assert.match( snapStep.slice(secondGraphicsConnect), new RegExp(`${hostileOverride}=/tmp/hostile`) ); } assert.doesNotMatch(snapStep, /snap run --shell/); assert.doesNotMatch(snapStep, /iptvnator_mpv_helper/); assert.doesNotMatch(snapStep, /LD_LIBRARY_PATH/); }); test('dedicated packaged x64 smoke cannot silently skip', () => { const e2eProject = JSON.parse( fs.readFileSync( path.join( workspaceRoot, 'apps', 'electron-backend-e2e', 'project.json' ), 'utf8' ) ); assert.deepEqual( e2eProject.targets?.['packaged-frame-copy-smoke']?.dependsOn, ['test-packaged-frame-copy-fixtures'] ); const linuxDependencies = workflowStep('Install Linux system dependencies'); assert.match(linuxDependencies, /--no-install-recommends/); assert.match(linuxDependencies, /^\s+libgl-dev\s*\\?$/m); assert.doesNotMatch(linuxDependencies, /\blibopengl-dev\b/); assert.match(linuxDependencies, /^\s+xauth\s*\\?$/m); assert.match(linuxDependencies, /^\s+xvfb\s*\\?$/m); const packagedSmoke = workflowStep( 'Run packaged x64 frame-copy and fallback smoke' ); assert.match( packagedSmoke, /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ ); assert.match( packagedSmoke, /IPTVNATOR_E2E_PACKAGED_EXECUTABLE: \$\{\{ github\.workspace \}\}\/dist\/executables\/linux-unpacked\/iptvnator/ ); assert.match( packagedSmoke, /electron-backend-e2e:packaged-frame-copy-smoke/ ); const hardwareDiagnostic = workflowStep( 'Diagnose packaged x64 frame-copy hardware path' ); assert.match(hardwareDiagnostic, /continue-on-error: true/); assert.match(hardwareDiagnostic, /\/dev\/dri\/renderD128/); assert.match( hardwareDiagnostic, /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ ); assert.doesNotMatch(hardwareDiagnostic, /LIBGL_ALWAYS_SOFTWARE/); }); test('draft release consumes split Linux artifacts and source compliance', () => { for (const artifactName of [ 'linux-system-artifacts', 'linux-portable-artifacts', 'linux-flatpak-artifacts', 'linux-frame-copy-runtime-sources', ]) { assert.match(buildWorkflow, new RegExp(artifactName)); } const systemUpload = workflowStep('Upload system-runtime Linux artifacts'); for (const artifactGlob of [ 'dist/executables/*.deb', 'dist/executables/*.rpm', 'dist/executables/*.pacman', 'dist/executables/*.pkg.tar.*', ]) { assert.ok(systemUpload.includes(artifactGlob)); } const portableUpload = workflowStep( 'Upload portable-runtime Linux artifacts' ); for (const artifactGlob of [ 'dist/executables/*.AppImage', 'dist/executables/*.snap', 'dist/executables/**/latest-linux*.yml', 'dist/executables/**/*.blockmap', ]) { assert.ok(portableUpload.includes(artifactGlob)); } assert.ok( workflowStep('Upload Flatpak-runtime Linux artifacts').includes( 'dist/executables/**/*.flatpak' ) ); const release = workflowStep('Create Draft Release'); for (const releasePath of [ 'artifacts/linux-system-artifacts/*.deb', 'artifacts/linux-system-artifacts/*.rpm', 'artifacts/linux-system-artifacts/*.pacman', 'artifacts/linux-system-artifacts/*.pkg.tar.*', 'artifacts/linux-portable-artifacts/*.AppImage', 'artifacts/linux-portable-artifacts/*.snap', 'artifacts/linux-flatpak-artifacts/*.flatpak', 'artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz', ]) { assert.ok(release.includes(releasePath)); } assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); });