diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747ec5e10..a7973b710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,9 +285,12 @@ jobs: # and never launch a Playwright browser, so no `playwright # install`. The runner image ships Electron's shared libraries and # xvfb; fail fast with a clear message if an image update drops one. + # pnpm skips Electron's postinstall, so download the binary first; + # otherwise ldd sees no file and the check passes vacuously. - name: Check Electron runtime dependencies run: | command -v xvfb-run || { echo "::error::xvfb-run is missing on the runner"; exit 1; } + node tools/testing/ensure-electron-binary.mjs || { echo "::error::Electron binary download failed"; exit 1; } missing="$(ldd node_modules/electron/dist/electron | grep 'not found' || true)" if [ -n "$missing" ]; then echo "::error::Electron is missing shared libraries:" @@ -368,6 +371,15 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # pnpm skips Electron's postinstall (it is not in + # onlyBuiltDependencies), so the binary is fetched on the first + # require('electron'). Specs that run SQLite under + # ELECTRON_RUN_AS_NODE resolve it that way while Tier A projects + # run concurrently, and one exec'ing it mid-extraction fails with + # ETXTBSY. Download and verify it once, before any test starts. + - name: Download Electron binary + run: node tools/testing/ensure-electron-binary.mjs + - name: Validate agent guidance run: pnpm run agents:validate diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index ff50576c9..5a9a5f9e6 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -107,6 +107,18 @@ with `diagnostics: false`); `isolatedModules`-incompatible syntax such as a type re-export without `export type` still fails at load time, and spec type errors are caught by `typecheck:spec` (see Unit And Type Checks). +Some `electron-backend` and `database` specs run SQLite code in the Electron +binary under `ELECTRON_RUN_AS_NODE`, resolving it with `require('electron')`. +pnpm does not run Electron's postinstall (`electron` is deliberately absent from +`onlyBuiltDependencies`, see [workspace shell](workspace-shell.md)), so the +first `require` downloads and extracts the binary. With projects and Jest +workers in parallel, one process can exec it while another is still extracting +it (`spawnSync … ETXTBSY` on Linux). `coverage:unit:ci` and a CI step right +after `pnpm install` therefore run `tools/testing/ensure-electron-binary.mjs` +first, which downloads the binary once and fails unless it runs and reports +the pinned version. In a fresh worktree, run it before starting several of +these specs at once by other means. + In CI, a pull request skips the Tier A suite (and the merged-coverage upload) when every changed file is outside Tier A test inputs: `tools/coverage/unit-coverage-scope.mjs` holds the allowlist (Markdown, `docs/`, diff --git a/package.json b/package.json index c5c2fb931..e25fad65c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs tools/coverage/unit-coverage-scope.test.mjs", - "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", + "coverage:unit:ci": "node tools/testing/ensure-electron-binary.mjs && node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", "coverage:policy:check": "node tools/coverage/check-coverage-policy.mjs", diff --git a/tools/testing/ensure-electron-binary.mjs b/tools/testing/ensure-electron-binary.mjs new file mode 100644 index 000000000..1f0946d34 --- /dev/null +++ b/tools/testing/ensure-electron-binary.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +// Downloads the Electron binary once and proves it runs, before anything +// spawns it concurrently. +// +// pnpm does not run Electron's postinstall (it is not in +// `onlyBuiltDependencies`), so `pnpm install` leaves no binary behind and +// `require('electron')` downloads and extracts it on first use. Unit specs +// that execute SQLite code under `ELECTRON_RUN_AS_NODE` resolve it that way, +// and Tier A projects and Jest workers run in parallel: one process can exec +// the binary while another is still extracting it (ETXTBSY on Linux, clobbered +// framework symlinks on macOS). Running this first leaves nothing to download. +// +// Usage: node tools/testing/ensure-electron-binary.mjs + +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import process from 'node:process'; + +const require = createRequire(import.meta.url); +const { version } = require('electron/package.json'); + +let electronPath; +try { + electronPath = require('electron'); +} catch (error) { + console.error( + `Electron ${version} binary download failed: ${error.message}` + ); + process.exit(1); +} + +let reported; +try { + reported = execFileSync( + electronPath, + ['-e', 'process.stdout.write(process.versions.electron)'], + { + encoding: 'utf8', + // The child's stderr (e.g. a dyld or loader error) goes straight + // to the log instead of being repeated in the message below. + stdio: ['ignore', 'pipe', 'inherit'], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeout: 60_000, + } + ).trim(); +} catch (error) { + console.error( + `Electron binary at ${electronPath} does not run (${error.code ?? `exit ${error.status}`}). ` + + 'If an interrupted download left a partial copy, delete node_modules/electron/dist and path.txt, then rerun.' + ); + process.exit(1); +} + +if (reported !== version) { + console.error( + `Electron binary at ${electronPath} reports ${reported || 'no version'}, expected ${version}.` + ); + process.exit(1); +} + +console.log(`Electron ${version} binary ready at ${electronPath}`);