@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/LICENSE +661 -0
- package/LLM.md +260 -0
- package/NOTICE +27 -0
- package/README.md +115 -2
- package/dist/binary.d.ts +7 -0
- package/dist/cdp.d.ts +23 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +58 -0
- package/dist/index.d.ts +173 -0
- package/dist/index.js +948 -0
- package/dist/screenshots.d.ts +35 -0
- package/dist/steps.d.ts +372 -0
- package/native/darwin-arm64/lightningpanda +4 -0
- package/native/darwin-x64/lightningpanda +4 -0
- package/package.json +39 -4
- package/src/binary.ts +21 -0
- package/src/cdp.ts +72 -0
- package/src/cli.ts +24 -0
- package/src/index.ts +485 -0
- package/src/screenshots.ts +115 -0
- package/src/steps.ts +604 -0
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
|
-
#
|
|
1
|
+
# @povio/lightning-panda
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/binary.d.ts
ADDED
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
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
|
+
}
|