A while back I wrote about shipping Playwright's Chromium inside a packaged Electron app: why Notifio bundles a second browser at all, why you have to resolve the executable path yourself, and why the Chromium revision ends up pinned as a string in a path.
This is the follow-up nobody warns you about, because it does not break on the machine where you set it up. It breaks weeks later, on the machine where you clean your caches.
The setup, in one paragraph
A prebuild step copies Chromium out of the Playwright cache into app/playwright-browsers/, and electron-builder bundles that directory into the installer. On macOS we stage both chrome-mac-arm64 and chrome-mac-x64 so one build serves Apple Silicon and Intel. The copy is a one-liner:
fs.cpSync(src, dstSub, { recursive: true });
Which works. The browser launches, the app scrapes, the installer ships. For weeks.
Then the build fails inside a directory it does not use
The failure is an ENOENT during packaging, minutes into a release build, on a path inside ~/Library/Caches/ms-playwright. A directory the build is not supposed to need any more, since the whole point of staging was to stop depending on it.
The cause is that a macOS .framework is not a folder of files. It is a small tree of symlinks:
Versions/Current -> 149.0.7827.55
Resources -> Versions/149.0.7827.55/Resources
Libraries -> Versions/149.0.7827.55/Libraries
Helpers -> Versions/149.0.7827.55/Helpers
Google Chrome for Testing Framework -> Versions/149.0.7827.55/Google Chrome for Testing Framework
Five links per framework, and we stage two architectures, so ten in total. That is what the tree looks like once it is correct. In Playwright's own cache every one of those targets is spelled as an absolute path instead, beginning /Users/you/Library/Caches/ms-playwright/chromium-1228/.... fs.cpSync copies a symlink as a symlink, not as the file it points at, so the staged copy is structurally perfect and functionally a set of pointers back into the cache you were trying to stop needing.
Everything works while the cache is still there. npx playwright install on a new version, a disk cleanup, a fresh checkout on another machine, and the staged copy becomes a shell.
What makes this specifically annoying rather than merely wrong is the timing. The links resolve fine at development time. They resolve fine when you test the packaged app on the machine that built it. It fails at packaging, on a different day, with an error that names a path you had already decided was irrelevant.
The fix is to rewrite the links, not to re-copy
Every target those links point at already exists inside the staged copy. Versions/149.0.7827.55/Resources was copied. The link just names it by the wrong route. So the repair is to walk the staged tree and rewrite each absolute link as the relative path a normal framework would use.
The only real design question is how to map a cache path to its staged equivalent. Keying on the cache directory means knowing what the cache directory is called, which varies by platform and can be overridden by an environment variable. Keying on the revision segment does not:
/**
* The staged equivalent of a cache path.
*
* Keyed on the "chromium-<revision>" segment rather than on the cache location,
* so it works whatever the cache directory is called on this machine.
*/
function toStagedPath(target) {
const parts = target.split(path.sep);
const index = parts.findIndex((p) => /^chromium[-_]/.test(p));
if (index === -1) return null;
return path.join(ROOT, ...parts.slice(index));
}
Everything from chromium-1228/ onwards is identical in both trees, because that is exactly what the copy preserved. Take the tail, glue it onto the staged root, done.
The rewrite itself is unremarkable:
for (const link of links) {
const target = fs.readlinkSync(link);
if (!path.isAbsolute(target)) continue;
const staged = toStagedPath(target);
if (!staged) {
console.warn(`[fix-symlinks] Cannot place target, leaving alone: ${link}`);
continue;
}
if (!fs.existsSync(staged)) {
console.error(`[fix-symlinks] Target missing from the staged copy: ${staged}`);
broken++;
continue;
}
const relative = path.relative(path.dirname(link), staged);
fs.unlinkSync(link);
fs.symlinkSync(relative, link);
fixed++;
}
Three properties that were worth being deliberate about:
It skips links that are already relative. That makes the script idempotent, so it can sit in prebuild and run on every build without a guard around it. A build step you have to remember to run is a build step that breaks for whoever did not know.
A target missing from the staged copy is a hard failure, not a warning:
if (broken > 0) {
console.error(
`[fix-symlinks] ${broken} link(s) point at files that are not staged. ` +
'Re-download the browser: npx playwright install chromium'
);
process.exit(1);
}
That is the same bug as before, just caught seconds into the build with the remedy printed, rather than minutes in with an ENOENT. Moving a failure earlier and giving it a sentence is most of what build tooling is for.
It reports the no-op case out loud: All 10 link(s) already relative. Silence from a repair script is indistinguishable from the script not running.
The sibling bug in the copy step
Fixing that exposed a second assumption in the same pipeline. The copy script guarded like this:
if (!fs.existsSync(srcBase)) {
console.error(`[copy-browsers] Source not found: ${srcBase}`);
process.exit(1);
}
It checked for the cache before checking whether it needed anything from it. The header of that same file has claimed "safe to re-run, skips copying if the destination already exists" since the day it was written, and it was not true: a machine with both browsers fully staged and no cache at all would fail on a check for something it was never going to read.
The fix is to work out what is actually missing first:
const staged = subFolders.filter((sub) => fs.existsSync(path.join(dest, sub)));
const needed = subFolders.filter((sub) => !staged.includes(sub));
if (needed.length === 0) {
console.log('[copy-browsers] All browsers already staged, nothing to copy.');
process.exit(0);
}
if (!fs.existsSync(srcBase)) {
console.error(`[copy-browsers] Missing from ${dest}: ${needed.join(', ')}`);
console.error(`[copy-browsers] Source not found: ${srcBase}`);
console.error('[copy-browsers] Run: npx playwright install chromium');
process.exit(1);
}
Same information, different order, and now the error names what is missing rather than only where it was going to look.
The generalisation
Both bugs are the same mistake with different symptoms: a staged copy that still needs the source is not staged. It is a cache with extra steps, and it will convince you otherwise for as long as the source happens to still exist.
If you vendor anything into a build directory, it is worth asking what the artifact would do if you deleted the source right now. On macOS, with anything containing a .framework, the answer is very likely "break, later, somewhere confusing".
Notifio is the app this build pipeline produces: a desktop monitor that watches rental search pages and tells you the second something new appears, instead of waiting for a portal's email to work its way through a send queue. Installers for macOS and Windows are at notifio.app/download, the per-site detail is at /alerts, and there is a setup walkthrough at /help.
Top comments (0)