@povio/lightning-panda 0.0.0-stage → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM.md ADDED
@@ -0,0 +1,260 @@
1
+ # Writing E2E tests with @povio/lightning-panda: instructions for coding agents
2
+
3
+ Read this before writing or porting a test. It is the whole contract; the
4
+ source is `src/steps.ts` (builders) and `src/index.ts` (pages and runs).
5
+
6
+ ## The model
7
+
8
+ - A test is **seeding, then one `page.run([...steps])`**. The browser runs every
9
+ step itself and replies once. Do not split a scenario into several `run`
10
+ calls unless plain code (an API call, a computation) must happen between
11
+ them.
12
+ - **Nothing is polled from the test.** Every step waits in the browser until it
13
+ holds (default 5 s per step). Never write `sleep`, retry loops,
14
+ `expect.poll`, `waitForTimeout` or `waitForResponse`.
15
+ - **Actions settle.** After `click`, `fill`, `press`, `check`, `uncheck` and
16
+ `route`, the next step starts only when the page is quiet: the requests the
17
+ action started have finished, no task is queued, no timeout is due within
18
+ 50 ms, no animation frame is pending and no navigation is half done. Intervals,
19
+ later timeouts and animation loops do not count. Assert the outcome with the
20
+ next `check`; do not wait for the request.
21
+ - **Requests are recorded, not awaited.** `check.request(method, path)` looks
22
+ at the requests made during *this* run (it waits for one to appear and
23
+ finish). Put it in the same `run` as the action that sends the request.
24
+
25
+ ## Skeleton
26
+
27
+ Install the browser and client with `bun add --dev @povio/lightning-panda`
28
+ (or `@povio/lightning-panda@next` for an RC). `Lightningpanda.launch()` finds
29
+ the bundled macOS browser automatically. Fork mode requires a separately built
30
+ patched JavaScriptCore browser selected via `LIGHTNINGPANDA_BIN` or `bin`.
31
+ On other operating systems, connect to a remote browser with
32
+ `Lightningpanda.connect()`.
33
+
34
+ ```ts
35
+ import { afterAll, beforeAll, expect, test } from 'bun:test';
36
+ import { Lightningpanda, button, check, dialog, text, label } from '@povio/lightning-panda';
37
+
38
+ let lp: Lightningpanda;
39
+ beforeAll(async () => {
40
+ lp = await Lightningpanda.connect(process.env.LIGHTNINGPANDA_WS!, { connections: 4 });
41
+ await lp.profile('employee', {
42
+ url: `${APP}/platform`, // any page of the app: it boots signed in and waits
43
+ localStorage: authEntries, // what the app reads at boot (tokens, user store)
44
+ viewport: { width: 1440, height: 900 },
45
+ pool: 2,
46
+ });
47
+ });
48
+ afterAll(() => lp.close());
49
+
50
+ test.concurrent('closes a position', async () => {
51
+ const position = await api.createPosition(); // seed through the API, not the UI
52
+ await using page = await lp.page({ as: 'employee', route: `/positions/${position.id}` });
53
+ const r = await page.run([
54
+ check(text(position.number)).visible(),
55
+ button('Settings', { exact: true }).click(),
56
+ dialog().hasText('will be closed').button('Close Position', { exact: true }).click(),
57
+ check.request('POST', `/positions/${position.id}/close`).status(200),
58
+ check(button('Reopen Position')).count(1),
59
+ text(/^Closed by: /).read('closedBy'),
60
+ ]);
61
+ expect(r.values.closedBy).toContain('E2E'); // bun:test expect on values read
62
+ });
63
+ ```
64
+
65
+ - `lp.page({ as, route })`: a pre-booted, signed-in page; `route` changes the
66
+ URL in place (pushState + popstate), so the app does not boot again. Use
67
+ `url` instead only when a full page load is the point of the test.
68
+ - `await using page` closes the page when the test ends (do not leave pages
69
+ open: an open page stops its connection from refilling).
70
+ - One profile per kind of user (`as`). Several profiles are fine.
71
+
72
+ ## Locators
73
+
74
+ Build them with the exported helpers; they are data, so build them once and
75
+ reuse them.
76
+
77
+ | Want | Write |
78
+ |---|---|
79
+ | Button / link / heading / dialog … by accessible name | `button('Save')`, `link('Docs')`, `heading('Positions')`, `dialog()`, `role('tab', 'Air')` |
80
+ | Form control by its label | `label('Email')` |
81
+ | By placeholder | `placeholder('Search')` |
82
+ | By visible text | `text('Saved')`, `text(/^Updated by .*/)` |
83
+ | By test id | `testId('row-42')` (`testId(id, 'data-test')` for another attribute) |
84
+ | CSS as a last resort | `css('#out')` |
85
+ | Inside another locator | `dialog().button('OK')`, `row('EXP-1001').button('Edit')`, `css('.card').text('Total')` |
86
+ | Inside an iframe | `css('#editor').frame().button('Bold')`, `inFrame(css('#editor')).text('Hi')` |
87
+ | Narrow the matches | `.visible()`, `.hasText('beta')`, `.hasNotText('alpha')`, `.has(button('Edit'))`, `.hasNot(…)` |
88
+ | Pick one of several | `.first()`, `.last()`, `.nth(i)` (after the filters) |
89
+
90
+ - Strings match **case-insensitive substrings** after whitespace
91
+ normalization; pass `{ exact: true }` for a whole, case-sensitive match. A
92
+ `RegExp` (flags `i`, `m`, `s` only) tests the normalized text.
93
+ - **Strict mode:** an action on a locator that matches several elements fails
94
+ at once and lists them. Fix the locator (scope it, filter it, `exact`), and
95
+ use `.first()` only when any match will do.
96
+ - `text()` follows Playwright's `getByText`: the scope element itself can match
97
+ (`css('td').text('Domestic', { exact: true })` finds the cell), and an exact
98
+ string also matches an element one of whose own text runs equals it
99
+ (`<span><span>|</span>TS-1</span>` for `text('TS-1', { exact: true })`).
100
+ - Prefer role + name, then label / placeholder / text, then test id, then CSS.
101
+ - Hidden elements (`display: none`, `aria-hidden`) have no role: `button('X')`
102
+ does not match a hidden button. Add `.visible()` to `text()` / `css()`
103
+ locators when hidden copies exist.
104
+
105
+ ## Steps
106
+
107
+ | Step | Notes |
108
+ |---|---|
109
+ | `loc.click()` | visible, enabled, not covered, scrolled into view, then a real mouse click; `{ button: 'right', position: { x, y }, force: true }` |
110
+ | `loc.dblclick()` / `loc.hover()` | same pointer options as `click` |
111
+ | `loc.type('text')` | focuses, then one key press per character (`pressSequentially`) |
112
+ | `loc.blur()` | moves focus away (`blur` / `focusout`) |
113
+ | `loc.upload([{ name, text \| base64, mimeType? }])` | sets the files of an `<input type=file>`; a string is a path on the browser's machine |
114
+ | `loc.dropFiles([{ name, text \| base64, mimeType? }])` | external file drag into a visible target; real dragenter/dragover/drop and File data, preserving inline names |
115
+ | `loc.drag(target)` | native drag and drop onto `target`, else a pointer drag |
116
+ | `loc.wheel(deltaX, deltaY)` / `mouse('click', { x, y })` | mouse wheel over the element / raw mouse at viewport coordinates (`click`, `dblclick`, `move`, `down`, `up`, `wheel`) |
117
+ | `loc.fill('text')` | replaces the value of an input, textarea or contenteditable |
118
+ | `loc.press('Enter')` | focuses, then a key or chord: `Escape`, `Tab`, `ArrowDown`, `Shift+Tab`, `ControlOrMeta+A`, `a` |
119
+ | `loc.check()` / `loc.uncheck()` | checkbox or radio; no-op when already in that state |
120
+ | `loc.focus()` | |
121
+ | `loc.read('name', 'text' \| 'value' \| 'count' \| 'visible' \| 'box' \| 'name' \| 'focused' \| 'enabled')` | value lands in `r.values.name`; `loc.read('c', 'css', 'color')` for a computed style |
122
+ | `check(loc).visible()` / `.hidden()` | |
123
+ | `check(loc).text('…')` / `.containsText('…')` / `.value('…')` / `.count(n)` / `.checked()` | `check(loc).not.visible()` negates |
124
+ | `check(loc).css('color', 'rgb(0, 0, 0)')` / `.focused()` / `.name('…')` / `.enabled()` / `.disabled()` | computed style, focus, accessible name, disabled state |
125
+ | `check.request('POST', '/exact/path').status(200)` | path is exact (no query); a `RegExp` tests the full URL; `.status([200, 304])` for any of several; `.count(n)` for exactly n; `.body(…)`, `.bodyKeys([…])`, `.headers({ … })`; `.state('started')` also counts requests in flight |
126
+ | `goto(url)` / `route(path)` | full navigation / same-document route change; `route(path, { settle: false })` does not wait for its requests |
127
+ | `reload()` / `waitForUrl('/positions/')` | reload and wait for `load` / wait until the URL contains (or matches) it |
128
+ | `loaded()` | wait for the page's document to load (added for you on a page still booting) |
129
+ | `idle({ timeout })` | wait until the page is quiet, as after an action; never fails |
130
+ | `mock('/api/items', { json: [] })` / `unmock(url?)` | answer matching requests (`status`, `body`, `headers`, `delay`, `abort`, `times`); `{ passthrough: true }` lets them through, `{ patch: { … } }` rewrites the real response, `{ hold: 'name' }` holds them until `release('name')` |
131
+ | `download({ as, saveTo })` / `popup('name')` | wait for the next download / popup of the run |
132
+ | `viewport(w, h)` / `media({ colorScheme: 'dark' })` | resize the viewport / emulate media features mid-test |
133
+ | `matchScreenshot('name', { … })` / `loc.matchScreenshot('name')` | renders the page (or `clip`, `fullPage`, the element) in the browser and compares it with `__screenshots__/name.png` beside the test; see Screenshots below |
134
+ | `evaluate('expression', 'name')` | escape hatch: JSON value of a page expression into `r.values.name`; a returned promise is awaited (within the step timeout) and a rejection fails the step |
135
+
136
+ Pointer options (wire shape; the builders take the same names): `click` and
137
+ `dblclick` accept `button: 'right' | 'middle'` (a right click fires
138
+ `contextmenu` after the press and `auxclick` after the release, never
139
+ `click`, as Chromium does); `click`, `dblclick`, `hover`
140
+ and `wheel` accept `position: { x, y }` (an offset from the element's
141
+ top-left instead of its center) and `force: true` (skip the covered and
142
+ enabled checks). `hover` never needs an enabled element: hovering a disabled
143
+ input or an `aria-disabled` menu item works. An element wider than its
144
+ scroll container is clicked at the center of its visible part, as Playwright
145
+ does.
146
+
147
+ | Step (wire) | Notes |
148
+ |---|---|
149
+ | `{ act: { locator, action: 'wheel', deltaX, deltaY } }` | hovers the element, fires `wheel` there and scrolls the nearest scrollable container unless canceled |
150
+ | `{ mouse: { action: 'click' \| 'dblclick' \| 'move', x, y, button?, steps? } }` | `page.mouse.*` at viewport coordinates; `move` with `steps` sends intermediate moves |
151
+ | `{ mouse: { action: 'down' \| 'up', button?, x?, y? } }` | at the current pointer position unless `x`/`y` are given; buttons stay held across `move` steps until `up` |
152
+ | `{ mouse: { action: 'wheel', deltaX, deltaY, x?, y? } }` | wheel at the current pointer position (or `x`/`y`) |
153
+ | `{ viewport: { width, height } }` | same as `viewport(w, h)`: resizes mid-run, fires `resize` and `matchMedia` `change`, settles like an action |
154
+ | `{ media: { colorScheme?, reducedMotion?, media? } }` | same as `media({ … })`: replaces the whole emulation (an omitted field resets: `light`, `no-preference`, `screen`); `media: 'print'` makes `print` queries match; `matchMedia(…)` `change` listeners fire |
155
+ | `{ page: { target: 'tab' \| targetId \| 'main' } }` | the following steps drive another page of the context: the `as` name of an earlier `popup('tab')`, a target id, or `main` (the page the run started on); add `waitForUrl` / `loaded()` after switching to a fresh popup. Builder: `switchTo('tab')`, `switchTo('main')` |
156
+
157
+ Every action and check takes `{ timeout }` (ms); `page.run(steps, { timeout })`
158
+ sets the default for the run. Pass `{ settle: false }` to an action whose
159
+ requests never finish (polling, streaming) or that you do not want to wait for.
160
+
161
+ ### Requests and mocks in detail
162
+
163
+ - A `RegExp` anywhere (locators, `mock`, `waitForUrl`, `check.request`) runs
164
+ with JavaScript semantics: look-ahead `(?=…)`, negative look-ahead `(?!…)`,
165
+ look-behind, backreferences and named groups work; flags `i`, `m`, `s`.
166
+ - `check.request(…)` and the reply's `requests` carry `requestHeaders`,
167
+ `postData` (text; base64 with `postDataBase64: true` when not UTF-8; cut at
168
+ 1 MiB with `postDataTruncated: true`) and `responseHeaders`.
169
+ `.bodyKeys(['a', 'b'])` wants exactly these top-level JSON keys;
170
+ `.headers({ 'X-Key': /^k\d$/ })` matches names case-insensitively.
171
+ - `.state('started')` counts a request as soon as it is sent, so
172
+ `.count(1).state('started')` holds while a mock still delays or holds it.
173
+ - `mock(url, { patch: { status, headers, json, body } })` sends the request to
174
+ the real backend and rewrites its response; `json` is a JSON merge patch
175
+ (`null` deletes a key) onto a JSON object body, else the new body.
176
+ - `mock(url, { hold: 'save', json: … })` keeps the request unsent until
177
+ `release('save')`, then answers it (or sends it on, without a response
178
+ option); with `passthrough: true` (or `patch`) the request reaches the
179
+ backend and its response is held instead. `release` waits until something
180
+ is held, so it can come before the action's request starts; holds outlive
181
+ the run. The native `advanceTime` step explicitly advances page Date and timers;
182
+ mock `delay` remains a network delay.
183
+
184
+ ## Porting from Playwright
185
+
186
+ | Playwright | @povio/lightning-panda |
187
+ |---|---|
188
+ | `page.getByRole('button', { name: 'X' })` | `button('X')` |
189
+ | `locator.filter({ visible: true }).first()` | `loc.visible().first()` |
190
+ | `page.getByRole('dialog').filter({ hasText }).last().getByRole('button', …)` | `dialog().hasText(…).last().button(…)` |
191
+ | `await expect(loc).toBeVisible()` / `toHaveText` / `toHaveCount` | `check(loc).visible()` / `.text()` / `.count()` |
192
+ | `await expect.poll(() => loc.count()).toBe(1)` | `check(loc).count(1)` |
193
+ | `Promise.all([page.waitForResponse(…), loc.click()])` | `loc.click()` then `check.request(method, path).status(200)` |
194
+ | `browser.newContext({ storageState }) + newPage + goto` | `lp.profile(...)` once, then `lp.page({ as, route })` |
195
+ | `page.evaluate(fn)` | `evaluate('expression', 'name')` (an expression string, no closures; promises are awaited) |
196
+ | `page.addInitScript(src)` | `lp.page({ as, initScripts: [src] })`, or `await page.addInitScript(src)` before a `goto` / `reload` |
197
+ | `page.waitForURL(url)` | `waitForUrl(url)` |
198
+ | `page.route(url, r => r.fulfill(…))` | `mock(url, { … })` as a step |
199
+ | `frameLocator('#editor').getByRole(…)` | `css('#editor').frame().role(…)` |
200
+ | helpers that retry clicks or re-open dropdowns | delete them: actions wait for actionability and settle |
201
+
202
+ ## When a run fails
203
+
204
+ `page.run` throws `RunError` with the step index and why that step was still
205
+ waiting when it timed out, for example:
206
+
207
+ - `matched no element`: wrong locator, or the UI never got there; check the
208
+ step before it.
209
+ - `strict mode violation: … matched 3 elements: <button> "Edit", …`: scope or
210
+ filter the locator.
211
+ - `<div> intercepts pointer events`: an overlay covers the element; close it
212
+ first or click what the user would click.
213
+ - `element is disabled` / `not editable` / `not visible`.
214
+ - `no matching request yet` / `last match was {…status: 500…}`: the request
215
+ check; the reply's `requests` lists everything the page requested.
216
+
217
+ Do not raise timeouts to fix a failure: a step waits only for its own
218
+ condition, so a timeout means the condition never held.
219
+
220
+ ## Do not
221
+
222
+ - Do not add waits, sleeps or retry loops; do not wrap steps in try/catch.
223
+ - Do not drive login or seeding through the UI; use the profile and the API.
224
+ - Do not send one `run` per step "to be safe": it costs a round trip per step
225
+ and request checks only see their own run.
226
+ - Do not share a page between tests; take a new one per test.
227
+
228
+ ## Screenshots
229
+
230
+ Visual checks run in the browser, in the same `run` as the steps that lead
231
+ to the state (needs a browser built with the `render` feature):
232
+
233
+ ```ts
234
+ await page.run([
235
+ label('Email').fill('ada@example.com'),
236
+ matchScreenshot('login-filled'), // viewport
237
+ css('.card').matchScreenshot('login-card'), // one element's box
238
+ matchScreenshot('login-full', { fullPage: true }),
239
+ ]);
240
+ await page.matchScreenshot('dashboard', { maxDiffPixels: 20 }); // one-step form
241
+ ```
242
+
243
+ - Baselines are `__screenshots__/<name>.png` (+ `<name>.json` with the
244
+ display-list hash) beside the test file; `dir` overrides. Commit them.
245
+ - First run (no baseline): the image is written and the test fails. Run once
246
+ with `LP_UPDATE_SNAPSHOTS=1` (or `bun test -u`) to write or refresh all.
247
+ - A mismatch throws `ScreenshotMismatchError` (`.result.diffPixels`,
248
+ `.result.files`) and writes `<name>.actual.png` and `<name>.diff.png`
249
+ (differing pixels in red). Look at the diff before updating a baseline.
250
+ - Rendering is deterministic on the CPU: compare exactly by default. Use
251
+ `threshold` (0-1, per channel) or `maxDiffPixels` only for known noise.
252
+ - What is captured: the live DOM after script, every stylesheet (including
253
+ ones added or edited by script), input/textarea/select state, the window
254
+ scroll position, viewport and device scale factor, images and fonts loaded
255
+ through the page's network (mocks apply). Not captured: canvas and video
256
+ pixels, iframe contents, element scroll offsets, `:hover`/`:focus` styles,
257
+ the caret, running animations. Pixels come from lightningpanda's renderer
258
+ (Blitz), never compare them with Chromium screenshots.
259
+
260
+ `downloadCount(as)` reads the current page's native initiated-download count without consuming positive download waits. Compare snapshots before and after a settled preview action to assert no new downloads; the count persists across batches on the same page.
package/NOTICE ADDED
@@ -0,0 +1,27 @@
1
+ lightningpanda
2
+ Copyright (C) 2026 Povio and contributors
3
+
4
+ This project is a Rust rewrite of Lightpanda Browser
5
+ (https://github.com/lightpanda-io/browser), Copyright (C) 2023-2026
6
+ Lightpanda (Selecy SAS), Francis Bouvier, Pierre Tachoire; licensed under the GNU Affero General Public License v3.0 only.
7
+ The design, module structure, behaviour and tests are derived from the
8
+ upstream source at commit 2ad38773fd674d3c9cea26a263b814ee46f6ba84.
9
+
10
+ lightningpanda is distributed under the same license, AGPL-3.0-only; see
11
+ LICENSE. Network users of a modified version must be offered its source
12
+ (AGPL section 13).
13
+
14
+ It dynamically links JavaScriptCore (WebKit, LGPL-2.0+/BSD). Standard macOS
15
+ archives use the operating-system framework. The optional macOS ARM64 fork
16
+ archive includes a separately built, patched JavaScriptCore shared library.
17
+ That variant includes upstream license files and identifies the matching
18
+ JavaScriptCore source archive supplied with the same release. The source
19
+ archive retains upstream notices and includes the exact WebKit revision,
20
+ Lightningpanda patches and build instructions.
21
+
22
+ crates/lp-dom/src/stylo/ua.css is the HTML user-agent stylesheet of
23
+ blitz-dom (https://github.com/DioxusLabs/blitz), derived from Mozilla
24
+ Firefox's html.css, under the Mozilla Public License 2.0 (header kept),
25
+ with Chrome's defaults for links and form controls.
26
+ crates/lp-dom/src/stylo/hints.rs adapts blitz-dom's presentational hints
27
+ (MIT OR Apache-2.0). Style computation uses Stylo (MPL-2.0).
package/README.md CHANGED
@@ -1,3 +1,116 @@
1
- # Temporary Holding Version
1
+ # @povio/lightning-panda
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Lightningpanda browser CLI and typed testing API in one package.
4
+
5
+ E2E tests for `bun test` on Lightningpanda. A test sends its whole scenario as
6
+ one `LP.run` command: the browser runs every step itself (locating, acting,
7
+ waiting for the app to settle, checking) and replies once.
8
+
9
+ ```ts
10
+ import { Lightningpanda, button, check, dialog, text } from '@povio/lightning-panda';
11
+
12
+ const lp = await Lightningpanda.connect('ws://127.0.0.1:9222/'); // or Lightningpanda.launch()
13
+ await lp.profile('employee', { url: 'http://localhost:3000/platform', localStorage: { token } });
14
+
15
+ test.concurrent('closes a position', async () => {
16
+ const position = await api.createPosition();
17
+ await using page = await lp.page({ as: 'employee', route: `/positions/${position.id}` });
18
+ const r = await page.run([
19
+ check(text(position.number)).visible(),
20
+ button('Settings').press('Enter'),
21
+ dialog().hasText('will be closed').button('Close Position', { exact: true }).click(),
22
+ check.request('POST', `/positions/${position.id}/close`).status(200),
23
+ check(button('Reopen Position')).count(1),
24
+ ]);
25
+ });
26
+ ```
27
+
28
+ - **Pages.** `lp.profile(name, …)` keeps signed-in pages booted ahead
29
+ (`Lightningpanda.registerPreparedPage`); `lp.page({ as, route })` takes one
30
+ and attaches in one call. `route` is a same-document navigation
31
+ (`pushState` + `popstate`) run as the first step of the first `run`, so the
32
+ app does not boot again. `url` navigates instead. With no page waiting, one
33
+ is booted for the test.
34
+ - **Connections.** `connect(ws, { connections: 4 })` opens one browser thread
35
+ per connection, each with its own pools. A test takes a page from the
36
+ connection with the fewest open pages, and a connection boots replacements
37
+ as soon as it has no open page, in parallel with tests on the others.
38
+ - **Fork mode.** Against `lightningpanda serve --fork` each `lp.page()` opens
39
+ its own connection, which the server forks from a process that booted the
40
+ profile once: the page starts booted in about 20 ms, shares nothing with
41
+ other tests, and its process exits when the page closes. `connections` and
42
+ `pool` then make no difference. `setup` steps in a profile run once on the
43
+ booted page (sign-in flows, opening a view, filling filters) and every page
44
+ starts from their result. `base: 'otherProfile'` builds a snapshot on top of
45
+ another one: only its own `setup` runs, on a fork of that snapshot (boot on
46
+ the login page once, sign in once, then one snapshot per test file).
47
+ - **Locators** are data: `css`, `testId`, `text`, `label`, `placeholder`,
48
+ `role` (and `button`, `dialog`, `link`, …), chained inside one another
49
+ (`dialog().button('OK')`), filtered (`.visible()`, `.hasText()`,
50
+ `.hasNotText()`, `.has()`, `.hasNot()`) and picked (`.first()`, `.last()`,
51
+ `.nth(i)`). Strings match case-insensitive substrings unless `{ exact: true }`;
52
+ a `RegExp` (flags `i`, `m`, `s`) tests the whitespace-normalized text.
53
+ - **Steps**: `.click()`, `.fill(v)`, `.press('Enter' | 'Shift+Tab' | …)`,
54
+ `.check()`, `.uncheck()`, `.focus()`, `.read(as)`; `check(loc).visible()`,
55
+ `.hidden()`, `.text()`, `.containsText()`, `.value()`, `.count()`,
56
+ `.checked()`, `.not.…`; `check.request(method, path).status(n)` /
57
+ `.count(n)`; `goto(url)`, `route(url)`, `evaluate(expr, as)`.
58
+ - **Waiting** happens in the browser. A step that does not hold yet is tried
59
+ again when the DOM changes; an action settles before the next step (the
60
+ page goes quiet: no queued task, no request the action started, no timeout
61
+ due within 50 ms, no pending animation frame or navigation); a request
62
+ check waits for the request log. Each step has its own timeout (5 s default,
63
+ `page.run(steps, { timeout })` or per step).
64
+ - **The reply** has `values` (reads), `results` (per step), `requests` (every
65
+ request the page made during the run), `screenshots` and `ms`. A failure
66
+ throws `RunError` naming the step and why it was still waiting.
67
+ - **Screenshots** (a browser built with the `render` feature):
68
+ `matchScreenshot('home')`, `css('#card').matchScreenshot('card')` or
69
+ `await page.matchScreenshot('home', { fullPage: true })` render the page in
70
+ the browser (Blitz, CPU) and compare it there with
71
+ `__screenshots__/home.png` beside the test file. A baseline is sent once
72
+ per connection; a render whose display list matches the one recorded in
73
+ `home.json` passes without rasterizing. A missing baseline is written and
74
+ fails the test; `LP_UPDATE_SNAPSHOTS=1` (or `bun test -u`) rewrites
75
+ baselines; a mismatch throws `ScreenshotMismatchError` and writes
76
+ `home.actual.png` and `home.diff.png`. Options: `threshold` (0-1 per
77
+ channel), `maxDiffPixels`, `maxDiffPixelRatio`, `clip`, `fullPage`, `dir`.
78
+ `expect.extend(screenshotMatchers)` adds `await expect(page).toMatchScreenshot(name)`.
79
+ Baselines come from lightningpanda's renderer: Chromium screenshots do not
80
+ match them pixel for pixel.
81
+
82
+ ## Install
83
+
84
+ ```sh
85
+ npm install --save-dev @povio/lightning-panda
86
+ # Or: bun add --dev @povio/lightning-panda
87
+ npx lightningpanda serve --port 9222
88
+ ```
89
+
90
+ For release candidates, install `@povio/lightning-panda@next`.
91
+
92
+ The package bundles browsers for **macOS 14+ on Apple Silicon and Intel**, with
93
+ layout, screenshots and PDF rendering enabled. No Rust toolchain, install
94
+ scripts or binary downloads are needed. The API supports Node.js 22+ and Bun;
95
+ Bun loads TypeScript sources, while Node loads compiled JavaScript and types.
96
+ On other operating systems, use `Lightningpanda.connect()` with a remote
97
+ browser. Installing/importing the client does not launch a local browser.
98
+
99
+ `Lightningpanda.launch()` selects `bin`, then `$LIGHTNINGPANDA_BIN`, then the
100
+ bundled browser for the current architecture, then a sibling browser in a
101
+ legacy release archive. The CLI also respects `$LIGHTNINGPANDA_BIN`.
102
+
103
+ The bundled browsers use macOS's system JavaScriptCore. **Fork mode requires
104
+ a separately built patched JavaScriptCore browser**; select that browser with
105
+ `bin` or `$LIGHTNINGPANDA_BIN` when using `args: ['--fork']`.
106
+
107
+ The package is licensed under AGPL-3.0-only; see the included `LICENSE` and
108
+ `NOTICE`. Maintainers: see
109
+ [publishing instructions](https://github.com/povio/lightningpanda/blob/main/docs/NPM_PUBLISHING.md).
110
+
111
+ Coding agents: read [LLM.md](LLM.md) before writing tests.
112
+
113
+ `examples/positions-close.test.ts` is TOS's `positions-close.e2e.ts` ported.
114
+ `bench/scripts/cdp-perf.mjs --filter '^(pw\.)?tos\.'` runs the same flow
115
+ against a TOS-shaped app in the bench site, as Playwright, step by step, as one
116
+ run, and as one run on a pre-booted page.
@@ -0,0 +1,7 @@
1
+ /** Shared by the CLI and launch(); never fall back to our own CLI on PATH. */
2
+ export declare function resolveBinary({ platform, arch, root, env, }?: {
3
+ platform?: string;
4
+ arch?: string;
5
+ root?: string;
6
+ env?: NodeJS.ProcessEnv;
7
+ }): string;
package/dist/cdp.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ export declare class CdpError extends Error {
2
+ readonly method: string;
3
+ readonly code: number;
4
+ /** The error's `data` (a failed `matchScreenshot` carries its images). */
5
+ readonly data?: unknown | undefined;
6
+ constructor(method: string, code: number, message: string,
7
+ /** The error's `data` (a failed `matchScreenshot` carries its images). */
8
+ data?: unknown | undefined);
9
+ }
10
+ export declare class Cdp {
11
+ private ws;
12
+ private nextId;
13
+ private pending;
14
+ private closed;
15
+ private constructor();
16
+ static open(url: string): Promise<Cdp>;
17
+ send<T = any>(method: string, params?: object, sessionId?: string): Promise<T>;
18
+ /** Sends without waiting; a failure is ignored (cleanup). */
19
+ post(method: string, params?: object, sessionId?: string): void;
20
+ close(): void;
21
+ private onMessage;
22
+ private fail;
23
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { spawn } from "node:child_process";
5
+
6
+ // src/binary.ts
7
+ import { statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ function resolveBinary({
11
+ platform = process.platform,
12
+ arch = process.arch,
13
+ root = fileURLToPath(new URL("../", import.meta.url)),
14
+ env = process.env
15
+ } = {}) {
16
+ if (env.LIGHTNINGPANDA_BIN)
17
+ return env.LIGHTNINGPANDA_BIN;
18
+ const bundled = join(root, "native", `${platform}-${arch}`, "lightningpanda");
19
+ if (statSync(bundled, { throwIfNoEntry: false })?.isFile())
20
+ return bundled;
21
+ const sibling = join(root, "..", "lightningpanda");
22
+ if (statSync(sibling, { throwIfNoEntry: false })?.isFile())
23
+ return sibling;
24
+ if (platform !== "darwin" || !["arm64", "x64"].includes(arch)) {
25
+ throw new Error(`Lightningpanda has no bundled browser for ${platform}-${arch}. The browser supports macOS 14+ on arm64 and x64. Use Lightningpanda.connect() for a remote browser, or set LIGHTNINGPANDA_BIN.`);
26
+ }
27
+ throw new Error(`Missing bundled browser: ${bundled}. Reinstall @povio/lightning-panda or set LIGHTNINGPANDA_BIN to a built browser.`);
28
+ }
29
+
30
+ // src/cli.ts
31
+ try {
32
+ const child = spawn(resolveBinary(), process.argv.slice(2), { stdio: "inherit" });
33
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
34
+ const forward = (signal) => {
35
+ child.kill(signal);
36
+ };
37
+ for (const signal of signals)
38
+ process.on(signal, forward);
39
+ const cleanup = () => {
40
+ for (const signal of signals)
41
+ process.off(signal, forward);
42
+ };
43
+ child.once("error", (error) => {
44
+ cleanup();
45
+ console.error(`lightningpanda: ${error.message}`);
46
+ process.exitCode = 1;
47
+ });
48
+ child.once("exit", (code, signal) => {
49
+ cleanup();
50
+ if (signal)
51
+ process.kill(process.pid, signal);
52
+ else
53
+ process.exitCode = code ?? 1;
54
+ });
55
+ } catch (error) {
56
+ console.error(`lightningpanda: ${error instanceof Error ? error.message : error}`);
57
+ process.exitCode = 1;
58
+ }