ScreenshotNeo

BlogHow-to

How to Use Puppeteer Core with Chrome Extensions

Load unpacked Chrome extensions with puppeteer-core, then test their service workers, popups, and content scripts in the right browser mode.

By the ScreenshotNeo team30 September 202610 min read

How to Use Puppeteer Core with Chrome Extensions

Use puppeteer-core from Node.js and point it at a Chrome executable you manage. For an unpacked extension, pass its directory in Puppeteer’s enableExtensions launch option. Then locate the extension’s service worker, background page, popup, or content-script realm according to the extension’s architecture. With puppeteer-core, specify either executablePath or channel; it does not choose a browser for you. See the Puppeteer Chrome Extensions guide and LaunchOptions reference.

This guide covers Node.js Puppeteer launching Chrome with an extension installed. If you instead mean running Puppeteer from inside extension code, that is a separate experimental setup; see Puppeteer in Chrome extensions.

1. Install Puppeteer Core and choose Chrome

Install the package in your project:

npm install puppeteer-core

Choose the browser executable your test environment will use. Puppeteer’s documentation recommends Chrome for Testing for compatibility. The compatibility guarantee applies to Puppeteer’s bundled browser; when you select a different Chrome executable, compatibility is not guaranteed. Pin and manage the browser version in CI if repeatable runs matter, and consult the current supported browsers page for the version mapping.

Set CHROME_PATH in your environment to the full path of Chrome for Testing or another Chrome installation. The value depends on the operating system and how Chrome was installed. Avoid assuming that google-chrome or a particular macOS application path exists on every machine.

2. Load the unpacked extension at launch

The launch-time approach is the straightforward option for test suites. The extension directory must contain its manifest and other extension files. This runnable ES module example reads the executable location from the environment, loads the extension, lists extensions that Chrome reports, and closes the browser even if a check fails.

Puppeteer Core launches a selected Chrome executable and loads an unpacked extension directory for testing.
Puppeteer Core launches a selected Chrome executable and loads an unpacked extension directory for testing.
import puppeteer from 'puppeteer-core';
import path from 'node:path';

const executablePath = process.env.CHROME_PATH;
if (!executablePath) {
  throw new Error('Set CHROME_PATH to the Chrome executable');
}

const extensionPath = path.resolve('my-extension');
const browser = await puppeteer.launch({
  executablePath,
  enableExtensions: [extensionPath],
  headless: false,
});

try {
  const extensions = await browser.extensions();
  const installed = [...extensions.values()].map(({ name, id }) => ({ name, id }));
  console.log(installed);
  if (installed.length === 0) {
    throw new Error('Chrome did not report an installed extension');
  }
} finally {
  await browser.close();
}

Save this as launch-extension.mjs and run CHROME_PATH=/absolute/path/to/chrome node launch-extension.mjs. On Windows, set the environment variable using the shell’s syntax and use the executable’s full path.

enableExtensions matters: Puppeteer’s default launch arguments can otherwise prevent extensions from being enabled. The option accepts extension paths, so multiple unpacked extensions can be loaded by providing multiple directories. Use absolute paths in automation when the working directory may vary.

3. Install an extension after launch

If the test needs to install and remove extensions dynamically, launch with enableExtensions: true, then call browser.installExtension(). It returns the extension ID, which you can use for checks and cleanup.

import puppeteer from 'puppeteer-core';
import path from 'node:path';

const browser = await puppeteer.launch({
  executablePath: process.env.CHROME_PATH,
  enableExtensions: true,
  headless: false,
});

try {
  const extensionId = await browser.installExtension(
    path.resolve('my-extension'),
  );
  console.log('Installed extension:', extensionId);

  const extensions = await browser.extensions();
  console.log([...extensions.values()].map(({ name, id }) => ({ name, id })));

  // Run assertions while the extension is installed.
  await browser.uninstallExtension(extensionId);
} finally {
  await browser.close();
}

Use a try/finally around browser work so a failed assertion does not leave Chrome running. If you want to inspect the extension before removing it, put your test between installation and uninstall. The official guide documents browser.extensions(), browser.installExtension(), and browser.uninstallExtension().

4. Test the extension’s actual execution context

Extensions do not run all their code in an ordinary web page. Identify the feature under test first, then wait for its target rather than assuming Chrome has already created it. Match targets using the extension ID, target type, and a known extension-specific URL or marker. The guide’s examples make assumptions about unique targets; tests should encode those assumptions explicitly.

Service workers, popups, and content scripts live in different contexts, so tests should wait for the specific target they exercise.
Service workers, popups, and content scripts live in different contexts, so tests should wait for the specific target they exercise.

Manifest V3 service worker

A Manifest V3 background context is a service worker. Wait for a service-worker target whose URL belongs to your extension, then obtain its worker handle and evaluate a check there. Do not guess the worker filename; read the actual worker configuration from your extension’s manifest and match accordingly.

const extensionId = 'replace-with-the-installed-extension-id';
const workerTarget = await browser.waitForTarget((target) =>
  target.type() === 'service_worker' &&
  target.url().startsWith(`chrome-extension://${extensionId}/`),
);

const worker = await workerTarget.worker();
if (!worker) {
  throw new Error('The service worker target did not provide a worker handle');
}

const result = await worker.evaluate(() => {
  return { title: chrome.runtime.getManifest().name };
});
console.log(result);

For a stronger test, check a behavior or state your extension intentionally exposes to its worker rather than relying only on its display name. A service worker may not remain alive indefinitely; wait for the target as part of the operation that needs it.

Manifest V2 background page

The Puppeteer extension guide also describes the Manifest V2 pattern: wait for a background_page target and use its page handle. Confirm that your target browser versions support the extension architecture you are testing. Do not write a test that silently assumes every extension has a persistent background page.

const backgroundTarget = await browser.waitForTarget((target) =>
  target.type() === 'background_page' &&
  target.url().startsWith(`chrome-extension://${extensionId}/`),
);
const backgroundPage = await backgroundTarget.page();
if (!backgroundPage) throw new Error('No background page handle');

const manifestName = await backgroundPage.evaluate(() =>
  chrome.runtime.getManifest().name,
);
console.log(manifestName);

To test a popup, trigger the extension action and wait for the popup target. Puppeteer documents both page.triggerExtensionAction(extension) and extension.triggerAction(page). Once the popup page is open, interact with it like a regular page.

const page = await browser.newPage();
await page.goto('https://example.com');

const extension = [...(await browser.extensions()).values()]
  .find(({ id }) => id === extensionId);
if (!extension) throw new Error('Extension was not found');

await page.triggerExtensionAction(extension);
const popupTarget = await browser.waitForTarget((target) =>
  target.type() === 'page' &&
  target.url().startsWith(`chrome-extension://${extensionId}/`),
);
const popup = await popupTarget.page();
if (!popup) throw new Error('Popup target did not provide a page');
console.log(await popup.title());

Some extensions open different pages or windows depending on their action configuration. Tighten the URL condition to the configured popup path when possible, so a background target or unrelated extension page cannot satisfy the wait by mistake.

Content script

Navigate to a page where the extension should run. Chrome injects the content script according to the extension’s configuration and the page’s URL and permissions. If you need to evaluate specifically in the content-script execution realm, use page.extensionRealms() to find the matching extension realm and evaluate there. This differs from evaluating in the page’s main JavaScript context.

const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

const realms = await page.extensionRealms();
const extensionRealm = realms.find((realm) =>
  realm.url().startsWith(`chrome-extension://${extensionId}/`),
);
if (!extensionRealm) {
  throw new Error('No content-script realm found; check URL rules and permissions');
}

const value = await extensionRealm.evaluate(() => {
  return document.documentElement.tagName;
});
console.log(value);

Use a page URL that matches the extension’s host permissions and content-script match patterns. A missing realm can mean the page does not match those rules, the extension has not injected yet, or the chosen browser and extension configuration are incompatible.

5. Choose the browser mode that represents the test

Puppeteer documents three relevant choices: headless: true uses the newer headless Chrome, headless: 'shell' uses the older chrome-headless-shell, and headless: false launches headful Chrome. The old shell does not fully match regular Chrome behavior. The documentation does not promise that every extension scenario behaves identically across these modes.

  • Use headful mode when the behavior depends on visible browser UI, such as an action popup, or when debugging launch and permission problems.
  • Use the newer headless mode when that is the intended test environment, and verify extension behavior there with your exact Chrome version.
  • Use 'shell' only when the old shell is specifically part of your environment. Do not assume it reproduces regular Chrome extension behavior.

