Behaviour differences
Most Playwright tests pass unchanged, but a few behaviours differ, either because Lightningpanda is not Chromium or because the API is implemented differently. This page lists every known difference with its consequence and the fix. Read it before debugging the first failures of a migrated suite.
Actions settle before they return
In Playwright, click() returns once the click has been dispatched. In Lightningpanda, an action returns once the page has settled: no queued task, no request the action started still running, no timer due within 50 ms, no pending animation frame or navigation. The browser waits for this itself, in the same command as the action. See Settling and auto-waiting.
Things that do not hold up settling: an open WebSocket, timers on a paused fake clock, and timers due more than 50 ms later.
Consequences:
Transient states are gone when the action returns. A test that checks a spinner or a "Saving..." label between the click and the result can fail, because by the time
click()returns, the save has finished:tsawait page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Saving...')).toBeVisible(); // may time out: already "Saved"Assert the final state instead, or check the request:
tsconst saved = page.waitForResponse('**/api/save'); await page.getByRole('button', { name: 'Save' }).click(); expect((await saved).status()).toBe(200); await expect(page.getByText('Saved')).toBeVisible();If the transient state itself must be tested, slow the request down with a route (
route.fulfillafter a delay), so the state lasts.Explicit waits become unnecessary.
waitForLoadState('networkidle')orwaitForTimeout()after an action mostly wait for nothing. They still work.A
waitForResponsestarted before the action still resolves. The response arrived during the action, and the listener saw it.
focus() and blur() do not wait for the page to settle.
Dialogs
With a page.on('dialog') listener registered, a dialog that an action opens waits for the listener's answer, as in Chrome. Without a listener, the dialog is dismissed: confirm() returns false, prompt() returns null, alert() returns at once. This is Playwright's default too.
Register the listener before the action. If a listener answers a dialog that was already dismissed, a warning is printed once: a dialog opened by an action or locator call was dismissed before the listener answered it.
The browser is not Chromium
- User agent.
navigator.userAgentisMozilla/5.0 (compatible; lightningpanda/<version>). Apps that sniff the user agent may show an "unsupported browser" screen or take a different code path. SetuserAgentinuseif needed (note that this disables booted pages). - Every project runs on Lightningpanda.
browserName: 'firefox'or'webkit'changes thebrowserNamefixture, not the browser. Each file runs once, under its first project. - Web APIs. Coverage is broad (DOM, fetch, XHR, WebSocket, workers, IndexedDB, Web Crypto, Canvas 2D, observers, ...) but partial. See Web platform support. A missing API shows up as
undefinedin app code or a page error. - JavaScript engine. Lightningpanda runs JavaScriptCore, not V8. Error messages and stack traces are worded the way JavaScriptCore words them. Code that parses V8 stack traces sees a different format.
- Fonts and rendering. Pages are rendered by Lightningpanda's CPU renderer with the operating system's fonts. Screenshots differ from Chromium's, and between macOS and Linux. See Screenshots and snapshots.
- Layout. Layout is computed by Lightningpanda's engine. Element sizes and positions (
boundingBox,toBeInViewport, scroll positions) can differ slightly from Chromium's.
Shadow DOM
Measured on version 0.0.33: CSS selectors, getByRole, getByLabel and getByPlaceholder do not find elements inside open shadow roots; getByText does. Playwright pierces open shadow roots with all of them. Locate shadow DOM content by text.
Context options without effect
These use / newContext options are accepted and do nothing to pages:
| Option | Alternative |
|---|---|
locale, timezoneId | None in the Playwright layer. |
geolocation, permissions, context.grantPermissions(), setGeolocation() | None. |
offline, context.setOffline() | A route that aborts every request. |
proxy | use.lightningpanda.args: ['--proxy', url]. |
httpCredentials | Works for request only. For pages, send an authorization header with extraHTTPHeaders. |
ignoreHTTPSErrors | Works for request only. |
deviceScaleFactor, isMobile, hasTouch | None. Pages render at scale 1. |
javaScriptEnabled: false, bypassCSP, serviceWorkers | None. |
recordVideo | None. |
Options you do not set are undefined, not false as in Playwright. test.skip(isMobile) therefore skips every test unless isMobile is set.
Selector-first page methods and deprecated APIs
Playwright's selector-first methods on Page and Frame (page.click(selector), page.fill(selector, value), page.check, page.hover, page.press, page.textContent(selector), page.getAttribute(selector, name), page.isVisible(selector), page.dispatchEvent, page.dragAndDrop, page.selectOption, ...) are not implemented. Calling one is a type error and a TypeError at run time. Use locators: page.locator(selector).click().
Deprecated APIs are left out, so using one is a type error: locator.type, elementHandle.type, the noWaitAfter option, page.accessibility, page.waitForNavigation, and FrameLocator.first() / last() / nth().
See API coverage for the full list.
Test runner
Plain
bun testreports a run-time skip as a pass.test.skip()inside a running test is reported as skipped bylightningpanda test, but Bun cannot mark a running test as skipped, so plainbun testcounts it as passed and prints↳ skipped: <title>.testInfo.statusin hooks.testInfo.status,error,errorsanddurationare not updated while a test runs. InafterEach,statusis'passed'even after a failure. A hook like this never takes its screenshot:tstest.afterEach(async ({ page }, testInfo) => { if (testInfo.status !== testInfo.expectedStatus) await page.screenshot({ path: `failed-${testInfo.title}.png` }); });Take such screenshots unconditionally into
testInfo.outputPath(), or catch the error in the test body and record it yourself.Worker fixtures are set up per file, not per worker process. The built-in
browserlives for the whole worker.Parallel tests inside one worker run one after another.
mode: 'parallel'andfullyParallelspread tests over workers; within a worker they are sequential unlessLP_CONCURRENT=1.Serial groups retry the failed test only. Playwright reruns the whole group.
step.skip()skips the whole test. Steps are not reported.Unsupported flags fail.
--shard,--trace,--repeat-each,--last-failedand other flags not listed in Config and the test runner exit with status 2.The config's
reporteris ignored, and there is no HTML report, trace or video.Project
dependenciesdo not run. Move setup projects intoglobalSetup.
Timeouts
- An action or navigation without a timeout waits up to 30 s. In Playwright the default action timeout is 0 (no limit except the test timeout). Set
actionTimeout/navigationTimeout, orpage.setDefaultTimeout(), to change it. - A single action's timeout is capped at 600 000 ms.
Network
page.requestandcontext.requestdo not share cookies with the browser context. Each API request context keeps its own cookie jar. In Playwright they share one.route.fetch()and API requests run in the test process, withfetch, not in the browser.- The
Responsefrompage.goto()has the URL and status only:headers()is empty andbody()throws.page.reload()is the same.goBack()andgoForward()returnnull. Agotoon a booted page returnsnull. request.frame()is always the main frame, andrequest.timing()fields are all -1.context.storageState()collectslocalStorageonly from origins with an open page.storageStateseeding leaves a__lpStorageStatekey inlocalStorage.page.routeWebSocket()is not implemented.
Assertions and locators
toHaveRolereads theroleattribute only. A<button>withoutrole="button"failstoHaveRole('button').toHaveAccessibleDescriptionreadsaria-description, elsetitle.toHaveAccessibleNamewith a RegExp orignoreCasereadsaria-label, else the text content; with a plain string it uses the browser's full name computation.toHaveScreenshotthreshold is a per-channel tolerance with default 0 (exact). Playwright's is a perceptual distance with default 0.2. Options other thanthreshold,maxDiffPixels,maxDiffPixelRatio,clip,fullPageandtimeoutare rejected.- Strict mode violations are worded differently:
strict mode violation: {"name":"Buy","role":"button"} matched 2 elements: <button> "Buy", <button> "Buy". getByTestId,getByAltText,getByTitletake strings only.click({ clickCount })supports 1 and 2.delayoptions are ignored.tap()is a click.selectors.setTestIdAttribute()does nothing; use thetestIdAttributeoption.
Marker attributes in the DOM
The Playwright layer adds attributes to find elements across calls:
data-lp-frame="<frame id>"on an<iframe>once its frame has been listed (page.frames(),frame.childFrames()).data-lp-handle="<id>"on an element for the length of an element handle action.
An app or a snapshot that serializes the DOM can see them.
Other API details
ConsoleMessage.args()returns an empty list.text()joins the arguments' values.page.pdf()passes its options to CDPPage.printToPDFunchanged: usepaperWidth,marginTopand the other CDP names, in inches. Playwright'sformatandmarginhave no effect.page.screenshot()ignoresmask,animations,omitBackground,scale,caretandstyle.page.pause(),page.bringToFront()andlocator.highlight()do nothing.page.video()andpage.opener()returnnull.context.tracing.start()/stop()do nothing.browser.version()returns'lightningpanda'.deviceshas five entries:Desktop Chrome,Desktop Chrome HiDPI,Desktop Edge,Pixel 7,iPhone 15.