mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-22 15:37:02 +00:00
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:
parent
e5aec5ceb2
commit
e72aa77336
3 changed files with 352 additions and 0 deletions
212
.github/workflows/electron-smoke.yml
vendored
Normal file
212
.github/workflows/electron-smoke.yml
vendored
Normal 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
|
||||
|
|
@ -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'",
|
||||
|
|
|
|||
139
tools/verify-electron-requires.js
Normal file
139
tools/verify-electron-requires.js
Normal 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}`,
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue