Summary
chef test unit resolves playwright from chef's own (global) node_modules, while chef test e2e runs the project's Playwright. When the two versions expect different browser revisions, e2e passes and unit dies with a missing-browser error — in the same repo, on the same machine, at the same moment.
Where the two strategies diverge
src/modules/engines/test/e2e/playwright/playwright-e2e-strategy.ts hands the run to the project:
const childProcess = spawn("npx", args, {
cwd: options.projectRoot,
...
});
src/modules/engines/test/unit/playwright/playwright-unit-strategy.ts imports Playwright into chef's own process:
const playwright = await import("playwright");
const browserLauncher = playwright[browserType];
Being a bare specifier inside @bitrix/chef/dist/cli.js, that resolves from chef's install directory, never from projectRoot.
This is inconsistent with what the same method already does a few lines above — it locates and reads the project's playwright.config.ts (findPlaywrightConfig(packageRoot, projectRoot)) and requires use.baseURL from it. So the unit strategy already treats the project's Playwright setup as authoritative for configuration, but not for the Playwright it actually launches.
Why the versions drift apart in practice
They are not pinned together, and nothing keeps them in step:
|
spec |
resolved |
chromium revision |
| project |
@playwright/test ^1.58.0, pinned by package-lock.json |
1.61.1 |
1228 |
| chef (global) |
playwright ^1.57.0, no lockfile |
1.62.1 |
1234 |
A global npm install has no lockfile, so chef's caret resolves to whatever is newest on the registry at install time, while the project's caret stays frozen by its lock.
Our repo's postinstall makes it systematic rather than accidental:
"postinstall": "npm install --global @bitrix/chef && npx playwright install"
The first half floats chef — and chef's bundled Playwright — forward. The second half resolves the project's binary (node_modules/.bin/playwright -> ../@playwright/test/cli.js) and therefore downloads browsers only for the project's pinned version. Nothing ever installs browsers for chef's copy, so every npm install widens the gap.
The same asymmetry also makes the failure hard to diagnose: the obvious remedy, npx playwright install chromium, is a no-op here — it re-installs the revision that is already present.
Reproduction
- In a project pinning
@playwright/test to a version whose chromium revision is already installed (here 1.61.1 → 1228).
- Install a chef whose bundled Playwright wants a newer revision (here chef 1.21.0 → playwright 1.62.1 → 1234), without installing browsers for it.
chef test e2e <ext> <spec>.spec.ts — passes.
chef test unit <ext> — fails:
[CF9002] browserType.launch: Executable doesn't exist at
/Users/…/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated. ║
║ Please run the following command to download new browsers: ║
║ npx playwright install ║
╚════════════════════════════════════════════════════════════╝
The suggested command does not help, for the reason above.
Environment: chef 1.21.0, node 22, macOS arm64.
Suggested fix
Resolve playwright from projectRoot first, falling back to chef's bundled copy when the project has none — mirroring what the e2e strategy already achieves by spawning npx in the project directory. createRequire is already used elsewhere in the codebase:
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
async function importPlaywright(projectRoot) {
try {
const require = createRequire(pathToFileURL(path.join(projectRoot, 'package.json')));
return await import(pathToFileURL(require.resolve('playwright')).href);
} catch {
return import('playwright'); // chef's own copy
}
}
That way a project that declares Playwright gets one Playwright, one browser set, and one npx playwright install that covers both runners.
A smaller alternative, if importing the project's copy is undesirable: detect the mismatch and say so. Comparing chef's playwright-core/browsers.json revision against ~/Library/Caches/ms-playwright would let CF9002 explain that the browsers belong to chef's Playwright and name the command that actually installs them, instead of suggesting a npx playwright install that resolves elsewhere.
Workaround for anyone hitting this
Either install browsers for chef's own copy:
"$(npm root -g)/@bitrix/chef/node_modules/.bin/playwright" install chromium
or, without downloading anything, point PLAYWRIGHT_BROWSERS_PATH at a directory that symlinks the wanted revision onto an installed neighbouring one — adjacent revisions are compatible enough for a full run.
Summary
chef test unitresolvesplaywrightfrom chef's own (global)node_modules, whilechef test e2eruns the project's Playwright. When the two versions expect different browser revisions, e2e passes and unit dies with a missing-browser error — in the same repo, on the same machine, at the same moment.Where the two strategies diverge
src/modules/engines/test/e2e/playwright/playwright-e2e-strategy.tshands the run to the project:src/modules/engines/test/unit/playwright/playwright-unit-strategy.tsimports Playwright into chef's own process:Being a bare specifier inside
@bitrix/chef/dist/cli.js, that resolves from chef's install directory, never fromprojectRoot.This is inconsistent with what the same method already does a few lines above — it locates and reads the project's
playwright.config.ts(findPlaywrightConfig(packageRoot, projectRoot)) and requiresuse.baseURLfrom it. So the unit strategy already treats the project's Playwright setup as authoritative for configuration, but not for the Playwright it actually launches.Why the versions drift apart in practice
They are not pinned together, and nothing keeps them in step:
@playwright/test^1.58.0, pinned bypackage-lock.jsonplaywright^1.57.0, no lockfileA global
npm installhas no lockfile, so chef's caret resolves to whatever is newest on the registry at install time, while the project's caret stays frozen by its lock.Our repo's
postinstallmakes it systematic rather than accidental:The first half floats chef — and chef's bundled Playwright — forward. The second half resolves the project's binary (
node_modules/.bin/playwright -> ../@playwright/test/cli.js) and therefore downloads browsers only for the project's pinned version. Nothing ever installs browsers for chef's copy, so everynpm installwidens the gap.The same asymmetry also makes the failure hard to diagnose: the obvious remedy,
npx playwright install chromium, is a no-op here — it re-installs the revision that is already present.Reproduction
@playwright/testto a version whose chromium revision is already installed (here 1.61.1 → 1228).chef test e2e <ext> <spec>.spec.ts— passes.chef test unit <ext>— fails:The suggested command does not help, for the reason above.
Environment: chef 1.21.0, node 22, macOS arm64.
Suggested fix
Resolve
playwrightfromprojectRootfirst, falling back to chef's bundled copy when the project has none — mirroring what the e2e strategy already achieves by spawningnpxin the project directory.createRequireis already used elsewhere in the codebase:That way a project that declares Playwright gets one Playwright, one browser set, and one
npx playwright installthat covers both runners.A smaller alternative, if importing the project's copy is undesirable: detect the mismatch and say so. Comparing chef's
playwright-core/browsers.jsonrevision against~/Library/Caches/ms-playwrightwould let CF9002 explain that the browsers belong to chef's Playwright and name the command that actually installs them, instead of suggesting anpx playwright installthat resolves elsewhere.Workaround for anyone hitting this
Either install browsers for chef's own copy:
"$(npm root -g)/@bitrix/chef/node_modules/.bin/playwright" install chromiumor, without downloading anything, point
PLAYWRIGHT_BROWSERS_PATHat a directory that symlinks the wanted revision onto an installed neighbouring one — adjacent revisions are compatible enough for a full run.