From c6f659b50fa8168f14f857fb8336b1f933b56134 Mon Sep 17 00:00:00 2001 From: beheerder Date: Thu, 6 Aug 2026 15:25:14 +0200 Subject: [PATCH 1/2] fix(linux): make the Linux build work on arm64 `npm run build:linux` could not complete on an aarch64 host. Four independent x86-only assumptions, each fixed here: - fetch-ffmpeg: SHARED_PINNED had no `linux-arm64` entry, so the SDK fetch bailed out. BtbN publishes the asset in the pinned release; only the pin was missing. Digest taken from its checksums.sha256. - build-linux-compositor-addon: `/usr/lib/x86_64-linux-gnu` and `/usr/lib/gcc/x86_64-linux-gnu` were hardcoded, hiding both libclang and gcc's stddef.h on arm64. Derived from `process.arch` instead. - build-linux-pipewire-helper: bindgen failed with "'limits.h' file not found" because Ubuntu ships libclang.so.1 without a resource dir. Now passes BINDGEN_EXTRA_CLANG_ARGS at gcc's includes, the same fallback build-linux-compositor-addon already applied. - audio.rs / ffmpeg.rs: `[0i8; 256]` passed to av_strerror's `*mut c_char`. `c_char` is signed on x86_64 but unsigned on aarch64, so this is a hard type error there. Use `c_char`. No behaviour change on x86_64: the ffmpeg pin is additive, the two path constants resolve to their previous values, and `c_char` *is* `i8` there. Verified by building and packaging on aarch64 (Ubuntu 24.04): the deb reports `Architecture: arm64`, and the app boots and renders under Xvfb. One caveat worth flagging separately: BtbN's linuxarm64 ffmpeg ships no h264_vaapi, so `every_ladder_entry_names_a_codec_this_build_has` fails on arm64. The encoder ladder probes and falls through at runtime, and h264_nvenc / h264_v4l2m2m / libopenh264 are all present, so this is a gap in the test's assumption rather than a broken export path. Left alone here to keep this PR to the build fix. --- crates/compositor/src/audio.rs | 4 +++- .../native/pipewire-capture/src/ffmpeg.rs | 4 +++- scripts/build-linux-compositor-addon.mjs | 11 +++++++-- scripts/build-linux-pipewire-helper.mjs | 24 +++++++++++++++++++ scripts/fetch-ffmpeg.mjs | 4 ++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 3c37326f..4def01ea 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -35,7 +35,9 @@ extern "C" { fn averr(ret: i32, ctx: &str) -> Result<()> { if ret < 0 { - let mut buf = [0i8; 256]; + // NOT `[0i8; 256]`: `c_char` is signed on x86_64 but UNSIGNED on aarch64, so a + // hardcoded i8 makes av_strerror's `*mut c_char` a type error on arm64. + let mut buf = [0 as ::std::os::raw::c_char; 256]; unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); bail!("{ctx}: {ret} ({msg})"); diff --git a/electron/native/pipewire-capture/src/ffmpeg.rs b/electron/native/pipewire-capture/src/ffmpeg.rs index 1a710341..daeb967a 100644 --- a/electron/native/pipewire-capture/src/ffmpeg.rs +++ b/electron/native/pipewire-capture/src/ffmpeg.rs @@ -49,7 +49,9 @@ pub const AVIO_FLAG_WRITE: i32 = 2; /// Formats an ffmpeg return code the way `av_err2str` would, since that too is a /// macro and needs a caller-supplied buffer. pub fn err_to_string(code: i32) -> String { - let mut buffer = [0i8; 256]; + // NOT `[0i8; 256]`: `c_char` is signed on x86_64 but UNSIGNED on aarch64, so a + // hardcoded i8 makes av_strerror's `*mut c_char` a type error on arm64. + let mut buffer = [0 as ::std::os::raw::c_char; 256]; // SAFETY: the buffer is the size we tell av_strerror it is, and ffmpeg // always NUL-terminates within it. let ok = unsafe { av_strerror(code, buffer.as_mut_ptr(), buffer.len()) } == 0; diff --git a/scripts/build-linux-compositor-addon.mjs b/scripts/build-linux-compositor-addon.mjs index aa0f82a5..6485b4a0 100644 --- a/scripts/build-linux-compositor-addon.mjs +++ b/scripts/build-linux-compositor-addon.mjs @@ -79,6 +79,13 @@ function resolveFfmpegDir() { ); } +/** + * Debian/Ubuntu multiarch triplet for the host. Hardcoding the x86_64 one made + * both libclang and gcc's stddef.h invisible on arm64, where the same libraries + * live under /usr/lib/aarch64-linux-gnu. + */ +const MULTIARCH = process.arch === "arm64" ? "aarch64-linux-gnu" : "x86_64-linux-gnu"; + /** * bindgen loads libclang at runtime. crates/.cargo/config.toml hardcodes a * Windows LLVM path, so on Linux we locate it ourselves rather than making @@ -88,7 +95,7 @@ function resolveLibclangDir() { if (process.env.LIBCLANG_PATH) { return process.env.LIBCLANG_PATH; } - const roots = ["/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib"]; + const roots = [`/usr/lib/${MULTIARCH}`, "/usr/lib64", "/usr/lib"]; for (const llvmRoot of ["/usr/lib"]) { if (!fs.existsSync(llvmRoot)) continue; for (const entry of fs.readdirSync(llvmRoot)) { @@ -120,7 +127,7 @@ function bindgenClangArgs() { if (process.env.BINDGEN_EXTRA_CLANG_ARGS) { return process.env.BINDGEN_EXTRA_CLANG_ARGS; } - const gccIncludeRoot = "/usr/lib/gcc/x86_64-linux-gnu"; + const gccIncludeRoot = `/usr/lib/gcc/${MULTIARCH}`; if (!fs.existsSync(gccIncludeRoot)) { return ""; } diff --git a/scripts/build-linux-pipewire-helper.mjs b/scripts/build-linux-pipewire-helper.mjs index d761db6f..54b8259f 100644 --- a/scripts/build-linux-pipewire-helper.mjs +++ b/scripts/build-linux-pipewire-helper.mjs @@ -63,9 +63,33 @@ if (cargoVersion.status !== 0) { process.exit(1); } +/** + * build.rs runs bindgen over the ffmpeg headers, and bindgen needs clang's own + * builtin includes (limits.h, stddef.h). Ubuntu ships libclang.so.1 without the + * matching resource dir, so clang finds neither and the build dies with + * "'limits.h' file not found". Point it at gcc's copies instead — the same + * fallback scripts/build-linux-compositor-addon.mjs already applies. + */ +function bindgenClangArgs() { + if (process.env.BINDGEN_EXTRA_CLANG_ARGS) { + return process.env.BINDGEN_EXTRA_CLANG_ARGS; + } + const multiarch = process.arch === "arm64" ? "aarch64-linux-gnu" : "x86_64-linux-gnu"; + const gccIncludeRoot = `/usr/lib/gcc/${multiarch}`; + if (!fs.existsSync(gccIncludeRoot)) { + return ""; + } + const withStddef = fs + .readdirSync(gccIncludeRoot) + .map((version) => path.join(gccIncludeRoot, version, "include")) + .filter((dir) => fs.existsSync(path.join(dir, "stddef.h"))); + return withStddef.length > 0 ? `-I${withStddef[0]}` : ""; +} + const build = spawnSync("cargo", ["build", "--release", "--manifest-path", manifest], { cwd: crateDir, stdio: "inherit", + env: { ...process.env, BINDGEN_EXTRA_CLANG_ARGS: bindgenClangArgs() }, }); if (build.error) { console.error(`Failed to start cargo: ${build.error.message}`); diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 331457da..ad8ba8f8 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -125,6 +125,10 @@ const SHARED_PINNED = { asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-linux64-lgpl-shared-8.1.tar.xz", sha256: "c882a80f06617149198a98a07a0880a7e881953ae9f9cb931f5be09a4f93caae", }, + "linux-arm64": { + asset: "ffmpeg-n8.1.2-32-gcfa62de001-linuxarm64-lgpl-shared-8.1.tar.xz", + sha256: "d1d632deac102b865d43ef3c1eca0d0f8c0df148b784327d1d381a39159f8285", + }, "win32-x64": { asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-win64-lgpl-shared-8.1.zip", sha256: "c222a490dde4e7059f45495deef6bfb98dbcacc2b43df5b607546252037aa95c", From 4e7961cfe5a233c58191f36b4bdba9b7e398f209 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 8 Aug 2026 23:31:52 +0200 Subject: [PATCH 2/2] fix(linux): re-pin the arm64 ffmpeg asset and fix bindgen's include lookup at its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the arm64 build fix. - fetch-ffmpeg: the linux-arm64 shared entry named n8.1.2-32-gcfa62de001, a build that is not in the release RELEASE_TAG points at. Nothing conflicted — the entry is added just below the lines main had changed — so it merged clean and `npm run build:linux` died on a 404 at step one, on the only arch that reaches it. Re-pinned to n8.1.2-34-g9b6c8969e0 with the digest from that release's checksums.sha256, like its siblings. - fetch-ffmpeg.test.mjs: the guard that was missing. Asserts every pinned asset names ONE ffmpeg build, carries a full sha-256, and is an -lgpl artifact. It fails on the pin above and names the odd entry out. Reads the file as text rather than importing it, which would start a download. `scripts` joins vitest's include glob for it. - pipewire-capture/build.rs: hardcoded /usr/lib/gcc/x86_64-linux-gnu, which is the actual arm64 defect. Derived from CARGO_CFG_TARGET_ARCH instead, matched on the vendor prefix so Arch's x86_64-pc-linux-gnu also resolves while a cross-gcc for the other arch is still refused. Picks the same directory as before on x86_64. - build-linux-pipewire-helper: drops the BINDGEN_EXTRA_CLANG_ARGS wrapper. build.rs already owns that fallback and its doc comment says why — doing it in the script covers only the npm path and leaves a bare `cargo build` broken on arm64, which is the split build.rs exists to prevent. - build-linux-compositor-addon: bindgenClangArgs() filtered on stddef.h while the failure it exists to fix is 'limits.h' file not found, and took whatever readdirSync returned first. Now requires both headers and sorts, matching build.rs. A dir with stddef.h but no limits.h made it export a non-empty BINDGEN_EXTRA_CLANG_ARGS, which suppressed build.rs's own fallback and failed with the very error being fixed. - audio.rs / ffmpeg.rs: std::ffi::c_char rather than the legacy std::os::raw alias, matching encoder.rs. audio.rs's comment in French like the rest of that file. --- crates/compositor/src/audio.rs | 7 +- electron/native/pipewire-capture/build.rs | 18 ++++- .../native/pipewire-capture/src/ffmpeg.rs | 2 +- scripts/build-linux-compositor-addon.mjs | 19 ++++- scripts/build-linux-pipewire-helper.mjs | 30 ++------ scripts/fetch-ffmpeg.mjs | 4 +- scripts/fetch-ffmpeg.test.mjs | 71 +++++++++++++++++++ vitest.config.ts | 2 +- 8 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 scripts/fetch-ffmpeg.test.mjs diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 4def01ea..1b51a7a9 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -35,9 +35,10 @@ extern "C" { fn averr(ret: i32, ctx: &str) -> Result<()> { if ret < 0 { - // NOT `[0i8; 256]`: `c_char` is signed on x86_64 but UNSIGNED on aarch64, so a - // hardcoded i8 makes av_strerror's `*mut c_char` a type error on arm64. - let mut buf = [0 as ::std::os::raw::c_char; 256]; + // Surtout pas `[0i8; 256]` : `c_char` est signé sur x86_64 mais NON SIGNÉ sur + // aarch64, donc un i8 en dur fait du `*mut c_char` d'av_strerror une erreur de + // type sur arm64. + let mut buf = [0 as std::ffi::c_char; 256]; unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); bail!("{ctx}: {ret} ({msg})"); diff --git a/electron/native/pipewire-capture/build.rs b/electron/native/pipewire-capture/build.rs index d9aa3366..0190e818 100644 --- a/electron/native/pipewire-capture/build.rs +++ b/electron/native/pipewire-capture/build.rs @@ -73,11 +73,23 @@ fn freestanding_header_args() -> Vec { return Vec::new(); } } - let gcc_root = Path::new("/usr/lib/gcc/x86_64-linux-gnu"); - let Ok(entries) = std::fs::read_dir(gcc_root) else { + // The vendor triplet is NOT hardcoded. It is `x86_64-linux-gnu` on Debian/Ubuntu + // amd64 but `aarch64-linux-gnu` on arm64 — hardcoding the former is why bindgen + // died with "'limits.h' file not found" there — and neither on Arch + // (`x86_64-pc-linux-gnu`). Matching on the target arch prefix covers all of them, + // and on a box with a cross-gcc installed it still refuses the OTHER + // architecture's headers, whose type widths would be wrong. + let arch = std::env::var("CARGO_CFG_TARGET_ARCH") + .unwrap_or_else(|_| std::env::consts::ARCH.to_string()); + let prefix = format!("{arch}-"); + let Ok(vendors) = std::fs::read_dir("/usr/lib/gcc") else { return Vec::new(); }; - let mut dirs: Vec = entries + let mut dirs: Vec = vendors + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + .filter_map(|vendor| std::fs::read_dir(vendor.path()).ok()) + .flatten() .filter_map(|entry| entry.ok()) .map(|entry| entry.path().join("include")) .filter(|dir| dir.join("limits.h").is_file() && dir.join("stddef.h").is_file()) diff --git a/electron/native/pipewire-capture/src/ffmpeg.rs b/electron/native/pipewire-capture/src/ffmpeg.rs index daeb967a..ded81cb8 100644 --- a/electron/native/pipewire-capture/src/ffmpeg.rs +++ b/electron/native/pipewire-capture/src/ffmpeg.rs @@ -51,7 +51,7 @@ pub const AVIO_FLAG_WRITE: i32 = 2; pub fn err_to_string(code: i32) -> String { // NOT `[0i8; 256]`: `c_char` is signed on x86_64 but UNSIGNED on aarch64, so a // hardcoded i8 makes av_strerror's `*mut c_char` a type error on arm64. - let mut buffer = [0 as ::std::os::raw::c_char; 256]; + let mut buffer = [0 as std::ffi::c_char; 256]; // SAFETY: the buffer is the size we tell av_strerror it is, and ffmpeg // always NUL-terminates within it. let ok = unsafe { av_strerror(code, buffer.as_mut_ptr(), buffer.len()) } == 0; diff --git a/scripts/build-linux-compositor-addon.mjs b/scripts/build-linux-compositor-addon.mjs index 6485b4a0..557ce868 100644 --- a/scripts/build-linux-compositor-addon.mjs +++ b/scripts/build-linux-compositor-addon.mjs @@ -122,6 +122,14 @@ function resolveLibclangDir() { * On distributions that ship only `libclang.so.1` (no -dev package) clang also * cannot find its own `stddef.h`. Point it at gcc's copy in that case rather * than making the caller discover it. + * + * Kept behaviourally identical to `freestanding_header_args()` in + * electron/native/pipewire-capture/build.rs, which is the authority — that one + * runs for a bare `cargo build` too. Two properties matter and both were once + * wrong here: `limits.h` must be required alongside `stddef.h` (the failure + * being fixed is `'limits.h' file not found` — glibc's copy `#include_next`s + * the compiler's), and the newest gcc must be picked deterministically rather + * than whatever `readdirSync` happened to return first. */ function bindgenClangArgs() { if (process.env.BINDGEN_EXTRA_CLANG_ARGS) { @@ -131,11 +139,16 @@ function bindgenClangArgs() { if (!fs.existsSync(gccIncludeRoot)) { return ""; } - const withStddef = fs + const usable = fs .readdirSync(gccIncludeRoot) .map((version) => path.join(gccIncludeRoot, version, "include")) - .filter((dir) => fs.existsSync(path.join(dir, "stddef.h"))); - return withStddef.length > 0 ? `-I${withStddef[0]}` : ""; + .filter( + (dir) => + fs.existsSync(path.join(dir, "limits.h")) && fs.existsSync(path.join(dir, "stddef.h")), + ) + .sort() + .reverse(); + return usable.length > 0 ? `-I${usable[0]}` : ""; } /** diff --git a/scripts/build-linux-pipewire-helper.mjs b/scripts/build-linux-pipewire-helper.mjs index 54b8259f..ac898c4c 100644 --- a/scripts/build-linux-pipewire-helper.mjs +++ b/scripts/build-linux-pipewire-helper.mjs @@ -63,33 +63,15 @@ if (cargoVersion.status !== 0) { process.exit(1); } -/** - * build.rs runs bindgen over the ffmpeg headers, and bindgen needs clang's own - * builtin includes (limits.h, stddef.h). Ubuntu ships libclang.so.1 without the - * matching resource dir, so clang finds neither and the build dies with - * "'limits.h' file not found". Point it at gcc's copies instead — the same - * fallback scripts/build-linux-compositor-addon.mjs already applies. - */ -function bindgenClangArgs() { - if (process.env.BINDGEN_EXTRA_CLANG_ARGS) { - return process.env.BINDGEN_EXTRA_CLANG_ARGS; - } - const multiarch = process.arch === "arm64" ? "aarch64-linux-gnu" : "x86_64-linux-gnu"; - const gccIncludeRoot = `/usr/lib/gcc/${multiarch}`; - if (!fs.existsSync(gccIncludeRoot)) { - return ""; - } - const withStddef = fs - .readdirSync(gccIncludeRoot) - .map((version) => path.join(gccIncludeRoot, version, "include")) - .filter((dir) => fs.existsSync(path.join(dir, "stddef.h"))); - return withStddef.length > 0 ? `-I${withStddef[0]}` : ""; -} - +// No BINDGEN_EXTRA_CLANG_ARGS wrangling here on purpose. bindgen needs clang's +// freestanding headers (limits.h, stddef.h) and Ubuntu ships libclang.so.1 with +// no resource dir, but the crate's own build.rs already falls back to gcc's +// copies — see freestanding_header_args() there. Doing it here too would only +// cover the build that goes through this script and leave a bare `cargo build` +// broken, which is exactly the split build.rs exists to avoid. const build = spawnSync("cargo", ["build", "--release", "--manifest-path", manifest], { cwd: crateDir, stdio: "inherit", - env: { ...process.env, BINDGEN_EXTRA_CLANG_ARGS: bindgenClangArgs() }, }); if (build.error) { console.error(`Failed to start cargo: ${build.error.message}`); diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index ad8ba8f8..8c2f0ed8 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -126,8 +126,8 @@ const SHARED_PINNED = { sha256: "c882a80f06617149198a98a07a0880a7e881953ae9f9cb931f5be09a4f93caae", }, "linux-arm64": { - asset: "ffmpeg-n8.1.2-32-gcfa62de001-linuxarm64-lgpl-shared-8.1.tar.xz", - sha256: "d1d632deac102b865d43ef3c1eca0d0f8c0df148b784327d1d381a39159f8285", + asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-linuxarm64-lgpl-shared-8.1.tar.xz", + sha256: "eec386482ac6799bb547b5f507dedd19ef6354eee0ca4ddb04bdd053d03c3cfb", }, "win32-x64": { asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-win64-lgpl-shared-8.1.zip", diff --git a/scripts/fetch-ffmpeg.test.mjs b/scripts/fetch-ffmpeg.test.mjs new file mode 100644 index 00000000..9ff869c7 --- /dev/null +++ b/scripts/fetch-ffmpeg.test.mjs @@ -0,0 +1,71 @@ +// The pin table in fetch-ffmpeg.mjs is a supply-chain artifact, and that file says +// so itself: "tag, asset name and digest are one unit. Re-pin all three or none." +// Nothing enforced it. A re-pin that moved some entries and not others merges clean +// — the edits do not even touch the same lines — and then fails only on the arch +// nobody builds in CI, where `npm run build:linux` dies on a 404 from the pinned +// release before it has done anything. +// +// That is not hypothetical: the linux-arm64 shared entry arrived naming +// n8.1.2-32-gcfa62de001 while every sibling had already moved to +// n8.1.2-34-g9b6c8969e0. This test is the guard that was missing. +// +// Read as source text rather than imported: fetch-ffmpeg.mjs calls main() at import +// and would start downloading. The property under test is a property of the literal +// table anyway. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const source = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), "fetch-ffmpeg.mjs"), + "utf8", +); + +// Anchored at the property so prose in the file's (extensive) comments cannot match. +const assets = [...source.matchAll(/^\s*asset:\s*"([^"]+)"/gm)].map((m) => m[1]); +const digests = [...source.matchAll(/^\s*sha256:\s*"([^"]+)"/gm)].map((m) => m[1]); + +/** `ffmpeg-n8.1.2-34-g9b6c8969e0-linuxarm64-lgpl-shared-8.1.tar.xz` -> `n8.1.2-34-g9b6c8969e0`. */ +const buildId = (asset) => asset.match(/-(n\d+(?:\.\d+)+-\d+-g[0-9a-f]+)-/)?.[1]; + +describe("fetch-ffmpeg pins", () => { + // Without this, a reformat that breaks the regexes above would leave every other + // assertion iterating an empty array and passing vacuously. + it("still finds the pin table", () => { + expect(assets.length).toBeGreaterThanOrEqual(8); + expect(digests).toHaveLength(assets.length); + }); + + it("names one single ffmpeg build across every pinned asset", () => { + const byBuild = new Map(); + for (const asset of assets) { + const id = buildId(asset); + // Also the "never an `N-…` master snapshot" rule: those carry no n. + expect(id, `${asset} does not name an n--g release build`).toBeDefined(); + byBuild.set(id, [...(byBuild.get(id) ?? []), asset]); + } + expect( + [...byBuild.keys()], + `The pin table straddles ${byBuild.size} different ffmpeg builds:\n${[...byBuild] + .map(([id, list]) => ` ${id}\n${list.map((a) => ` ${a}`).join("\n")}`) + .join("\n")}\nRe-pin every entry together — RELEASE_TAG, asset and digest are one unit.`, + ).toHaveLength(1); + }); + + // One GPL component relicenses the whole binary, which would contaminate this MIT + // app. assertLgpl() checks the artifact after download; this checks the intent + // before anyone runs the script. + it("pins only -lgpl assets", () => { + for (const asset of assets) { + expect(asset, `${asset} is not an -lgpl asset`).toMatch(/-lgpl(-shared)?-/); + } + }); + + it("pins a full sha-256 for every asset", () => { + for (const digest of digests) { + expect(digest).toMatch(/^[0-9a-f]{64}$/); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d775a6e3..3a6243b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ // docblock the other way round, to escape the global jsdom; that one is now // redundant but harmless.) environment: "node", - include: ["{src,electron,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron,scripts,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], // Vitest's 5s default is too tight here and produces red runs that mean nothing. // Measured: with the machine loaded, 11 tests fail and 9 of them are purely // "Test timed out in 5000ms" — ordinary component tests that pass in 200ms on an