super-productivity/tools/verify-electron-requires.js
Johannes Millan e72aa77336
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>
2026-04-23 21:53:28 +02:00

139 lines
4.5 KiB
JavaScript

#!/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}`,
);