diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c49091ab148..a9e45ef116e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -775,18 +775,9 @@ jobs: [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } - name: Verify headless serve signal shutdown - run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage - - - name: Verify extracted launcher serve signal shutdown run: >- node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint launcher - - - name: Verify AppImage CLI registration and serve signal shutdown - run: >- - node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint appimage - --signal-target serving-electron --int-delivery pid + --appimage dist/orca-linux.AppImage --all-entrypoints # A default container reproduces the hostile AppImage launch environment. - name: Verify Linux CLI launch contract diff --git a/config/scripts/headless-serve-shutdown-matrix.test.mjs b/config/scripts/headless-serve-shutdown-matrix.test.mjs new file mode 100644 index 00000000000..7231cc21e4f --- /dev/null +++ b/config/scripts/headless-serve-shutdown-matrix.test.mjs @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnSync } = vi.hoisted(() => ({ spawnSync: vi.fn() })) +vi.mock('node:child_process', () => ({ spawnSync })) + +let directory +let artifact +let originalArgv +let originalExitCode +const commands = () => spawnSync.mock.calls.map(([, args]) => args) +const signalRuns = () => commands().filter((args) => ['INT', 'TERM'].includes(args.at(-1))) +const succeeded = { status: 0, stdout: '', stderr: '' } + +async function run(...options) { + process.argv = ['node', 'runner', '--appimage', artifact, ...options] + await import('./run-headless-serve-shutdown-docker.mjs') +} + +beforeEach(() => { + vi.resetModules() + spawnSync.mockReset().mockReturnValue(succeeded) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + directory = mkdtempSync(join(tmpdir(), 'orca-shutdown-matrix-')) + artifact = join(directory, 'original.AppImage') + writeFileSync(artifact, 'original package bytes') + originalArgv = process.argv + originalExitCode = process.exitCode +}) + +afterEach(() => { + process.argv = originalArgv + process.exitCode = originalExitCode + rmSync(directory, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('packaged shutdown matrix', () => { + it('shares extraction but isolates every entrypoint and signal', async () => { + await run('--all-entrypoints') + expect(commands().filter((args) => args[0] === 'build')).toHaveLength(1) + const startup = commands().filter((args) => + args.includes('/usr/local/bin/run-appimage-desktop-startup-case') + ) + const extraction = commands().filter((args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ) + expect(startup).toHaveLength(1) + expect(extraction).toHaveLength(1) + expect(commands().indexOf(startup[0])).toBeLessThan(commands().indexOf(extraction[0])) + expect(signalRuns()).toHaveLength(6) + const names = new Set() + for (const [index, args] of signalRuns().entries()) { + const entrypoint = ['app', 'launcher', 'appimage'][Math.floor(index / 2)] + expect(args).toContain(`ORCA_TEST_ENTRYPOINT=${entrypoint}`) + expect(args).toContain( + `ORCA_SIGNAL_TARGET=${entrypoint === 'appimage' ? 'serving-electron' : 'app'}` + ) + expect(args).toContain( + `ORCA_INT_DELIVERY=${entrypoint === 'appimage' ? 'pid' : 'foreground-process-group'}` + ) + expect(args.at(-1)).toBe(index % 2 === 0 ? 'INT' : 'TERM') + expect(args).toContain(`${artifact}:/input/orca.AppImage:ro`) + expect(args.some((arg) => arg.endsWith(':/artifacts:ro'))).toBe(true) + expect(args).toContain('--rm') + names.add(args[args.indexOf('--name') + 1]) + } + expect(names.size).toBe(6) + const evidence = console.log.mock.calls + .map(([line]) => line) + .filter((line) => line.startsWith('{')) + .map(JSON.parse) + expect(evidence).toHaveLength(3) + expect( + evidence.every( + (entry) => + entry.sha256 === createHash('sha256').update('original package bytes').digest('hex') + ) + ).toBe(true) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('attributes failures and still attempts later cases before cleanup', async () => { + spawnSync.mockImplementation((_, args) => + args.at(-1) === 'INT' ? { ...succeeded, status: 7 } : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow( + 'app:INT:7, launcher:INT:7, appimage:INT:7' + ) + expect(signalRuns()).toHaveLength(6) + expect(commands().at(-2).slice(0, 2)).toEqual(['volume', 'rm']) + }) + + it('cleans setup resources without running cases after failed extraction', async () => { + spawnSync.mockImplementation((_, args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ? { ...succeeded, status: 9 } + : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow('docker run failed') + expect(signalRuns()).toHaveLength(0) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('preserves individual launcher overlay invocations', async () => { + await run('--entrypoint', 'launcher', '--launcher-exec-overlay') + expect(signalRuns()).toHaveLength(2) + expect(signalRuns().every((args) => args.includes('ORCA_TEST_ENTRYPOINT=launcher'))).toBe(true) + expect( + commands().some((args) => + args.some((arg) => arg.includes("sed -i 's/^ELECTRON_RUN_AS_NODE=1")) + ) + ).toBe(true) + }) + + it('rejects ambiguous matrix overrides before invoking Docker', async () => { + await expect(run('--all-entrypoints', '--entrypoint', 'launcher')).rejects.toThrow( + 'cannot be combined' + ) + expect(spawnSync).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 90a3f73c77d..3b71a2e774e 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -56,12 +56,6 @@ describe('headless serve shutdown PR gate', () => { const packageStep = steps.find((step) => step.name === 'Package unpacked app') const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads') const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown') - const launcherShutdownStep = steps.find( - (step) => step.name === 'Verify extracted launcher serve signal shutdown' - ) - const appImageShutdownStep = steps.find( - (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown' - ) expect(workflow.jobs.package['timeout-minutes']).toBe(90) expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never') @@ -69,19 +63,13 @@ describe('headless serve shutdown PR gate', () => { expect(markerStep.run).toContain('rpm2cpio') expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(shutdownStep.run).toBe( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage' + 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints' ) - expect(launcherShutdownStep.run).toContain( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs' - ) - expect(launcherShutdownStep.run).toContain('--entrypoint launcher') - expect(appImageShutdownStep.run).toContain('--entrypoint appimage') - expect(appImageShutdownStep.run).toContain('--signal-target serving-electron') - expect(appImageShutdownStep.run).toContain('--int-delivery pid') expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep)) - expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep)) - expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) + expect( + steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs')) + ).toHaveLength(1) }) it('keeps readiness polling finite and leak-free', () => { diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs index d8dcdd345ad..4c367b5f7c4 100755 --- a/config/scripts/run-headless-serve-shutdown-docker.mjs +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -11,6 +11,22 @@ const signalTarget = valueAfter('--signal-target') ?? 'app' const entrypoint = valueAfter('--entrypoint') ?? 'app' const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group' const launcherExecOverlay = args.includes('--launcher-exec-overlay') +const allEntrypoints = args.includes('--all-entrypoints') +if ( + allEntrypoints && + ['--entrypoint', '--signal-target', '--int-delivery', '--launcher-exec-overlay'].some((flag) => + args.includes(flag) + ) +) { + fail('--all-entrypoints cannot be combined with individual case options') +} +const cases = allEntrypoints + ? [ + { entrypoint: 'app', signalTarget: 'app', intDelivery: 'foreground-process-group' }, + { entrypoint: 'launcher', signalTarget: 'app', intDelivery: 'foreground-process-group' }, + { entrypoint: 'appimage', signalTarget: 'serving-electron', intDelivery: 'pid' } + ] + : [{ entrypoint, signalTarget, intDelivery }] if (!appImageArg) { fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage') } @@ -98,50 +114,52 @@ try { ].join(' && ') ]) - console.log( - JSON.stringify({ - type: 'appimage_under_test', - appImage, - sha256, - platform, - signalTarget, - entrypoint, - intDelivery, - launcherExecOverlay - }) - ) const failedSignals = [] - for (const signal of ['INT', 'TERM']) { - const result = docker( - [ - 'run', - '--rm', - '--init', - '--platform', + for (const { entrypoint, signalTarget, intDelivery } of cases) { + console.log( + JSON.stringify({ + type: 'appimage_under_test', + appImage, + sha256, platform, - '--shm-size', - '256m', - '--name', - `orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`, - '-e', - `ORCA_SIGNAL_TARGET=${signalTarget}`, - '-e', - `ORCA_TEST_ENTRYPOINT=${entrypoint}`, - '-e', - `ORCA_INT_DELIVERY=${intDelivery}`, - '-v', - `${appImage}:/input/orca.AppImage:ro`, - '-v', - `${artifactVolume}:/artifacts:ro`, - image, - signal - ], - { allowFailure: true } + signalTarget, + entrypoint, + intDelivery, + launcherExecOverlay + }) ) - process.stdout.write(result.stdout) - process.stderr.write(result.stderr) - if (result.status !== 0) { - failedSignals.push(`${signal}:${result.status}`) + for (const signal of ['INT', 'TERM']) { + const result = docker( + [ + 'run', + '--rm', + '--init', + '--platform', + platform, + '--shm-size', + '256m', + '--name', + `orca-headless-serve-shutdown-${entrypoint}-${signal.toLowerCase()}-${suffix}`, + '-e', + `ORCA_SIGNAL_TARGET=${signalTarget}`, + '-e', + `ORCA_TEST_ENTRYPOINT=${entrypoint}`, + '-e', + `ORCA_INT_DELIVERY=${intDelivery}`, + '-v', + `${appImage}:/input/orca.AppImage:ro`, + '-v', + `${artifactVolume}:/artifacts:ro`, + image, + signal + ], + { allowFailure: true } + ) + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + if (result.status !== 0) { + failedSignals.push(`${entrypoint}:${signal}:${result.status}`) + } } } if (failedSignals.length > 0) {