Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/linux.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 26 additions & 12 deletions app/scripts/recovery-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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'}`)
Expand Down Expand Up @@ -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 })
Expand All @@ -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
)

Expand Down
33 changes: 33 additions & 0 deletions app/scripts/recovery-headless-check.mjs
Original file line number Diff line number Diff line change
@@ -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')
8 changes: 6 additions & 2 deletions app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading