How I fixed the macOS bug that broke every VS Code extension's tests
My own extension's tests started failing on macOS. I chased the wrong cause first, then found someone else had already diagnosed the right one, and shipped the fix myself instead of waiting.
- debugging
- open source
- macos
- vs code
In late July 2026 I was cleaning up some functionality in my VS Code extension, css-js-minifier. Its CI runs the extension's tests against a real VS Code Insiders build on macOS, and that run started failing with an error that reads like a filesystem problem:
Error: spawn /…/Visual Studio Code - Insiders.app/Contents/MacOS/Electron ENOENT
ENOENT means "no such file or directory." The path was right there in the message. The .app bundle had downloaded and unzipped without complaint. And yet the binary at the end of that path did not exist.
The lead that went nowhere
My first guess was Apple's, not Microsoft's. A few days earlier my local Java install had thrown a Rosetta warning, and I'd been reading Apple's notes on Intel-app support winding down on Apple silicon. "Electron" and "a binary that stopped existing on macOS" felt like they could be the same story, Rosetta support narrowing, an Intel-only binary no longer launching.
It wasn't. Nothing in the failure was architecture-specific, and I hadn't actually confirmed the connection. I'd pattern-matched two things that happened to be in my head at the same time. Worth naming, because it's exactly the kind of lead that feels right and costs you an hour before you check it.
The misleading part
The failure itself was partial, which is what actually pointed me somewhere useful.
@vscode/test-electron does two things with a downloaded build: it runs a --version probe to confirm the download is sane, and it launches the app to run your tests. The probe kept passing. Only the launch failed.
That combination rules out a corrupt download or a permissions problem. Both would fail everything. Something that passes one check and fails the next means the two checks are reaching different places. They resolve the path differently. The probe walks relatively:
../../../Contents/Resources/app/bin/code
The launcher builds an absolute path with a filename baked into the source:
path.resolve(appPath, 'Contents', 'MacOS', 'Electron');
One of those depends on a name. The other doesn't. Only the one that depends on a name broke.
Filing the issue, and finding it was already solved
I filed microsoft/vscode-test#348 with the reproducer, scoped to darwin-arm64 + Insiders, since that's the combination that failed on my CI while Stable and every other OS stayed green.
While writing it up I found #349, filed about forty minutes earlier by another user, Aasim Khan. He'd already done the real work: VS Code renamed the macOS main binary from Contents/MacOS/Electron to the product's short name (Code on Stable, Code - Insiders on Insiders) in microsoft/vscode#291948, merged 2026-02-03. The release shipped a compatibility symlink at the old location, so every consumer that had hardcoded Electron, including @vscode/test-electron, kept working without knowing the name it depended on was already gone. Then, on 2026-07-20, microsoft/vscode#326502 removed that symlink. Within a day, the failures started.
| Date | Event |
|---|---|
| 2026-02-03 | Binary renamed on macOS |
| 2026-02 | 1.110 ships with an Electron → product-name symlink |
| 2026-07-20 | Symlink removed |
| ~2026-07-21 | Every consumer spawning the old path starts failing |
| 2026-07-22 | #348 and #349 filed, independently, ~40 minutes apart |
So the breaking change and the break were five months and two pull requests apart, in a different repository from the one reporting the error, and someone else had already connected all of it before I had. Aasim's issue even named the fix direction: read the bundle's real name instead of hardcoding one.
Asking instead of assuming
Diagnosed is not shipped, though, and #349 had been open with no PR against it. So I wrote one.
The fix is to stop guessing the executable's name and ask the bundle what it's called. macOS already stores this: every .app has an Info.plist, and CFBundleExecutable is the field the operating system itself reads when you double-click the icon.
function resolveDarwinAppExecutable(appPath: string): string {
const macosDir = path.resolve(appPath, 'Contents', 'MacOS');
const infoPlistPath = path.resolve(appPath, 'Contents', 'Info.plist');
try {
const plist = readFileSync(infoPlistPath, 'utf-8');
const match = plist.match(/<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/);
if (match) {
return path.resolve(macosDir, match[1]);
}
} catch {
// Fall through to the legacy `Electron` name.
}
return path.resolve(macosDir, 'Electron');
}
Two deliberate choices in something this small.
A regex instead of a plist parser. VS Code ships its Info.plist as XML, which I verified against a stock 1.111 install. A targeted match avoids adding a dependency or shelling out to PlistBuddy, no new packages, no new subprocess, to read one string. If VS Code ever ships a binary plist this fails, and it fails into the fallback below rather than throwing.
The old name stays as the fallback. Any bundle whose plist predates the rename, lacks the key, or can't be opened gets exactly the string the function returned before. That makes the change a strict superset of the old behaviour, which is what lets it ship without a version check or a migration note.
It went in as microsoft/vscode-test#350, with six unit tests covering Stable and Insiders after the rename, both before it, a malformed plist, and non-macOS platforms, and closed both #348 and #349.
What I actually took from it
The bug was easy once located. Locating it, and not stopping at the first plausible story, is the part that generalises.
Pattern-matching isn't diagnosis. "Electron" plus "a binary that vanished on macOS" plus a Rosetta warning I'd seen days earlier felt connected. It wasn't, and I lost time on it because I didn't check before I believed it. The probe-vs-launch split was the first evidence I actually verified, and it's the one that led somewhere.
Search before you diagnose. I filed my own issue before finding that someone had already filed a better one. That's fine. A reproducer with a scoped repro case is useful regardless, but the lesson is to search the tracker with the specific symptom before spending an hour reconstructing a root cause that's sitting in an issue filed forty minutes earlier.
A correct diagnosis with no PR against it is still a bug. Aasim named the right fix direction. Nobody had shipped it. "Someone already figured this out" is not the same as "this is handled". The gap between the two is exactly where I could be useful, and it's a cheap way to contribute to a project you don't otherwise have context in.
A hardcoded name is an undeclared dependency on someone else's decision. 'Electron' looks like a constant. It is actually a value owned by a different team, and the moment you write it into your source you've taken a dependency on it without recording that anywhere. When the platform exposes the real answer, reading it beats guessing it, and the guess gets settled at whatever moment is least convenient. CFBundleExecutable existed the whole time.