How to Automate Chrome Extensions with Puppeteer
Load an unpacked extension, test its background context, popup, and content scripts, and troubleshoot Puppeteer’s browser modes and launch settings.
Puppeteer can automate Chrome extension tests by launching Chrome with a built, unpacked extension enabled, then interacting with the extension’s background context, popup, and content-script realm. In Manifest V3 (MV3), the background context is a service worker; in Manifest V2 (MV2), it is a background page. Use target predicates that identify your extension and expected URL, and run in the browser mode your tests are meant to cover.
The examples below use Puppeteer’s current extension APIs. The official Chrome Extensions guide documents loading extensions, triggering actions, and finding extension realms. Pair Puppeteer with its downloaded Chrome for Testing version where possible, since that is the documented compatibility baseline.
1. Prepare the extension and test project
Build your extension first. The directory passed to Puppeteer must be the unpacked extension directory: it should contain the built manifest.json and the files referenced by that manifest. For example, your project might have this layout:
my-extension/
manifest.json
background.js
popup.html
popup.js
content.js
dist/
Use your actual build output directory if your project emits the manifest and scripts under dist. Install Puppeteer in the test project and save the test as an ES module, such as test-extension.mjs:
npm install --save-dev puppeteer
Manifest V3 commonly declares a service worker and action popup; Manifest V2 uses a background page and may use the legacy browser-action declaration. Keep the test’s expected target type aligned with the manifest and the extension’s actual filenames and URLs.
2. Load the unpacked extension
The simplest launch-time method is to pass the extension directory in enableExtensions:
import puppeteer from 'puppeteer';
import path from 'node:path';
const extensionPath = path.resolve('my-extension');
const browser = await puppeteer.launch({
enableExtensions: [extensionPath],
});
try {
console.log(await browser.extensions());
} finally {
await browser.close();
}
enableExtensions accepts a boolean or an array of unpacked extension paths. Puppeteer’s default launch arguments disable extensions; enabling them is necessary for this workflow. See the LaunchOptions reference.
Alternatively, enable extension support at launch and install the directory at runtime. This is useful when the test needs the returned extension ID explicitly:
import puppeteer from 'puppeteer';
import path from 'node:path';
const extensionPath = path.resolve('my-extension');
const browser = await puppeteer.launch({ enableExtensions: true });
try {
const extensionId = await browser.installExtension(extensionPath);
console.log('Installed extension:', extensionId);
console.log(await browser.extensions());
} finally {
await browser.close();
}
The browser API also provides browser.uninstallExtension(extensionId) when a test needs to remove an installed extension before the browser closes. Prefer one loading approach per test setup so that the installed extension state stays clear.
3. Find the background context for MV3 or MV2
Do not assume every extension has the same background target. Wait for the target type declared by the extension architecture, and narrow the predicate to your extension’s URL or known script path. Puppeteer’s guide shows a service worker whose URL ends in background.js; adapt that condition to your build.
Manifest V3: service worker
const workerTarget = await browser.waitForTarget(target =>
target.type() === 'service_worker' &&
target.url().endsWith('/background.js')
);
const worker = await workerTarget.worker();
if (!worker) {
throw new Error('The extension background service worker was not available');
}
const result = await worker.evaluate(() => typeof chrome !== 'undefined');
if (!result) throw new Error('Chrome extension APIs were not available in the worker');
If the extension’s worker has a different filename, use the URL observed for your extension and update the predicate. A broad check for any service worker can match a site worker or another installed extension.
Manifest V2: background page
const backgroundTarget = await browser.waitForTarget(target =>
target.type() === 'background_page' &&
target.url().startsWith('chrome-extension://')
);
const backgroundPage = await backgroundTarget.page();
if (!backgroundPage) {
throw new Error('The extension background page was not available');
}
const result = await backgroundPage.evaluate(() => typeof chrome !== 'undefined');
if (!result) throw new Error('Chrome extension APIs were not available in the background page');
In a suite with multiple extensions, also match the expected extension ID in the URL. A target’s URL uses the chrome-extension://<extension-id>/... form, so an ID-specific predicate prevents selecting another extension’s context.
4. Trigger the toolbar action and test its popup
Puppeteer provides page.triggerExtensionAction(extension) and extension.triggerAction(page) to trigger an extension’s default action on a page. When the action opens a popup, wait for the popup target and convert it to a page with asPage().
const extensionId = 'YOUR_EXTENSION_ID'; // Get this from browser.extensions() or installExtension().
const popupPath = 'popup.html';
const testPage = await browser.newPage();
await testPage.goto('https://example.com');
const extension = (await browser.extensions()).find(item => item.id === extensionId);
if (!extension) throw new Error(`Extension ${extensionId} was not installed`);
const popupTargetPromise = browser.waitForTarget(target =>
target.type() === 'page' &&
target.url().startsWith(`chrome-extension://${extensionId}/`) &&
target.url().endsWith(popupPath)
);
await testPage.triggerExtensionAction(extension);
const popupTarget = await popupTargetPromise;
const popup = await popupTarget.asPage();
const heading = await popup.locator('h1').textContent();
if (heading !== 'Extension settings') {
throw new Error(`Unexpected popup heading: ${heading}`);
}
Replace the ID, popup path, and assertion with values from your extension. The target predicate should identify both the extension and the expected popup. If the popup does not open from the action trigger in your setup, the MV3 service worker can call chrome.action.openPopup() through its worker context, as shown in the Puppeteer guide. Confirm that your extension’s action configuration and browser mode support the behavior under test.
Keep the target wait active before triggering the action, as above, so the test does not miss a short-lived popup target. Close pages you create when the test is done, or close the browser in a finally block.
5. Test content-script behavior in the extension realm
A content script runs in an extension realm associated with the page. Navigate to a page where the extension’s manifest matches, wait for injection if needed, then use page.extensionRealms() to find the realm belonging to the installed extension. Evaluate assertions there instead of silently falling back to the ordinary page context.
const extensionId = 'YOUR_EXTENSION_ID';
const page = await browser.newPage();
await page.goto('https://example.com');
const realms = await page.extensionRealms();
const extensionRealm = realms.find(realm => realm.extension?.id === extensionId);
if (!extensionRealm) {
throw new Error(`No content-script realm found for extension ${extensionId}`);
}
const injectedValue = await extensionRealm.evaluate(() => {
return document.documentElement.getAttribute('data-my-extension');
});
if (injectedValue !== 'ready') {
throw new Error(`Content script did not set the expected value: ${injectedValue}`);
}
Change the attribute and expected value to match your content script’s observable behavior. If injection is asynchronous, wait for a reliable page condition before inspecting realms or asserting state. The page must satisfy the content script’s URL match rules and any other injection conditions.
6. Complete runnable example
This test installs the built extension at launch, discovers its ID, checks the MV3 worker, opens the popup through the extension action, and verifies a content-script marker. Update the three extension-specific constants and the expected marker for your extension. For MV2, replace the worker section with the background-page flow above.
import puppeteer from 'puppeteer';
import path from 'node:path';
const extensionPath = path.resolve('my-extension');
const expectedPopupFile = 'popup.html';
const expectedMarker = 'ready';
const pageUrl = 'https://example.com';
const browser = await puppeteer.launch({
enableExtensions: [extensionPath],
});
try {
const extensions = await browser.extensions();
const extension = extensions[0];
if (!extension) throw new Error('No extension was installed');
const extensionId = extension.id;
// MV3: wait for this extension's background service worker.
const workerTarget = await browser.waitForTarget(target =>
target.type() === 'service_worker' &&
target.url().startsWith(`chrome-extension://${extensionId}/`) &&
target.url().endsWith('/background.js')
);
const worker = await workerTarget.worker();
if (!worker) throw new Error('Could not get the extension service worker');
const workerReady = await worker.evaluate(() => typeof chrome !== 'undefined');
if (!workerReady) throw new Error('Chrome APIs were unavailable in the worker');
// Open and inspect the action popup.
const page = await browser.newPage();
await page.goto(pageUrl);
const popupTargetPromise = browser.waitForTarget(target =>
target.type() === 'page' &&
target.url().startsWith(`chrome-extension://${extensionId}/`) &&
target.url().endsWith(expectedPopupFile)
);
await page.triggerExtensionAction(extension);
const popupTarget = await popupTargetPromise;
const popup = await popupTarget.asPage();
const popupHeading = await popup.locator('h1').textContent();
if (!popupHeading) throw new Error('Expected an h1 in the extension popup');
// Check content-script output in the extension's isolated realm.
const realms = await page.extensionRealms();
const extensionRealm = realms.find(realm => realm.extension?.id === extensionId);
if (!extensionRealm) throw new Error(`No content-script realm found for ${extensionId}`);
const marker = await extensionRealm.evaluate(() =>
document.documentElement.getAttribute('data-my-extension')
);
if (marker !== expectedMarker) {
throw new Error(`Expected marker ${expectedMarker}, received ${marker}`);
}
console.log('Extension checks passed');
} finally {
await browser.close();
}
For a full suite, split these checks into focused tests so a failure identifies whether loading, background logic, popup behavior, or content-script injection is responsible. Keep extension IDs and page URLs specific to the test environment rather than copying the placeholder values.
7. Choose a browser mode for the behavior under test
Puppeteer launches headless by default. Use headless: false when you need to inspect the visible browser or assert behavior that depends on full Chrome UI. Puppeteer also supports headless: 'shell', which selects the separate chrome-headless-shell binary. The shell does not completely match regular Chrome, so validate the exact mode used in CI when extension UI is part of the test. See Puppeteer’s headless modes guide.
// Headful Chrome, useful when visible browser behavior is part of the assertion.
const browser = await puppeteer.launch({
headless: false,
enableExtensions: [extensionPath],
});
// Separate headless shell; use only if its behavior fits the test.
const browser = await puppeteer.launch({
headless: 'shell',
enableExtensions: [extensionPath],
});
For reproducible runs, use the Chrome for Testing build downloaded by Puppeteer. The launch compatibility guidance says Puppeteer works best with that version and does not guarantee operation with another Chrome version. If your environment requires an independently installed Chrome, validate that pairing explicitly. See PuppeteerNode.launch().
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Extension does not appear in the browser | Extension support was not enabled, or the path does not contain the built unpacked extension. | Set enableExtensions to the path array or true before runtime installation. Check that the directory contains the built manifest.json and referenced files. |
| Wait for target times out | The predicate expects the wrong target type, filename, popup path, or extension ID. | For MV3 look for service_worker; for MV2 look for background_page. Inspect installed extension properties and match the actual target URL. Avoid assuming every extension uses background.js or popup.html. |
| Popup target is never found | The action did not open a popup, the extension has no configured popup, or the target predicate is too broad or too strict. | Check the action configuration, use the right extension ID and popup filename, and ensure the target wait starts before triggering the action. Try the documented MV3 chrome.action.openPopup() path when appropriate. |
| No content-script realm is found | The page URL does not match the extension’s injection rules, injection has not completed, or the wrong extension ID is being matched. | Navigate to a matching page, wait for a dependable injection signal, and compare the realm’s extension ID with the installed ID. Fail clearly if it remains absent. |
| Extension UI behaves differently in CI | Headless mode and the headless shell do not behave identically to visible Chrome. | Run with headless: false when visible Chrome behavior is the requirement, and validate the same mode used in CI. See the headless modes guide. |
| Chrome fails to launch on Linux | Required system dependencies may be missing, or the environment has a browser setup issue. | Follow Puppeteer’s troubleshooting guide to check system dependencies and environment details. The guide strongly discourages running Chrome without its sandbox; do not use --no-sandbox as a routine fix. |
| Different behavior with system Chrome | The installed Chrome version does not match the Puppeteer pairing. | Prefer the Chrome for Testing build Puppeteer downloads, or validate the independently managed browser version in your environment. |
9. Reliability, runtime, and cost considerations
- Make target selection deterministic. Match extension ID, target type, and expected path. This avoids accidentally testing another extension or page worker when multiple targets exist.
- Wait for observable conditions. Use target waits for popup and worker creation, and a meaningful content-script signal for injection. Fixed delays can make tests slow or flaky when startup varies.
- Keep browser and extension builds aligned. Build the extension before launching, use the same output directory CI expects, and prefer Puppeteer’s downloaded Chrome for Testing for the documented compatibility baseline.
- Choose mode by coverage goal. Headful runs cover visible browser behavior; headless runs can suit automation that does not require visible UI. The headless shell has behavioral differences that matter for some extension tests.
- Control resources. Reuse a browser for a related test group when isolation requirements permit, close pages you create, and always close the browser in cleanup. More independent browser launches use more resources and increase setup work.
- Budget for browser infrastructure. Puppeteer is code you run in your own environment; account for the CI compute, browser installation, and maintenance required by your test suite. The cited documentation does not provide a universal runtime or cost figure.
Or skip the browser setup
If your task is to capture a page image or PDF rather than verify extension internals, ScreenshotNeo offers a website screenshot API and MCP server. It does not replace Puppeteer tests for extension workers, popups, or content scripts. One GET request captures a URL; see the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie banners, newsletter popups, and chat widgets are removed before the screenshot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Responses identify page verdict and billing with headers.
- An MCP server lets AI agents using Claude, Cursor, or another MCP client take screenshots.
- 1,000 screenshots a month are free with no card. Paid plans start at $5 for 3,000 screenshots.
Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Can Puppeteer test a Chrome extension?
Yes. Puppeteer’s official guide documents loading unpacked extensions and testing their background contexts, actions, and content-script realms.
Can I test an extension popup in headless mode?
The extension guide documents popup target detection, but browser mode can affect behavior. Validate the mode used by your CI; use headful Chrome when visible browser behavior is part of the assertion.
Do MV2 and MV3 use the same background target?
No. The workflow in Puppeteer’s guide uses a background page for MV2 and a service worker for MV3.
Does a screenshot API test extension behavior?
No. A screenshot API captures a website page. Use Puppeteer when you need to exercise extension code, popup behavior, or content-script injection.


