Lightpanda is an open-source headless browser written from scratch, without Chromium. It starts in milliseconds, holds a fraction of Chrome’s memory, and speaks the Chrome DevTools Protocol. lightpanda() from @e2e-dev/lightpanda is a browser provider for it: each worker slot gets its own lightpanda serve, started on a free port when the run starts and stopped when it ends. Tests and CI stay the same; the config changes only in the target’s browser.

Lightpanda has no layout engine. It builds the DOM, runs JavaScript, and exposes the accessibility tree the engine reads, which is what locators, assertions, and agent steps use. Anything that depends on where a node is painted behaves differently; see What does not work before moving a suite over.

Setup

Install the package:

npm install --save-dev @e2e-dev/lightpanda

And the Lightpanda nightly binary, which the provider starts:

brew install lightpanda-io/browser/lightpanda
e2e.config.ts
import type { E2EConfig } from 'e2e';
import { web } from '@e2e-dev/web';
import { lightpanda } from '@e2e-dev/lightpanda';

export default {
  targets: [
    {
      name: 'lightpanda',
      engine: web({ browser: lightpanda() }),
      app: { url: 'http://localhost:3000' },
    },
  ],
} satisfies E2EConfig;

The provider runs lightpanda from PATH, ~/.lightpanda/lightpanda, or ~/.local/bin/lightpanda, the places agent-browser looks too, or the binary LIGHTPANDA_PATH names in the environment e2e run starts in, or binary in the options. None found fails the run before any test, naming the install page. Each worker gets its own server, so workers sets how many run at once, and the run’s log names each one:

ℹ lightpanda (1 of 2): lightpanda 1.1.0-nightly at ws://127.0.0.1:51165/

Attach to a running server

A Lightpanda already running, such as the Docker image in CI, is a connect target rather than a provider: the engine attaches to its CDP endpoint and leaves it running.

e2e.config.ts
web({ connect: { cdpEndpoint: () => process.env.LIGHTPANDA_URL ?? 'ws://127.0.0.1:9222/' } })
docker run -d --name lightpanda -p 127.0.0.1:9222:9222 lightpanda/browser:nightly

Every worker then shares that server. One lightpanda serve takes 16 connections by default; raise its --cdp-max-connections above workers when you run more.

Options

OptionDefaultMeaning
binaryLIGHTPANDA_PATH, else foundThe binary to start: a path, or a name on PATH.
loadResources[]Sub-resources the server fetches: iframe, image, stylesheet. See Fetch more of the page.
args[]Further lightpanda serve flags, after the ones the provider sets.

The provider leases one server per worker slot for the run; it has no scope: 'attempt', since Lightpanda does not expose the default context identity a per-attempt lease reattaches by. A server that does not answer on its port within ten seconds fails the lease, and so does a binary that exits first, with its stderr in the message.

Fetch more of the page

lightpanda serve requests no sub-resources by default: no images, no external stylesheets, no iframes. loadResources opts into each, as --load-resources on the server:

web({ browser: lightpanda({ loadResources: ['stylesheet', 'iframe'] }) })

Inline styles, <style> blocks, and the hidden attribute already decide visibility; stylesheet makes an external file’s rules count too, so a node a linked stylesheet hides is hidden to a query. image fetches image headers so load and error reflect the real status.

What works

Against the e2e testbed’s local suite on a Lightpanda nightly from mid-October 2026, these pass unchanged:

  • app.open, app.back, and expect(browser).toHaveURL and toHaveTitle.
  • Every screen query (getByRole, getByLabel, getByText, getByTestId, filters, nth) and the actions on them: tap, fill, check, select, press, and keyboard input.
  • Polling assertions on text, value, count, and state.
  • browser.route mocks and request waits.
  • browser.setCookies from a test, after a page is open.
  • app.screenshot and the screenshot of a failure. Lightpanda paints a plain render of the DOM, not the page as Chrome would show it.
  • Agent steps: agent.act, agent.assert, agent.extract, and agent.waitFor read the screen tree, which Lightpanda provides in full. The testbed’s agent suite passes its tree-driven flows (todos, wizard, sign-in, hover, context menu, long press, upload); what fails is drawn on a canvas, dragged, or below the fold. A judgment on pixels sees the plain render, so keep the agent on the tree.

A test that asserts on what the user sees, not where it is drawn, moves over as is.

What does not work

Each of these fails the test with the engine’s usual error, not a hang. The reasons are Lightpanda’s, and its Playwright compatibility issue tracks most of them.

FeatureWhat happensWhy
Sessions (test.setup with session.save, session: on a test)The setup passes and the session is saved, but a test that restores it opens signed out.Cookies set on a context before its first page exists are dropped. Set them from the test with browser.setCookies after app.open instead.
Dialogsbrowser.onDialog runs, then fails the test with ENGINE_FAILURE: Page.handleJavaScriptDialog reports no dialog.Lightpanda answers alert, confirm (false), and prompt (null) itself before a client can.
Downloadsbrowser.waitForDownload times out.No download events over CDP.
IframesframeLocator finds nothing.Iframes are not loaded by default. loadResources: ['iframe'] loads them; queries and fills then work, but a listener in the host page on an element the frame created does not fire.
Drags: screen.swipe, locator.swipeTo, and browser.mouse moves with a button downENGINE_FAILURE naming Input.setInterceptDrags.Playwright needs that command for a drag and Lightpanda has not implemented it.
browser.setViewportThe page measures the new size, but a resize listener never runs.No resize event is dispatched.
Media emulation: colorScheme, reducedMotion, browser.emulateMediaThe page keeps reporting light and full motion.Emulation.setEmulatedMedia does not change what matchMedia answers.
Scrolling: scrollIntoView, scrollUntilVisible, and an action on a node “outside of the viewport”The scroll is a no-op, and an actionability wait can time out.Without layout there is no scroll position to move. The plain render stacks every block in one column, so more of a page sits below the fold than in Chrome, and nothing below it can be reached.
<canvas> content: a pin, keypad, or chart drawn with the 2D contextA screenshot shows no drawing, and the canvas takes no space. The agent’s pixel tier (tap_at, type_at, a vision judgment) has nothing to see.getContext('2d') returns a context that records nothing the painter draws.

A Lightpanda release that closes one of these makes the row above go away with no change on the e2e side; the engine sends the same CDP commands it sends Chrome.

Where it fits

Lightpanda suits a suite that is heavy on the agent’s reading of the screen and light on geometry: smoke flows, forms, navigation, API-backed pages, and agent.act steps whose checks are text and state. It runs that suite in a fraction of Chrome’s time and memory, on a box with no Chrome installed.

Keep a Chromium target for the rest, and select by target when both are in one config:

e2e run --target lightpanda
e2e run --target chromium