Files
iptvnator/tools/packaging/configure-linux-frame-copy-build.test.mjs

1023 lines
36 KiB
JavaScript

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);
});