@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.
@@ -0,0 +1,35 @@
1
+ import type { ScreenshotStep } from './steps.js';
2
+ /** Write new baselines instead of comparing (`LP_UPDATE_SNAPSHOTS=1`, or `bun test --update-snapshots` / `-u`). */
3
+ export declare const updateMode: () => boolean;
4
+ export type ScreenshotResult = {
5
+ name: string;
6
+ pass: boolean;
7
+ /** Passed on the display-list hash: no raster, no pixel compare. */
8
+ hashHit: boolean;
9
+ /** No baseline was compared: the render was written as the baseline. */
10
+ recorded: boolean;
11
+ diffPixels: number;
12
+ sceneHash: string;
13
+ width: number;
14
+ height: number;
15
+ /** Browser-side timings, ms. */
16
+ ms: Record<string, number>;
17
+ /** The written baseline (recorded) or the images written on a mismatch. */
18
+ files?: {
19
+ baseline?: string;
20
+ actual?: string;
21
+ diff?: string;
22
+ };
23
+ };
24
+ export declare class ScreenshotMismatchError extends Error {
25
+ readonly result: ScreenshotResult;
26
+ constructor(message: string, result: ScreenshotResult);
27
+ }
28
+ /** A baseline as this connection last received it, by wire name. */
29
+ export type SentBaselines = Map<string, string>;
30
+ /** Fills `step.wire` from disk. */
31
+ export declare function prepare(step: ScreenshotStep, sent: SentBaselines): void;
32
+ /** A step's result from a successful run: writes recorded baselines. */
33
+ export declare function settle(step: ScreenshotStep, r: any): ScreenshotResult;
34
+ /** A failed run's `error.data` for this step: writes the actual image and the diff. */
35
+ export declare function mismatch(step: ScreenshotStep, data: any, message: string): ScreenshotMismatchError;
@@ -0,0 +1,372 @@
1
+ export type TextMatch = string | RegExp;
2
+ type Pattern = string | {
3
+ regex: string;
4
+ flags?: string;
5
+ };
6
+ /** The wire form of a locator (see `crates/lp-cdp/src/engine/driver.rs`). */
7
+ export type LocatorSpec = {
8
+ css?: string;
9
+ testId?: string;
10
+ testIdAttribute?: string;
11
+ text?: Pattern;
12
+ role?: string;
13
+ name?: Pattern;
14
+ label?: Pattern;
15
+ placeholder?: Pattern;
16
+ exact?: boolean;
17
+ within?: LocatorSpec;
18
+ filter?: {
19
+ visible?: boolean;
20
+ hasText?: Pattern;
21
+ hasNotText?: Pattern;
22
+ has?: LocatorSpec;
23
+ hasNot?: LocatorSpec;
24
+ };
25
+ nth?: number;
26
+ /** Search inside the document of the `<iframe>` this locator matches (in the parent document). */
27
+ frame?: LocatorSpec;
28
+ };
29
+ export type Step = Record<string, unknown>;
30
+ type Exact = {
31
+ exact?: boolean;
32
+ };
33
+ /** Anything that becomes one step of a run. */
34
+ export interface StepLike {
35
+ toStep(): Step;
36
+ }
37
+ export type ActOptions = {
38
+ settle?: boolean;
39
+ timeout?: number;
40
+ };
41
+ /**
42
+ * Pointer options of `click`, `dblclick`, `hover` and `wheel` (engine 0.0.10):
43
+ * `button` (left), `position` relative to the element's top-left corner
44
+ * (its center), `force` skips the actionability checks.
45
+ */
46
+ export type PointerOptions = ActOptions & {
47
+ button?: 'left' | 'right' | 'middle';
48
+ position?: {
49
+ x: number;
50
+ y: number;
51
+ };
52
+ force?: boolean;
53
+ };
54
+ /** A file for `upload`: inline contents (`text` or `base64`) and an optional type. */
55
+ export type UploadFile = {
56
+ name: string;
57
+ text?: string;
58
+ base64?: string;
59
+ mimeType?: string;
60
+ };
61
+ export type ReadWhat = 'text' | 'value' | 'count' | 'visible' | 'box' | 'css' | 'name' | 'focused' | 'enabled';
62
+ export declare class Locator {
63
+ readonly spec: LocatorSpec;
64
+ constructor(spec: LocatorSpec);
65
+ private with;
66
+ visible(visible?: boolean): Locator;
67
+ hasText(text: TextMatch): Locator;
68
+ hasNotText(text: TextMatch): Locator;
69
+ has(inner: Locator): Locator;
70
+ hasNot(inner: Locator): Locator;
71
+ nth(index: number): Locator;
72
+ first(): Locator;
73
+ last(): Locator;
74
+ /** `spec` scoped to this locator; a `frame` stays on the outermost locator. */
75
+ private inner;
76
+ locator(css: string): Locator;
77
+ role(role: string, name?: TextMatch, opts?: Exact): Locator;
78
+ button(name?: TextMatch, opts?: Exact): Locator;
79
+ link(name?: TextMatch, opts?: Exact): Locator;
80
+ textbox(name?: TextMatch, opts?: Exact): Locator;
81
+ text(text: TextMatch, opts?: Exact): Locator;
82
+ label(text: TextMatch, opts?: Exact): Locator;
83
+ placeholder(text: TextMatch, opts?: Exact): Locator;
84
+ testId(id: string): Locator;
85
+ /**
86
+ * Locators inside the document of the `<iframe>` this locator matches:
87
+ * `css('#editor').frame().button('Bold')` (Playwright's `frameLocator`).
88
+ */
89
+ frame(): FrameScope;
90
+ /** This locator, searched in the document of the `<iframe>` `frame` matches. */
91
+ inFrame(frame: Locator): Locator;
92
+ click(opts?: PointerOptions): StepLike;
93
+ /** Two clicks at the center (the second with click count 2). */
94
+ dblclick(opts?: PointerOptions): StepLike;
95
+ /** Moves the pointer to the element (tooltips, hover toolbars). */
96
+ hover(opts?: PointerOptions): StepLike;
97
+ fill(value: string, opts?: ActOptions): StepLike;
98
+ /** Focuses, then one key press per character (Playwright's `pressSequentially`). */
99
+ type(text: string, opts?: ActOptions): StepLike;
100
+ press(key: string, opts?: ActOptions): StepLike;
101
+ check(opts?: ActOptions): StepLike;
102
+ uncheck(opts?: ActOptions): StepLike;
103
+ focus(): StepLike;
104
+ blur(): StepLike;
105
+ /**
106
+ * Sets the files of an `<input type=file>`: inline files, or a path (several:
107
+ * one per line) on the machine running Lightningpanda.
108
+ */
109
+ upload(files: UploadFile[] | string, opts?: ActOptions): StepLike;
110
+ dropFiles(files: UploadFile[] | string, opts?: ActOptions): StepLike;
111
+ /** Drags the element onto `to`: native drag and drop when it is a drag source, else a pointer drag. */
112
+ drag(to: Locator, opts?: ActOptions): StepLike;
113
+ /** Scrolls with the mouse wheel over the element (engine 0.0.10). */
114
+ wheel(deltaX: number, deltaY: number, opts?: ActOptions): StepLike;
115
+ /** Compares this element's box with the baseline `name` (see `matchScreenshot`). */
116
+ matchScreenshot(name: string, opts?: ScreenshotOptions): ScreenshotStep;
117
+ /**
118
+ * Reads a value into the run's `values[as]`: `read('n', 'css', 'color')`
119
+ * for a computed style property, `'name'` for the accessible name.
120
+ */
121
+ read(as: string, what?: ReadWhat, property?: string): StepLike;
122
+ }
123
+ /** Locators inside an `<iframe>`'s document (`loc.frame()`). */
124
+ export declare class FrameScope {
125
+ private frameLocator;
126
+ constructor(frameLocator: Locator);
127
+ locator(css: string): Locator;
128
+ role(role: string, name?: TextMatch, opts?: Exact): Locator;
129
+ button(name?: TextMatch, opts?: Exact): Locator;
130
+ link(name?: TextMatch, opts?: Exact): Locator;
131
+ textbox(name?: TextMatch, opts?: Exact): Locator;
132
+ text(text: TextMatch, opts?: Exact): Locator;
133
+ label(text: TextMatch, opts?: Exact): Locator;
134
+ placeholder(text: TextMatch, opts?: Exact): Locator;
135
+ testId(id: string): Locator;
136
+ /** A nested `<iframe>` inside this one. */
137
+ frame(inner: Locator): FrameScope;
138
+ }
139
+ export declare const css: (selector: string) => Locator;
140
+ export declare const testId: (id: string, attribute?: string) => Locator;
141
+ export declare const text: (t: TextMatch, opts?: Exact) => Locator;
142
+ export declare const label: (t: TextMatch, opts?: Exact) => Locator;
143
+ export declare const placeholder: (t: TextMatch, opts?: Exact) => Locator;
144
+ export declare const role: (r: string, name?: TextMatch, opts?: Exact) => Locator;
145
+ export declare const button: (name?: TextMatch, opts?: Exact) => Locator;
146
+ export declare const link: (name?: TextMatch, opts?: Exact) => Locator;
147
+ export declare const dialog: (name?: TextMatch, opts?: Exact) => Locator;
148
+ export declare const heading: (name?: TextMatch, opts?: Exact) => Locator;
149
+ export declare const textbox: (name?: TextMatch, opts?: Exact) => Locator;
150
+ export declare const checkbox: (name?: TextMatch, opts?: Exact) => Locator;
151
+ export declare const combobox: (name?: TextMatch, opts?: Exact) => Locator;
152
+ export declare const option: (name?: TextMatch, opts?: Exact) => Locator;
153
+ export declare const row: (name?: TextMatch, opts?: Exact) => Locator;
154
+ export declare const cell: (name?: TextMatch, opts?: Exact) => Locator;
155
+ export declare const tab: (name?: TextMatch, opts?: Exact) => Locator;
156
+ export declare const menuitem: (name?: TextMatch, opts?: Exact) => Locator;
157
+ /** Locators inside the `<iframe>` `frame` matches: `inFrame(css('#editor')).button('Bold')`. */
158
+ export declare const inFrame: (frame: Locator) => FrameScope;
159
+ type CheckOpts = {
160
+ timeout?: number;
161
+ };
162
+ declare class Check {
163
+ private loc;
164
+ private negate;
165
+ constructor(loc: Locator, negate?: boolean);
166
+ get not(): Check;
167
+ private step;
168
+ visible(opts?: CheckOpts): StepLike;
169
+ hidden(opts?: CheckOpts): StepLike;
170
+ text(expected: string, opts?: CheckOpts): StepLike;
171
+ containsText(expected: string, opts?: CheckOpts): StepLike;
172
+ value(expected: string, opts?: CheckOpts): StepLike;
173
+ count(expected: number, opts?: CheckOpts): StepLike;
174
+ checked(expected?: boolean, opts?: CheckOpts): StepLike;
175
+ /** A computed style property: `check(button('Save')).css('background-color', 'rgb(1, 2, 3)')`. */
176
+ css(property: string, expected: string, opts?: CheckOpts): StepLike;
177
+ /** The element is the document's focused element. */
178
+ focused(expected?: boolean, opts?: CheckOpts): StepLike;
179
+ /** The accessible name (exact after whitespace normalization). */
180
+ name(expected: string, opts?: CheckOpts): StepLike;
181
+ enabled(expected?: boolean, opts?: CheckOpts): StepLike;
182
+ disabled(opts?: CheckOpts): StepLike;
183
+ }
184
+ declare class RequestCheck implements StepLike {
185
+ private m;
186
+ constructor(m: Record<string, unknown>);
187
+ /** One status, or any of several (`[200, 304]`). */
188
+ status(status: number | number[]): RequestCheck;
189
+ count(count: number): RequestCheck;
190
+ /** The request body contains (or matches) `body` (engine 0.0.10). */
191
+ body(body: TextMatch): RequestCheck;
192
+ /** The request body is a JSON object with exactly these top-level keys (engine 0.0.10). */
193
+ bodyKeys(keys: string[]): RequestCheck;
194
+ /** Request headers (case-insensitive names) whose values contain (or match) these (engine 0.0.10). */
195
+ headers(headers: Record<string, TextMatch>): RequestCheck;
196
+ /**
197
+ * `started` also counts requests still in flight (`count` included);
198
+ * `finished` (the default) only those with a response or an error (engine 0.0.10).
199
+ */
200
+ state(state: 'started' | 'finished'): RequestCheck;
201
+ /** Include prior runs of this page only; batch scope is the default. */
202
+ scope(scope: 'batch' | 'context'): RequestCheck;
203
+ timeout(timeout: number): RequestCheck;
204
+ toStep(): Step;
205
+ }
206
+ /**
207
+ * `check(locator).visible()`, `.text("…")`, `.count(n)`, `.not.visible()`;
208
+ * `check.request("POST", "/path").status(200)` checks the run's request log
209
+ * (`path` is exact, a RegExp tests the full URL).
210
+ */
211
+ export declare const check: ((loc: Locator) => Check) & {
212
+ request: (method: string, path: string | RegExp) => RequestCheck;
213
+ };
214
+ export declare const goto: (url: string, waitUntil?: "load" | "domcontentloaded") => StepLike;
215
+ /**
216
+ * Same-document navigation (pushState + popstate), as a client-side router
217
+ * sees a link. Settles on the requests it starts unless `settle: false`.
218
+ */
219
+ export declare const route: (url: string, opts?: ActOptions) => StepLike;
220
+ /**
221
+ * Evaluates an expression in the page; its JSON value lands in `values[as]`.
222
+ * A returned promise is awaited (within `timeout`); a rejection fails the step.
223
+ */
224
+ export declare const evaluate: (expression: string, as?: string, opts?: CheckOpts) => StepLike;
225
+ /**
226
+ * Waits for the current document's `load`. On a page taken from a pool while
227
+ * still booting (`lp.page()` adds it), it waits for the profile URL's
228
+ * navigation to commit and load, never for the `about:blank` before it.
229
+ */
230
+ export declare const loaded: (opts?: CheckOpts) => StepLike;
231
+ /**
232
+ * Waits until the page is quiet: no queued task, request in flight, timeout due
233
+ * within 50 ms, pending animation frame or unfinished navigation. Never fails;
234
+ * when `timeout` passes first its result has `settled: false` and `pending`.
235
+ */
236
+ export declare const idle: (opts?: {
237
+ timeout?: number;
238
+ }) => StepLike;
239
+ /** Reloads the current document and waits for its `load`. */
240
+ export declare const reload: (opts?: CheckOpts) => StepLike;
241
+ /** Waits until the page URL contains (or matches) `url` (Playwright's `waitForURL`). */
242
+ export declare const waitForUrl: (url: TextMatch, opts?: CheckOpts) => StepLike;
243
+ export type MockOptions = {
244
+ method?: string;
245
+ status?: number;
246
+ /** Serialized as the body (content type `application/json`). */
247
+ json?: unknown;
248
+ body?: string;
249
+ contentType?: string;
250
+ headers?: Record<string, string>;
251
+ /** Milliseconds before the response (loading states). */
252
+ delay?: number;
253
+ /** Fail the request instead, with a `Network.ErrorReason` such as `Failed` or `ConnectionRefused`. */
254
+ abort?: string;
255
+ /** Answer only the first n matching requests. */
256
+ times?: number;
257
+ /** Let matching requests reach the network unchanged (an exception to a broader mock; engine 0.0.10). */
258
+ passthrough?: boolean;
259
+ /** Hold matching requests under this name until a `release(name)` step (engine 0.0.10). */
260
+ hold?: string;
261
+ /** Let the request reach the server, then rewrite the fields given of its response (engine 0.0.10). */
262
+ patch?: {
263
+ status?: number;
264
+ headers?: Record<string, string>;
265
+ json?: unknown;
266
+ body?: string;
267
+ };
268
+ };
269
+ /**
270
+ * Answers requests of this page's context whose URL contains `url` (or
271
+ * matches the RegExp) with the given response (Playwright's `page.route` +
272
+ * `fulfill`): `mock('/api/items', { json: [] })`.
273
+ */
274
+ export declare const mock: (url: TextMatch, opts?: MockOptions) => StepLike;
275
+ /** Removes the mocks matching `url` (all of them without one). */
276
+ export declare const unmock: (url?: TextMatch) => StepLike;
277
+ /** Lets the requests held by `mock(url, { hold: name })` go on (engine 0.0.10). */
278
+ export declare const release: (hold: string) => StepLike;
279
+ /** Resizes the viewport mid-test; layout and `resize` listeners see the new size (engine 0.0.10). */
280
+ export declare const viewport: (width: number, height: number) => StepLike;
281
+ export type MediaOptions = {
282
+ colorScheme: 'light' | 'dark' | 'no-preference';
283
+ reducedMotion?: 'reduce' | 'no-preference';
284
+ /** `screen` or `print`. */
285
+ media?: string;
286
+ };
287
+ /** Emulates media features (`prefers-color-scheme` and friends) (engine 0.0.10). */
288
+ export declare const media: (opts: MediaOptions) => StepLike;
289
+ /** Reads this page's native initiated-download count without consuming download records. */
290
+ export declare const downloadCount: (as: string) => StepLike;
291
+ /**
292
+ * Waits for the next download the page starts during the run; its
293
+ * `{ url, filename, text, ... }` lands in `values[as]`, and the file in
294
+ * `saveTo` (a directory) when given.
295
+ */
296
+ export declare const download: (opts?: {
297
+ saveTo?: string;
298
+ as?: string;
299
+ timeout?: number;
300
+ }) => StepLike;
301
+ /**
302
+ * The following steps drive another page of the context (engine 0.0.10):
303
+ * the `as` name of an earlier `popup(as)`, a target id, or `'main'` for the
304
+ * page the run started on. Add `loaded()` or `waitForUrl()` after switching
305
+ * to a fresh popup.
306
+ */
307
+ export declare const switchTo: (target: string) => StepLike;
308
+ /** Waits for the next popup the page opens during the run; its target id lands in `values[as]`. */
309
+ export declare const popup: (as?: string, opts?: CheckOpts) => StepLike;
310
+ export type MouseAction = 'click' | 'dblclick' | 'move' | 'down' | 'up' | 'wheel';
311
+ export type MouseOptions = {
312
+ /** Viewport point; required for `click`, `dblclick` and `move`, else the pointer stays where it is. */
313
+ x?: number;
314
+ y?: number;
315
+ button?: 'left' | 'right' | 'middle';
316
+ /** `wheel`: scroll deltas. */
317
+ deltaX?: number;
318
+ deltaY?: number;
319
+ /** `move`: intermediate `mousemove` events. */
320
+ steps?: number;
321
+ settle?: boolean;
322
+ timeout?: number;
323
+ };
324
+ /** A raw mouse event at viewport coordinates (engine 0.0.10): `mouse('click', { x: 10, y: 20 })`. */
325
+ export declare const mouse: (action: MouseAction, opts?: MouseOptions) => StepLike;
326
+ /** Mouse wheel over an element (engine 0.0.10). */
327
+ export declare const wheel: (loc: Locator, deltaX: number, deltaY: number, opts?: ActOptions) => StepLike;
328
+ export type ScreenshotOptions = {
329
+ /** 0-1: how far a pixel's channels may differ and still match (default 0: exact). */
330
+ threshold?: number;
331
+ /** Differing pixels allowed (default 0). */
332
+ maxDiffPixels?: number;
333
+ /** Differing pixels allowed as a fraction of the image. */
334
+ maxDiffPixelRatio?: number;
335
+ /** A rectangle of the document, CSS px. */
336
+ clip?: {
337
+ x: number;
338
+ y: number;
339
+ width: number;
340
+ height: number;
341
+ };
342
+ /** The whole document, not only the viewport. */
343
+ fullPage?: boolean;
344
+ /** Where baselines live (default: `__screenshots__` beside the test file). */
345
+ dir?: string;
346
+ timeout?: number;
347
+ };
348
+ /**
349
+ * A `matchScreenshot` step: the page renders in the browser and is compared
350
+ * there with the baseline `<dir>/<name>.png`. `page.run` reads the baseline
351
+ * (sent once per connection), writes it when missing or in update mode, and
352
+ * writes `<name>.actual.png` and `<name>.diff.png` on a mismatch.
353
+ */
354
+ export declare class ScreenshotStep implements StepLike {
355
+ readonly name: string;
356
+ readonly opts: ScreenshotOptions & {
357
+ locator?: LocatorSpec;
358
+ };
359
+ /** Directory of the test file that built the step. */
360
+ readonly testDir: string;
361
+ /** Baseline fields `page.run` adds (`baseline`, `sceneHash`, `update`). */
362
+ wire: Record<string, unknown>;
363
+ constructor(name: string, opts: ScreenshotOptions & {
364
+ locator?: LocatorSpec;
365
+ },
366
+ /** Directory of the test file that built the step. */
367
+ testDir: string);
368
+ toStep(): Step;
369
+ }
370
+ /** Compares the viewport (or `clip`, or `fullPage`) with the baseline `name`. */
371
+ export declare const matchScreenshot: (name: string, opts?: ScreenshotOptions) => ScreenshotStep;
372
+ export {};
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: package/native/darwin-arm64/lightningpanda
3
+ size: 35434864 bytes
4
+ sha256: 1239cb423a30531603b31a780234232c28f4d0ec71c1366de2ffa323dbb314e7
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: package/native/darwin-x64/lightningpanda
3
+ size: 37847744 bytes
4
+ sha256: 2f4e69371305c4fdebc8763f65574113691006d8551c3fde85c519bc33864640
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@povio/lightning-panda",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.17",
4
+ "description": "Lightningpanda headless browser and typed E2E testing API",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/povio/lightningpanda.git",
9
+ "directory": "packages/lightning-panda"
10
+ },
11
+ "engines": { "node": ">=22" },
12
+ "publishConfig": { "access": "public" },
13
+ "bin": { "lightningpanda": "./dist/cli.js" },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "bun": "./src/index.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md",
26
+ "LLM.md",
27
+ "native",
28
+ "LICENSE",
29
+ "NOTICE"
30
+ ],
31
+ "scripts": {
32
+ "build": "bun build src/index.ts src/cli.ts --outdir dist --target node --format esm && tsc -p tsconfig.json",
33
+ "prepack": "bun run build && node ../../scripts/npm/prepare-package.mjs",
34
+ "test:package": "bun test test/binary.test.ts && node --test ../../scripts/npm/release.test.mjs",
35
+ "test": "bun test ./test"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.6.0"
40
+ }
41
+ }
package/src/binary.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /** Shared by the CLI and launch(); never fall back to our own CLI on PATH. */
6
+ export function resolveBinary({
7
+ platform = process.platform as string,
8
+ arch = process.arch as string,
9
+ root = fileURLToPath(new URL('../', import.meta.url)),
10
+ env = process.env,
11
+ }: { platform?: string; arch?: string; root?: string; env?: NodeJS.ProcessEnv } = {}): string {
12
+ if (env.LIGHTNINGPANDA_BIN) return env.LIGHTNINGPANDA_BIN;
13
+ const bundled = join(root, 'native', `${platform}-${arch}`, 'lightningpanda');
14
+ if (statSync(bundled, { throwIfNoEntry: false })?.isFile()) return bundled;
15
+ const sibling = join(root, '..', 'lightningpanda');
16
+ if (statSync(sibling, { throwIfNoEntry: false })?.isFile()) return sibling;
17
+ if (platform !== 'darwin' || !['arm64', 'x64'].includes(arch)) {
18
+ 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.`);
19
+ }
20
+ throw new Error(`Missing bundled browser: ${bundled}. Reinstall @povio/lightning-panda or set LIGHTNINGPANDA_BIN to a built browser.`);
21
+ }
package/src/cdp.ts ADDED
@@ -0,0 +1,72 @@
1
+ // Minimal CDP client over the runtime's WebSocket (Bun, Node >= 22): flat
2
+ // sessions, one promise per command, no event subscriptions.
3
+
4
+ export class CdpError extends Error {
5
+ constructor(
6
+ readonly method: string,
7
+ readonly code: number,
8
+ message: string,
9
+ /** The error's `data` (a failed `matchScreenshot` carries its images). */
10
+ readonly data?: unknown,
11
+ ) {
12
+ super(`${method}: ${message}`);
13
+ }
14
+ }
15
+
16
+ type Pending = { method: string; resolve: (v: any) => void; reject: (e: Error) => void };
17
+
18
+ export class Cdp {
19
+ private nextId = 1;
20
+ private pending = new Map<number, Pending>();
21
+ private closed: Error | null = null;
22
+
23
+ private constructor(private ws: WebSocket) {
24
+ ws.addEventListener('message', (ev) => this.onMessage(String(ev.data)));
25
+ ws.addEventListener('close', () => this.fail(new Error('CDP connection closed')));
26
+ ws.addEventListener('error', () => this.fail(new Error('CDP connection failed')));
27
+ }
28
+
29
+ static open(url: string): Promise<Cdp> {
30
+ return new Promise((resolve, reject) => {
31
+ const ws = new WebSocket(url);
32
+ ws.addEventListener('open', () => resolve(new Cdp(ws)), { once: true });
33
+ ws.addEventListener('error', () => reject(new Error(`cannot connect to ${url}`)), { once: true });
34
+ });
35
+ }
36
+
37
+ send<T = any>(method: string, params: object = {}, sessionId?: string): Promise<T> {
38
+ if (this.closed) return Promise.reject(this.closed);
39
+ const id = this.nextId++;
40
+ const msg: Record<string, unknown> = { id, method, params };
41
+ if (sessionId) msg.sessionId = sessionId;
42
+ return new Promise<T>((resolve, reject) => {
43
+ this.pending.set(id, { method, resolve, reject });
44
+ this.ws.send(JSON.stringify(msg));
45
+ });
46
+ }
47
+
48
+ /** Sends without waiting; a failure is ignored (cleanup). */
49
+ post(method: string, params: object = {}, sessionId?: string): void {
50
+ this.send(method, params, sessionId).catch(() => {});
51
+ }
52
+
53
+ close(): void {
54
+ this.ws.close();
55
+ }
56
+
57
+ private onMessage(text: string) {
58
+ const msg = JSON.parse(text);
59
+ if (typeof msg.id !== 'number') return; // events are not used
60
+ const p = this.pending.get(msg.id);
61
+ if (!p) return;
62
+ this.pending.delete(msg.id);
63
+ if (msg.error) p.reject(new CdpError(p.method, msg.error.code, msg.error.message, msg.error.data));
64
+ else p.resolve(msg.result);
65
+ }
66
+
67
+ private fail(error: Error) {
68
+ this.closed ??= error;
69
+ for (const p of this.pending.values()) p.reject(error);
70
+ this.pending.clear();
71
+ }
72
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { resolveBinary } from './binary.js';
4
+
5
+ try {
6
+ const child = spawn(resolveBinary(), process.argv.slice(2), { stdio: 'inherit' });
7
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
8
+ const forward = (signal: NodeJS.Signals) => { child.kill(signal); };
9
+ for (const signal of signals) process.on(signal, forward);
10
+ const cleanup = () => { for (const signal of signals) process.off(signal, forward); };
11
+ child.once('error', (error) => {
12
+ cleanup();
13
+ console.error(`lightningpanda: ${error.message}`);
14
+ process.exitCode = 1;
15
+ });
16
+ child.once('exit', (code, signal) => {
17
+ cleanup();
18
+ if (signal) process.kill(process.pid, signal);
19
+ else process.exitCode = code ?? 1;
20
+ });
21
+ } catch (error) {
22
+ console.error(`lightningpanda: ${error instanceof Error ? error.message : error}`);
23
+ process.exitCode = 1;
24
+ }