Two flags are common in extension-automation examples:
--disable-extensions-except=/path/to/ext --load-extension=/path/to/ext
Older recipes point Puppeteer or Playwright at an installed Chrome and pass both flags.
In the stock Google Chrome 150 build tested here, the browser started without a command-line error but the extension service ignored both flags. Automation connected normally and the extension was absent; the delayed symptom was a selector timeout in this harness.
Chrome logged the refusal in one line, but only after stderr logging was enabled.
The primary finding is the browser-build comparison. Two later sections are explicitly harness notes: one covers extension-triggered downloads and the other covers this command-line installation path on file:// fixtures. Thunderbit is itself a Chrome extension, so we have a direct interest in this problem; Thunderbit was not the test target.
The line
Add --enable-logging=stderr and launch stock Chrome with those flags:
Official reference: Chromium extension service source.
WARNING:chrome/browser/extensions/extension_service.cc:442]
--disable-extensions-except is not allowed in Google Chrome, ignoring.
Pass --load-extension on its own and you get its own warning, from a different line of the same file:
WARNING:chrome/browser/extensions/extension_service.cc:420]
--load-extension is not allowed in Google Chrome, ignoring.
Chrome for Testing, given identical flags, prints neither.
"Not allowed in Google Chrome." The warning makes a branded-build rule the leading interpretation for this observed refusal. It proves that this stock Google Chrome 150 build ignored the flag and identifies the source location. It does not independently prove that version is irrelevant or expose the implementation discriminator.
Everything below is confirmation and consequences.
Confirming it behaviourally
A minimal MV3 extension, built for this test rather than downloaded, uses a content script, popup, message round trip, DOM extraction, and chrome.downloads export against a local three-product fixture. The harness records six numbered checks: service worker registered; extension marker carries an ID; content-script marker matches; popup button is visible; popup returns three rows; captured CSV contains the expected row. A separate agreement assertion compares the independent service-worker and page-marker signals.
Three builds used the same explicit extension flags, extension directory, HTTP fixture, headed persistent-context mode, and fresh per-arm profile. The stock arm resolved through channel: 'chrome'; the two passing arms used explicit executable paths. Version strings were read back over CDP.
| Build | Version reported by the browser | Extension service worker | Content script injected | Full six-check run |
|---|---|---|---|---|
| Chrome for Testing | Chrome/149.0.7827.55 | âś… | âś… | PASS 6/6 |
| Stock Google Chrome | Chrome/150.0.7871.187 | ❌ never registered | ❌ | FAIL at step 0 |
| Chrome for Testing | Chrome/151.0.7922.10 | âś… | âś… | PASS 6/6 |
Raw summaries: Chrome for Testing 149, stock Chrome 150, Chrome for Testing 151, and stderr warnings.
Chrome for Testing 149 is included deliberately: it is older than the stock build it's being compared against. If the capability had been removed by a version bump, an older build should sit on the working side of the line and the failing build should be the newest. Instead the failing build sits between two working ones, which rules out a simple version progression.
That table alone does not prove the gate is build-based, and it's worth being exact about why. The one failing cell is simultaneously the only stock cell and the only 150 cell — build and version are still perfectly confounded in this design. Three arms rule out monotonic removal; they cannot rule out "regressed in 150, restored in 151." Getting that from behaviour alone would need a cell this machine can't produce: Chrome for Testing 150, or a branded build at some other version.
The warning strengthens the branded-build interpretation because it explicitly names Google Chrome, while the three-arm behavior rules out only a simple monotonic removal. A same-version cross-build comparison or source/config citation would still be needed to prove a version-independent mechanism.
The linked per-build artifacts show the summarized run for each arm; this article does not publish a run-by-run matrix for the additional launch count, so it does not use that count as independent evidence.
One signal is not enough to say "it didn't load"
The first version of this test decided whether the extension had loaded by looking for a marker that the content script writes into the page — and that is also how it decided whether the content script had run. One reading, used as two measurements. If the marker is missing you cannot tell "the extension never loaded" from "it loaded and its content script didn't inject," and those have completely different fixes.
MV3 extensions run a background service worker, and Playwright exposes service workers directly. That's an independent signal: it never touches the page, so it cannot be confounded with injection. The current test reads both and checks that they agree.
In all three runs they agree. On stock Chrome no service worker was ever registered — the strong form of the claim. On both Chrome for Testing builds the worker came up at chrome-extension://<id>/background.js before the page was ever opened.
Use Chrome for Testing, which you may already have
The two passing arms used explicit Chrome for Testing executables reporting versions 149.0.7827.55 and 151.0.7922.10. Point Playwright's executablePath at the pinned executable rather than resolving stock Chrome through channel: 'chrome'. npx playwright install chromium installs a Playwright-managed Chromium build; that can also be useful for automation, but it is not the same distribution label as the two Chrome for Testing arms and was not a fourth arm in this comparison.
Official reference: Chrome for Testing announcement.
Related review: Chrome extension permissions audit.
There's a side benefit that outlasts this particular breakage. Stock Chrome auto-updates underneath you, so a suite that passes today can fail on Tuesday for reasons no commit explains. Chrome for Testing is pinned. For anything whose result should still mean something in three months, that matters more than convenience.
Harness note: capturing the extension export
For a scraping extension the export is the whole point — it's where fields get dropped, encodings get mangled, and nested data gets flattened wrong. Two things about it are undocumented and both bite.
| What the export run recorded | Value |
|---|---|
playwright_download_event_fired | false |
suggestedFilename | null |
| Filename the extension asks for | probe-export.csv |
| Filename that lands in the directory | download.csv |
| File contents | the header and all three rows survive intact |
In this MV3/Playwright 1.56.0 harness, chrome.downloads did not fire Playwright's download event. With the extension exporting a CSV through the API, waitForEvent('download') did not resolve. The harness captured the file by opening a CDP session, setting download behavior explicitly, and reading the output directory:
const cdp = await ctx.newCDPSession(page);
await cdp.send('Browser.setDownloadBehavior', {
behavior: 'allow', downloadPath: DIR, eventsEnabled: true,
});
In the same harness, that CDP capture path did not preserve the requested filename. The header and all three rows survived intact, but probe-export.csv landed as download.csv. This is an observed behavior for the tested browser builds and configuration, not a documented invariant for every extension download. Assert on content separately from filename.
Harness note: file:// behavior for this install path
While verifying this piece, one widely repeated claim turned out to be false, and it had made it into an earlier draft of this very article: that content scripts don't run on file:// pages because extensions lack file access by default.
Measured on both Chrome for Testing builds, loading the extension from the command line and navigating straight to file:///…/fixture/index.html: the content script injects normally. Marker present, extension id correct, both builds. Command-line-loaded unpacked extensions get file access; the "grant file access" toggle people remember applies to a different install path.
Serving fixtures over HTTP is still the better default, because a file:// page is nothing like a real target. But it is a realism argument, not a technical requirement, and the mechanism usually given for it is wrong.
What this doesn't establish
- The gate's implementation is only as deep as the warning string. Chrome says these flags aren't allowed in this build and names the source file. Whether that's driven by build config, policy plumbing, or something else was not read out of the source.
- This is one machine. macOS on arm64, one stock patch version, two Chrome for Testing builds. Chrome moves fast enough that this needs re-checking rather than citing.
- This is only the
--load-extensionpath. Packed.crxinstalls, developer-mode loading, and enterprise allowlist policies were not tested. Nothing here supports "stock Chrome can't run extensions." - The extension is a purpose-built stub. It exercises the mechanics every scraper extension uses, but a real extension is bigger and can fail in ways a stub can't.
What I got wrong on the way here
Worth stating plainly, because both errors are the kind that survive review when the result looks right.
The first version of this write-up said the failure was silent, that no log line existed, and that the mechanism was unknowable — that anyone claiming to know it was guessing. The mechanism was one flag away, and Chrome had been printing it at WARNING severity the whole time. "I couldn't find it" had been written up as "it can't be found."
The second was the file:// claim above: repeated from notes and drafted with a confident mechanism attached before it was tested. One run falsified it.
The pattern is the same in both cases. A plausible statement that nobody would challenge, carried forward because checking felt unnecessary. The fix isn't more caution in the prose, it's a rule about what gets stated: a claim that isn't traceable to a run doesn't ship.
Commands used in the local harness
The versioned probe, extension stub, fixture, and raw summaries are linked here, but this is not yet a standalone public reproduction bundle. The exact Chrome for Testing 149 and 151 download sources and checksums are not recorded in the article, and the executable paths below were local inputs. Publish those browser sources plus a stable repository commit before presenting the full comparison as independently reproducible.
Related review: Playwright review.
cd harness
npm install playwright@1.56.0
npx playwright install chromium # Playwright Chromium; not the two CfT arms below
(cd fixture && python3 -m http.server 8731 &)
SP=$(pwd) OUT=cft-149.json LABEL=cft-149 EXE="<Chrome for Testing 149>" node probe_v2.mjs
SP=$(pwd) OUT=stock-150.json LABEL=stock-150 CHANNEL=chrome node probe_v2.mjs
SP=$(pwd) OUT=cft-151.json LABEL=cft-151 EXE="<Chrome for Testing 151>" node probe_v2.mjs
The same script and explicit extension arguments were used for all three arms, but executable resolution differed: CHANNEL=chrome for stock Chrome and EXE for the two Chrome for Testing binaries. The version in each output file is read from the browser rather than trusted from the label.
For the warning line, no harness is needed:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--user-data-dir=/tmp/p --enable-logging=stderr \
--load-extension=/path/to/ext about:blank 2>&1 | grep "not allowed"
As-of 2026-07-28.
Try Thunderbit for Web Data Extraction
Decision and caveats
In the tested matrix, stock Google Chrome 150 refused --disable-extensions-except plus --load-extension, while Chrome for Testing 149 and 151 loaded the extension. Chrome logged the refusal at WARNING severity: "--load-extension is not allowed in Google Chrome, ignoring." The wording supports a branded-build interpretation, and the version sandwich rules out simple monotonic removal, but build and version remain confounded without a same-version cross-build arm or source/config evidence.
For a pinned extension harness, use an explicitly versioned browser executable and verify the service worker plus content-script marker. In this Playwright 1.56.0 setup, the extension export required CDP directory capture and arrived under a different filename. The command-line-loaded stub also injected on the tested file:// fixture; other installation paths were not tested.
Try Thunderbit for Web Data Extraction Get Started Free
FAQs
Is --load-extension removed from Chrome entirely?
No. Chrome for Testing 149.0.7827.55 and 151.0.7922.10 both loaded the unpacked MV3 stub from that flag and completed the six scored checks. Stock Google Chrome 150.0.7871.187 refused it and logged "--load-extension is not allowed in Google Chrome, ignoring". This supports, but does not prove, a branded-build gate: the behavior rules out simple monotonic removal, while a Chrome-150-specific regression restored in 151 remains compatible with the three-arm design.
Why don't I see any error, and how do I tell "never loaded" from "loaded and failed quietly"?
The warning isn't printed at default verbosity. Launch with --enable-logging=stderr and it appears immediately; without it Chrome starts normally but its extension service ignores the flags, and the first harness symptom is a selector timeout. To separate “never loaded” from injection failure, use two independent signals: the MV3 background service worker and a content-script marker in the target DOM. On the tested stock Chrome arm neither appeared; on both Chrome for Testing arms both did.
What should I use to automate extensions instead?
The passing arms used pinned Chrome for Testing executables via executablePath. Playwright-managed Chromium is another possible automation binary, but it was not an arm in this test and should not be described as the same distribution without checking the resolved executable.
Why does waitForEvent('download') never resolve when the extension exports a file?
In this MV3/Playwright 1.56.0 setup, the event did not resolve and no suggested filename was offered. The harness opened a CDP session, called Browser.setDownloadBehavior with an explicit directory, then read the file from disk. In the tested runs, the CSV bytes survived but probe-export.csv arrived as download.csv; broader extension and browser combinations were not tested.
What does this not say — about file:// URLs, and about stock Chrome generally?
Two boundaries, in opposite directions. Content scripts do work on file:// URLs for extensions loaded from the command line — tested on both Chrome for Testing builds, with the script injecting normally and the extension id reported correctly, so the common claim that they don't (on the grounds that extensions lack file access by default) is wrong for this install path. Serving fixtures over HTTP is still better practice because it resembles a real target, not because file:// blocks injection. In the other direction: none of this says stock Chrome can't run extensions. Only the --load-extension command-line path was measured. Packed .crx installation, developer-mode loading and enterprise allowlist policies were not, and no claim is made about them. The scope is the one flag pair that automation tutorials tell you to use.