Prefer matching the mode used by your users or CI target over optimizing for a mode merely because it launches without a visible window.

6. Common failures and fixes

Symptom Likely cause Fix
Launch fails or Puppeteer cannot find Chrome puppeteer-core has no bundled browser selection for this launch. Provide a valid executablePath or a channel. Check that the executable exists and is runnable in the current environment.
Chrome launches but the extension is missing The extension path is wrong, not unpacked, or extensions were not enabled. Point to the directory containing the manifest. Set enableExtensions: [path], or use enableExtensions: true before calling installExtension().
The extension loads, but no popup appears The action was not triggered, the popup URL condition is too broad or too narrow, or the test assumes a popup the extension does not configure. Trigger the extension action using the documented API, then wait for the extension’s configured popup target and inspect its URL.
No service-worker target is found The extension may use a different worker path, its worker has not started, or the extension is not configured as expected. Match the extension ID and actual manifest worker URL; wait during the operation that starts the worker and inspect browser targets during debugging.
No content-script realm is found The test page does not match the extension’s URL rules or permissions, or injection has not happened. Navigate to a permitted matching URL, wait for the page to load, and query the extension realm rather than the page’s main realm.
Works locally but fails in CI The Chrome path, version, permissions, display environment, or headless mode differs. Pin and log the browser executable/version, use an explicit path, and run the CI test in the browser mode it is intended to cover.
Intermittent timeout waiting for a target The predicate assumes a unique target or matches too many extension pages. Include target type, extension ID, and a specific URL marker. Avoid taking the first target from an unordered list.

7. Reliability, speed, and test design

Browser startup and extension initialization are part of test cost. Reuse a browser within a test suite when isolation requirements allow it, but keep test state deliberate: use fresh pages or clean extension storage where required, and close the browser in a final cleanup path. If tests can mutate extension state, one test’s storage or tab state can affect another.

Use target waits rather than fixed sleeps for workers, popups, and pages. A fixed delay can waste time on fast runs and still fail on a slow machine. Make wait predicates narrow and use a finite timeout so a missing target produces a useful failure instead of hanging indefinitely.

For reproducibility, record the Puppeteer package version, browser executable/version, headless mode, extension build, and relevant launch configuration. Puppeteer’s compatibility guarantees are tied to its bundled browser; external Chrome versions may work but are not guaranteed. When a test covers toolbar UI, run headful tests in an environment that supports a visible browser window.

8. An important distinction: Puppeteer inside an extension

If your goal is to bundle Puppeteer into a Chrome extension and control a tab from extension code, the setup above is not the one you need. The Puppeteer Next guide describes support through chrome.debugger and ExtensionTransport as experimental. It requires a browser-compatible bundle and the browser-specific puppeteer-core entry point.

That transport can attach to one page at a time. Puppeteer cannot create additional pages through that connection, so the extension must use chrome.tabs and establish another connection for a new tab. Because this guide is marked Next and the feature experimental, check the current documentation and validate against the exact browser environment before depending on it.

9. Or skip the browser setup

If your goal is to capture a website image rather than test extension behavior, ScreenshotNeo provides a website screenshot API and MCP server. A single GET request returns an image or PDF. This does not install or test a Chrome extension; it handles website capture.

See the ScreenshotNeo API documentation for request options. Here is the one-call cURL example:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Equivalent Python:

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)

Equivalent Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, no card required.

10. Frequently asked questions

Can I load more than one unpacked extension?

Yes. Pass multiple extension directories in the enableExtensions array and identify the one under test by its extension ID.

Does puppeteer-core download Chrome for me?

For launch, supply executablePath or channel. Manage the browser separately and use the compatibility documentation to choose a suitable version.

Can Puppeteer test a popup in headless mode?

Extension behavior can depend on the browser mode. Verify the popup in the exact mode and Chrome build your test targets; use headful mode when browser UI behavior is central.

Is this the same as Puppeteer running inside a Chrome extension?

No. This article uses Node.js Puppeteer to launch Chrome with an extension. Running Puppeteer from extension code uses an experimental debugger transport with different constraints.

Primary references