From a6835e4453a5b546a01d7f4611fc7d006fb8f5b1 Mon Sep 17 00:00:00 2001 From: charlie barmore <235893792+charliebarmore@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:56:01 -0400 Subject: [PATCH] test: verify Linux source builds and reject false recovery launches --- .github/workflows/linux.yml | 73 +++++++++++++++++++++++++ CONTRIBUTING.md | 17 ++++++ README.md | 5 +- app/scripts/recovery-check.mjs | 38 +++++++++---- app/scripts/recovery-headless-check.mjs | 33 +++++++++++ app/src/main/index.ts | 8 ++- 6 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/linux.yml create mode 100644 app/scripts/recovery-headless-check.mjs diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..aff048a --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,73 @@ +name: Linux + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + branches: [main] + +concurrency: + group: linux-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Verify source on Linux + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: app/package-lock.json + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Install Electron, display, and viewer dependencies + run: | + sudo apt-get update + sudo apt-get install -y xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 libatk-bridge2.0-0 poppler-utils tesseract-ocr + + - name: Create the engine venv + run: | + python3 -m venv engine/.venv + engine/.venv/bin/pip install --require-hashes -r engine/requirements.lock + + - name: Generate synthetic fixtures + run: engine/.venv/bin/python spike/run_spike.py + + - name: Install app dependencies + working-directory: app + run: npm ci + + # Run the real Electron checks with a display and the sandbox enabled. + # Source coverage only: Linux packaging has a separate verification gap. + - name: Verify application with a virtual display + working-directory: app + run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npm run verify + + - name: Reject a recovery launch without a display + working-directory: app + run: node scripts/recovery-headless-check.mjs + + - name: Cross-viewer conformance + working-directory: app + run: npm run verify:viewers + + - name: Upload failure evidence + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-evidence + retention-days: 3 + if-no-files-found: warn + path: | + spike/out/*.png + spike/out/recovery-headless.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1881b4..671b51c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,23 @@ Add `engine/requirements-build.lock` on top of either if you intend to run `npm run package:dir` — it carries PyInstaller. The `run_spike.py` line builds the gitignored fixtures that `npm run verify` checks against. +Linux pull requests run the source suite on Ubuntu 22.04 with Node 22 and Python +3.12. Xvfb provides a virtual display for the real Electron checks; the job also +runs cross-viewer conformance with poppler and pdfium. This job does not verify +Linux packaging or replace a manual check on your desktop distribution. + +After `npm run verify` has generated the roundtrip binder, run the Linux-only +negative check from `app/`: + +```bash +node scripts/recovery-headless-check.mjs +``` + +It removes the child process's display variables and requires both recovery +launches to fail. Recovery success requires reaching the scripted prompt and +capturing a fresh rendered window, not just a zero launcher exit code. The +negative check saves its output in `spike/out/recovery-headless.log`. + Architecture notes live in `DATA-FLOW.md`; scope and non-goals live in the README. Security defects belong in the private channel described in `SECURITY.md`. diff --git a/README.md b/README.md index 8325537..5403521 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,9 @@ Two other ways to run it, in order of effort: ticked — the installer is attached only on a manual dispatch, deliberately, because building installers for every pull request burns runner minutes and uploading a ~140 MB installer each time burns storage. Pull requests still - run the complete source and Electron smoke suites on Windows and macOS; - packaging and installed-app verification are release-candidate gates. + run the complete source and Electron smoke suites on Windows, macOS and + Linux; Windows and macOS packaging and installed-app verification are + release-candidate gates. Linux CI covers source builds only. Artifacts expire after 3 days and are **unsigned: for pilot testing, not for redistribution.** 2. **From source**, below. Works on macOS, Windows and Linux and takes about diff --git a/app/scripts/recovery-check.mjs b/app/scripts/recovery-check.mjs index d684494..077f3ad 100644 --- a/app/scripts/recovery-check.mjs +++ b/app/scripts/recovery-check.mjs @@ -121,6 +121,7 @@ function runApp(binder, response, label, exportTo) { let stdout = '' let stderr = '' let settled = false + let timedOut = false child.stdout.on('data', (data) => (stdout += data)) child.stderr.on('data', (data) => (stderr += data)) const finish = (result) => { @@ -129,17 +130,36 @@ function runApp(binder, response, label, exportTo) { resolve(result) } const timer = setTimeout(() => { + timedOut = true void stopApp(child).then(() => finish({ code: null, stdout, stderr: `${stderr}\n${label} timed out` }) ) }, 120_000) child.on('close', (code) => { clearTimeout(timer) - finish({ code, stdout, stderr }) + if (!timedOut) finish({ code, stdout, stderr }) + }) + child.on('error', (error) => { + clearTimeout(timer) + finish({ code: null, stdout, stderr: `${stderr}\n${label}: ${error.message}` }) }) }) } +function launchCompleted(run, response) { + const output = `${run.stdout}\n${run.stderr}` + const reachedPrompt = output.split(/\r?\n/).includes(`[dev] recovery choice: ${response}`) + // runApp deletes the screenshot before each launch. A fresh capture proves + // the renderer finished the reopen flow, including the Cancel path. + const rendered = existsSync(SHOT) + return { + ok: run.code === 0 && reachedPrompt && rendered, + detail: + `exit=${run.code}; recovery prompt reached=${reachedPrompt}; window rendered=${rendered}` + + (run.code === 0 && reachedPrompt && rendered ? '' : `\n${output.trim()}`) + } +} + const opened = await engine({ cmd: 'open_binder', path: SOURCE_BINDER }) if (!opened.ok || !opened.binder?.session) { console.error(`could not read roundtrip fixture: ${opened.error ?? 'no embedded session'}`) @@ -168,11 +188,8 @@ const recoveredRun = await runApp( 'recovery launch', RECOVERED ) -check( - 'recovery launch exits cleanly', - recoveredRun.code === 0, - recoveredRun.stderr.trim() || recoveredRun.stdout.trim() -) +const recoveredLaunch = launchCompleted(recoveredRun, 'recover') +check('recovery launch reaches the prompt and renders', recoveredLaunch.ok, recoveredLaunch.detail) check('recovered binder is exported', existsSync(RECOVERED), RECOVERED) if (existsSync(RECOVERED)) { const recoveredBinder = await engine({ cmd: 'open_binder', path: RECOVERED }) @@ -182,15 +199,12 @@ if (existsSync(RECOVERED)) { seedRecovery(CANCEL_BINDER, opened.binder.session) const canceledRun = await runApp(CANCEL_BINDER, 'cancel', 'cancel launch') -check( - 'cancel launch exits cleanly', - canceledRun.code === 0, - canceledRun.stderr.trim() || canceledRun.stdout.trim() -) +const canceledLaunch = launchCompleted(canceledRun, 'cancel') +check('cancel launch reaches the prompt and renders', canceledLaunch.ok, canceledLaunch.detail) const canceledRecovery = recoveryPathFor(CANCEL_BINDER) check( 'Cancel preserves the recovery sibling', - existsSync(canceledRecovery), + canceledLaunch.ok && existsSync(canceledRecovery), canceledRecovery ) diff --git a/app/scripts/recovery-headless-check.mjs b/app/scripts/recovery-headless-check.mjs new file mode 100644 index 0000000..8a02395 --- /dev/null +++ b/app/scripts/recovery-headless-check.mjs @@ -0,0 +1,33 @@ +/** Linux regression: a launcher exit code is not proof that Electron started. */ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +assert.equal(process.platform, 'linux', 'this regression check requires Linux') +const env = { ...process.env } +delete env.DISPLAY +delete env.WAYLAND_DISPLAY +const result = spawnSync(process.execPath, ['scripts/recovery-check.mjs'], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + env, + encoding: 'utf8', + timeout: 360_000, + maxBuffer: 8 * 1024 * 1024 +}) +const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}` +writeFileSync(new URL('../../spike/out/recovery-headless.log', import.meta.url), output) +assert.ifError(result.error) +assert.equal(result.status, 1, `the suite must fail without a display:\n${output}`) +for (const label of ['recovery', 'cancel']) { + assert.ok( + output.includes(`[FAIL] ${label} launch reaches the prompt and renders`), + `${label} must report a failed launch:\n${output}` + ) +} +assert.ok( + output.includes('[FAIL] Cancel preserves the recovery sibling'), + `Cancel must not pass when the app never started:\n${output}` +) +assert.doesNotMatch(output, /\[PASS\]/, 'no recovery assertion may pass without the app') +console.log('[PASS] missing display fails both launches and cannot pass Cancel') diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 6238137..4091505 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -1188,8 +1188,12 @@ function registerIpc(): void { // Focused UI checks can choose without automating a native dialog. This // seam is development-only and never exists in a packaged release. const scripted = isDev ? process.env.WPT_DEV_RECOVERY_RESPONSE : undefined - if (scripted === 'cancel' || scripted === 'saved') return scripted - if (scripted === 'recover' && canRecover) return scripted + if (scripted === 'cancel' || scripted === 'saved' || (scripted === 'recover' && canRecover)) { + // The harness must prove the renderer reached this prompt. The dev + // launcher can exit 0 even when Electron could not start at all. + console.log(`[dev] recovery choice: ${scripted}`) + return scripted + } if (!canRecover) { const result = await dialog.showMessageBox({