Add Electron packaging smoke test to catch missing dependencies (#7336)

* ci(electron): verify packaged app.asar and smoke-test launch

Adds coverage for the gap that let #7320 ship: CI built electron TS
and ran electron-builder on release, but never exercised the packaged
binary. A stray relative import reaching out of electron/** was
compiled by tsc into src/app/util/*.js, fell outside the files glob
in electron-builder.yaml, and was missing from app.asar — the main
process crashed on launch for every 18.2.7 user.

Two new checks, both running on ubuntu-latest:

1. tools/verify-electron-requires.js extracts app.asar and re-resolves
   every relative require() under electron/** against the packaged
   tree. Runs on every PR that touches electron/**, electron-builder
   config, package.json, or the workflow itself. ~10s after the build.

2. A launch-under-xvfb smoke test starts the packaged binary, waits
   30s for a crash, and greps stderr for 'Cannot find module' /
   'Uncaught Exception'. Runs on push to master, release tags, and
   manual dispatch (not every PR — needs xvfb setup + idle wait).

Exposed as npm run electron:verify-asar for local use after dist.

* ci(electron): harden smoke workflow and verify script from review

Review feedback on the previous commit surfaced a handful of real
defects. Addressed here:

verify-electron-requires.js
- Guard resolved paths against escaping the extracted asar tree.
  Without this, a relative require with enough `..` climbs above the
  temp dir and Node's resolver hits the host filesystem, masking a
  genuinely missing module behind a stray file on the CI runner.
- Walk .cjs and .mjs files too. electron/simple-store.test.cjs ships
  via the `electron/**/*` glob and was being skipped.
- Move cleanup out of the path where `process.exit(1)` runs inside a
  `try/finally` (synchronous exit does not guarantee `finally`), so
  the temp dir is reliably removed.
- Code comment noting that esbuild-bundled preload.js is opaque to
  this walker — that coverage comes from the launch smoke test.

electron-smoke.yml
- Broaden pull_request paths filter. The previous filter only matched
  electron/**, which is exactly the wrong answer: #7320's root cause
  was a file under src/app/util/ that electron/ imported. Now uses a
  negative list — docs, translations, android/, ios/, other workflows.
- Explicit Xvfb screen geometry (`-screen 0 1280x720x24`) and
  `--disable-dev-shm-usage` on the Electron side. Both known CI
  flake preventers.
- Expanded crash-marker regex: SIGSEGV, Segmentation fault,
  TypeError:, ReferenceError:, FATAL ERROR, Check failed. Also scan
  the log during the liveness loop, not just at the end — catches
  main-process exceptions that get logged without killing the
  process.
- Move the launch log under .tmp/smoke/ so upload-artifact@v7 reliably
  finds it on failure; /tmp paths are fragile across runner images.
- Sweep stray electron worker processes on teardown via pkill.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Johannes Millan 2026-04-23 21:53:28 +02:00 committed by GitHub
parent e5aec5ceb2
commit e72aa77336
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 352 additions and 0 deletions

212
.github/workflows/electron-smoke.yml vendored Normal file
View file

@ -0,0 +1,212 @@
name: Electron Packaging Smoke Test
# Validates the packaged Electron app — not just that sources compile.
#
# Catches bugs where code works at source level (tsc succeeds, `npm start`
# runs fine from loose files) but the production app.asar is missing a
# file that the main process require()s at startup. See #7320 for the
# motivating incident: electron/backup.ts imported from ../src/app/util/
# and the compiled dep was omitted from the electron-builder files glob,
# so every 18.2.7 binary crashed on launch.
#
# PRs run the cheap static check (~10s after the build) — paths filter
# excludes docs, translations, and platform-specific trees but still covers
# src/**, because #7320's root cause lived under src/app/util/. The full
# launch-under-xvfb smoke test runs on merge to master, release tags, and
# manual dispatch — stronger signal but needs xvfb setup + ~30s of wait.
on:
pull_request:
branches: [master, main]
# Trigger broadly — #7320's root cause was a file under src/app/util/
# that electron/ imported, so narrowing to electron/** would have let
# the exact bug this workflow exists to prevent slip through again.
# Translation-only edits and non-code trees are excluded to keep
# unrelated PRs cheap.
paths:
- '**'
- '!docs/**'
- '!**/*.md'
- '!src/assets/i18n/**'
- '!android/**'
- '!ios/**'
- '!.github/ISSUE_TEMPLATE/**'
- '!.github/workflows/**'
- '.github/workflows/electron-smoke.yml'
push:
branches: [master]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
jobs:
linux-packaged-app:
name: Linux Packaged Electron App
runs-on: ubuntu-latest
env:
UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }}
UNSPLASH_CLIENT_ID: ${{ secrets.UNSPLASH_CLIENT_ID }}
steps:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Check out Git repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Work around npm installs from git+https://github.com/johannesjo/J2M.git
- name: Reconfigure git to use HTTP authentication
run: >
git config --global url."https://github.com/".insteadOf
ssh://git@github.com/
- name: Get npm cache directory
id: npm-cache-dir
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node22-
- name: Install npm packages
run: npm i
- run: npm run env
- name: Build frontend (production es6)
run: npm run buildFrontend:prod:es6
env:
NODE_OPTIONS: '--max-old-space-size=4096'
- name: Build electron main process
run: npm run electron:build
- name: Package electron app (linux dir, unpacked)
# --linux dir produces linux-unpacked/ with the real app.asar but
# skips AppImage/deb/snap/rpm — cheap and enough to exercise the
# same packaging pipeline release uses.
run: npx electron-builder --linux dir --publish never
- name: Verify require() targets in app.asar
run: |
ASAR=.tmp/app-builds/linux-unpacked/resources/app.asar
if [ ! -f "$ASAR" ]; then
echo "::error::Packaged app.asar not found at $ASAR"
ls -la .tmp/app-builds/ || true
exit 1
fi
node tools/verify-electron-requires.js "$ASAR"
- name: Install xvfb
if: github.event_name != 'pull_request'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Smoke test — launch packaged app
if: github.event_name != 'pull_request'
run: |
set -uo pipefail
UNPACKED=.tmp/app-builds/linux-unpacked
# afterPack.js renames the real binary to superproductivity-bin and
# installs a shell wrapper at superproductivity. Launch the wrapper
# so the smoke test exercises the exact entry path users hit.
BIN="$UNPACKED/superproductivity"
if [ ! -x "$BIN" ]; then
echo "::error::Expected launcher at $BIN not found or not executable"
ls -la "$UNPACKED" || true
exit 1
fi
# Keep the log inside the workspace so upload-artifact can find it
# when a step fails. /tmp is accessible but fragile across runner
# images and upload-artifact glob evaluation.
mkdir -p .tmp/smoke
LOG=.tmp/smoke/launch.log
: > "$LOG"
echo "Launching $BIN under xvfb (log: $LOG)"
# Electron flags:
# --no-sandbox: chrome-sandbox needs setuid root, not set on runners
# --disable-gpu: no GPU on the runner
# --disable-dev-shm-usage: /dev/shm is small in GH-hosted containers,
# and Chromium/Electron can die with shared-memory errors without it
# Xvfb flags:
# --server-args: explicit screen geometry; Xvfb's 640x480x8 default
# trips some Chromium color-depth assertions
xvfb-run \
--auto-servernum \
--server-args="-screen 0 1280x720x24" \
"$BIN" \
--no-sandbox \
--disable-gpu \
--disable-dev-shm-usage \
> "$LOG" 2>&1 &
WRAPPER_PID=$!
# Wait up to 30s for the app to crash. The #7320 crash surfaces
# within ~100ms — 30s is generous headroom for Angular bootstrap
# in CI. xvfb-run exits when its child exits, so the wrapper PID
# going away is a reliable crash signal. We also scan stderr for
# known failure markers as a belt-and-suspenders backstop: some
# main-process exceptions log without killing the process.
CRASH_RE='(Uncaught Exception|Cannot find module|A JavaScript error occurred in the main process|Segmentation fault|SIGSEGV|SIGABRT|TypeError:|ReferenceError:|FATAL ERROR|Check failed)'
for i in $(seq 1 30); do
if ! kill -0 $WRAPPER_PID 2>/dev/null; then
echo "::error::xvfb-run/Electron exited after ${i}s — likely crash."
echo "----8<---- launch log"
cat "$LOG" || true
echo "---->8----"
exit 1
fi
if grep -E -q "$CRASH_RE" "$LOG" 2>/dev/null; then
echo "::error::Crash marker detected in log after ${i}s:"
grep -E "$CRASH_RE" "$LOG" || true
echo "----8<---- launch log"
cat "$LOG" || true
echo "---->8----"
kill -9 $WRAPPER_PID 2>/dev/null || true
exit 1
fi
sleep 1
done
echo "App still alive after 30s — startup OK. Terminating."
kill $WRAPPER_PID 2>/dev/null || true
# xvfb-run forwards SIGTERM to its child; give them 5s to exit
# cleanly before SIGKILL. Also sweep any stray electron workers.
for i in 1 2 3 4 5; do
if ! kill -0 $WRAPPER_PID 2>/dev/null; then break; fi
sleep 1
done
kill -9 $WRAPPER_PID 2>/dev/null || true
pkill -9 -f "$UNPACKED/superproductivity" 2>/dev/null || true
wait $WRAPPER_PID 2>/dev/null || true
# Final scan for errors that didn't crash the main process but
# would still indicate a broken build.
if grep -E -q "$CRASH_RE" "$LOG" 2>/dev/null; then
echo "::error::Crash marker detected in final log scan:"
grep -E "$CRASH_RE" "$LOG" || true
exit 1
fi
echo "Smoke test passed."
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: electron-smoke-logs
path: |
.tmp/smoke/
.tmp/app-builds/builder-debug.yml
.tmp/app-builds/builder-effective-config.yaml
retention-days: 7
if-no-files-found: ignore

View file

@ -72,6 +72,7 @@
"e2e:docker:all": "docker compose -f docker-compose.e2e.yaml -f docker-compose.yaml -f docker-compose.supersync.yaml up -d app webdav db supersync && ./scripts/wait-for-app.sh && ./scripts/wait-for-webdav.sh && ./scripts/wait-for-supersync.sh && E2E_BASE_URL=http://localhost:${APP_PORT:-4242} npm run e2e:all -- --workers=6; EXIT_CODE=$?; docker compose -f docker-compose.e2e.yaml -f docker-compose.yaml -f docker-compose.supersync.yaml down; exit $EXIT_CODE",
"electron": "NODE_ENV=PROD electron .",
"electron:build": "tsc -p electron/tsconfig.electron.json && node electron/scripts/bundle-preload.js",
"electron:verify-asar": "node tools/verify-electron-requires.js .tmp/app-builds/linux-unpacked/resources/app.asar",
"electron:watch": "tsc -p electron/tsconfig.electron.json --watch",
"electronBuilderOnly": "electron-builder",
"empty": "echo 'EMPTY YEAH'",

View file

@ -0,0 +1,139 @@
#!/usr/bin/env node
/*
* Walks the contents of a packaged app.asar and verifies that every relative
* require() call inside electron/ .js/.cjs/.mjs files resolves to a file that
* was actually packaged. Catches #7320-class bugs where tsc happily compiles
* an import reaching out of the electron tree (e.g. '../src/app/util/foo')
* but the compiled dependency lives outside the files glob in
* electron-builder.yaml and is therefore missing from app.asar at runtime.
*
* Usage:
* node tools/verify-electron-requires.js <path/to/app.asar>
*
* Exits 0 on success, 1 when unresolvable requires are found, 2 on usage
* errors.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const asarPath = process.argv[2];
if (!asarPath) {
console.error('Usage: verify-electron-requires.js <path/to/app.asar>');
process.exit(2);
}
if (!fs.existsSync(asarPath)) {
console.error(`asar not found: ${asarPath}`);
process.exit(2);
}
let asar;
try {
asar = require('@electron/asar');
} catch (err) {
console.error(
'Could not load @electron/asar. Run "npm i" first (it is a transitive of electron-builder).',
);
console.error(err.message);
process.exit(2);
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-verify-asar-'));
const errors = [];
// Note: `electron/preload.js` is an esbuild bundle produced by
// electron/scripts/bundle-preload.js. All relative imports are inlined at
// bundle time, so no require('./...') calls reach this walker — meaning
// preload-side regressions of the #7320 class are NOT caught here. That
// coverage comes from the launch smoke test in electron-smoke.yml.
const SHIPPED_JS_EXT = /\.(c|m)?js$/;
const walkSourceFiles = (root, visit) => {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const full = path.join(root, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
walkSourceFiles(full, visit);
} else if (entry.isFile() && SHIPPED_JS_EXT.test(full)) {
visit(full);
}
}
};
const collectRelativeRequires = (src) => {
const targets = [];
const re = /\brequire\(\s*(['"])(\.[^'"\n]+)\1\s*\)/g;
let m;
while ((m = re.exec(src)) !== null) targets.push(m[2]);
return targets;
};
// Guard: a resolved path must stay inside the extracted asar tree.
// Without this, a relative require with enough `..` climbs above `tmp` and
// Node's resolver happily hits the host filesystem (or the host's
// node_modules), masking a genuinely missing module behind a stray file.
const tmpPrefix = tmp.endsWith(path.sep) ? tmp : tmp + path.sep;
const isInsideTmp = (p) => p === tmp || p.startsWith(tmpPrefix);
let exitCode = 0;
try {
asar.extractAll(asarPath, tmp);
const electronDir = path.join(tmp, 'electron');
if (!fs.existsSync(electronDir)) {
console.error(`No electron/ directory found inside ${asarPath}`);
exitCode = 1;
} else {
walkSourceFiles(electronDir, (file) => {
const src = fs.readFileSync(file, 'utf8');
for (const target of collectRelativeRequires(src)) {
const candidate = path.resolve(path.dirname(file), target);
if (!isInsideTmp(candidate)) {
errors.push(
`${path.relative(tmp, file)}: require('${target}') escapes packaged tree`,
);
continue;
}
try {
const resolved = require.resolve(candidate, {
paths: [path.dirname(file)],
});
if (!isInsideTmp(resolved)) {
errors.push(
`${path.relative(tmp, file)}: require('${target}') resolved to host path ${resolved} — module is missing from app.asar`,
);
}
} catch {
errors.push(
`${path.relative(tmp, file)}: cannot resolve require('${target}')`,
);
}
}
});
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
if (exitCode !== 0) process.exit(exitCode);
if (errors.length) {
console.error(
`Found ${errors.length} unresolvable require() target(s) inside ${asarPath}:\n`,
);
for (const err of errors) console.error(' ' + err);
console.error(
'\nThis means a .ts file under electron/ imported a path that was not packaged.',
);
console.error(
'Fix: move the imported module under electron/shared-with-frontend/ (or another',
);
console.error(
'path covered by files: in electron-builder.yaml) and update importers.',
);
process.exit(1);
}
console.log(
`OK: all relative require() targets under electron/ resolve cleanly in ${asarPath}`,
);