From dd28911ee457468813bd171f5b02bd369c8cbdc6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 21:47:03 +0200 Subject: [PATCH] ci(test): download the Electron binary once before unit specs run pnpm skips Electron's postinstall (electron is deliberately not in onlyBuiltDependencies), so the binary is downloaded lazily on the first require('electron'). Since Tier A projects run concurrently (#1701), several electron-backend and database specs resolve and exec it at the same time; one process execs it while another is still extracting it and fails with `spawnSync .../electron ETXTBSY`. tools/testing/ensure-electron-binary.mjs downloads the binary once and fails unless it runs under ELECTRON_RUN_AS_NODE and reports the pinned version. The unit job runs it right after pnpm install, coverage:unit:ci runs it first for local runs, and the performance journeys job runs it before its ldd check, which passed vacuously while no binary existed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 12 +++++ docs/architecture/validation-map.md | 12 +++++ package.json | 2 +- tools/testing/ensure-electron-binary.mjs | 62 ++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tools/testing/ensure-electron-binary.mjs 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}`);