@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/dist/index.js ADDED
@@ -0,0 +1,948 @@
1
+ // src/index.ts
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:net";
4
+
5
+ // src/binary.ts
6
+ import { statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ function resolveBinary({
10
+ platform = process.platform,
11
+ arch = process.arch,
12
+ root = fileURLToPath(new URL("../", import.meta.url)),
13
+ env = process.env
14
+ } = {}) {
15
+ if (env.LIGHTNINGPANDA_BIN)
16
+ return env.LIGHTNINGPANDA_BIN;
17
+ const bundled = join(root, "native", `${platform}-${arch}`, "lightningpanda");
18
+ if (statSync(bundled, { throwIfNoEntry: false })?.isFile())
19
+ return bundled;
20
+ const sibling = join(root, "..", "lightningpanda");
21
+ if (statSync(sibling, { throwIfNoEntry: false })?.isFile())
22
+ return sibling;
23
+ if (platform !== "darwin" || !["arm64", "x64"].includes(arch)) {
24
+ 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.`);
25
+ }
26
+ throw new Error(`Missing bundled browser: ${bundled}. Reinstall @povio/lightning-panda or set LIGHTNINGPANDA_BIN to a built browser.`);
27
+ }
28
+
29
+ // src/cdp.ts
30
+ class CdpError extends Error {
31
+ method;
32
+ code;
33
+ data;
34
+ constructor(method, code, message, data) {
35
+ super(`${method}: ${message}`);
36
+ this.method = method;
37
+ this.code = code;
38
+ this.data = data;
39
+ }
40
+ }
41
+
42
+ class Cdp {
43
+ ws;
44
+ nextId = 1;
45
+ pending = new Map;
46
+ closed = null;
47
+ constructor(ws) {
48
+ this.ws = ws;
49
+ ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
50
+ ws.addEventListener("close", () => this.fail(new Error("CDP connection closed")));
51
+ ws.addEventListener("error", () => this.fail(new Error("CDP connection failed")));
52
+ }
53
+ static open(url) {
54
+ return new Promise((resolve, reject) => {
55
+ const ws = new WebSocket(url);
56
+ ws.addEventListener("open", () => resolve(new Cdp(ws)), { once: true });
57
+ ws.addEventListener("error", () => reject(new Error(`cannot connect to ${url}`)), { once: true });
58
+ });
59
+ }
60
+ send(method, params = {}, sessionId) {
61
+ if (this.closed)
62
+ return Promise.reject(this.closed);
63
+ const id = this.nextId++;
64
+ const msg = { id, method, params };
65
+ if (sessionId)
66
+ msg.sessionId = sessionId;
67
+ return new Promise((resolve, reject) => {
68
+ this.pending.set(id, { method, resolve, reject });
69
+ this.ws.send(JSON.stringify(msg));
70
+ });
71
+ }
72
+ post(method, params = {}, sessionId) {
73
+ this.send(method, params, sessionId).catch(() => {});
74
+ }
75
+ close() {
76
+ this.ws.close();
77
+ }
78
+ onMessage(text) {
79
+ const msg = JSON.parse(text);
80
+ if (typeof msg.id !== "number")
81
+ return;
82
+ const p = this.pending.get(msg.id);
83
+ if (!p)
84
+ return;
85
+ this.pending.delete(msg.id);
86
+ if (msg.error)
87
+ p.reject(new CdpError(p.method, msg.error.code, msg.error.message, msg.error.data));
88
+ else
89
+ p.resolve(msg.result);
90
+ }
91
+ fail(error) {
92
+ this.closed ??= error;
93
+ for (const p of this.pending.values())
94
+ p.reject(error);
95
+ this.pending.clear();
96
+ }
97
+ }
98
+
99
+ // src/steps.ts
100
+ var pattern = (t) => {
101
+ if (typeof t === "string")
102
+ return t;
103
+ const flags = t.flags.replace(/[gyu]/g, "");
104
+ return flags ? { regex: t.source, flags } : { regex: t.source };
105
+ };
106
+
107
+ class Locator {
108
+ spec;
109
+ constructor(spec) {
110
+ this.spec = spec;
111
+ }
112
+ with(filter) {
113
+ if (this.spec.nth !== undefined)
114
+ throw new Error("filter before first() / last() / nth()");
115
+ return new Locator({ ...this.spec, filter: { ...this.spec.filter, ...filter } });
116
+ }
117
+ visible(visible = true) {
118
+ return this.with({ visible });
119
+ }
120
+ hasText(text) {
121
+ return this.with({ hasText: pattern(text) });
122
+ }
123
+ hasNotText(text) {
124
+ return this.with({ hasNotText: pattern(text) });
125
+ }
126
+ has(inner) {
127
+ return this.with({ has: inner.spec });
128
+ }
129
+ hasNot(inner) {
130
+ return this.with({ hasNot: inner.spec });
131
+ }
132
+ nth(index) {
133
+ return new Locator({ ...this.spec, nth: index });
134
+ }
135
+ first() {
136
+ return this.nth(0);
137
+ }
138
+ last() {
139
+ return this.nth(-1);
140
+ }
141
+ inner(spec) {
142
+ const { frame, ...within } = this.spec;
143
+ return new Locator({ ...spec, within, ...frame ? { frame } : {} });
144
+ }
145
+ locator(css) {
146
+ return this.inner({ css });
147
+ }
148
+ role(role, name, opts = {}) {
149
+ return this.inner(roleSpec(role, name, opts));
150
+ }
151
+ button(name, opts) {
152
+ return this.role("button", name, opts);
153
+ }
154
+ link(name, opts) {
155
+ return this.role("link", name, opts);
156
+ }
157
+ textbox(name, opts) {
158
+ return this.role("textbox", name, opts);
159
+ }
160
+ text(text, opts = {}) {
161
+ return this.inner(textSpec(text, opts));
162
+ }
163
+ label(text, opts = {}) {
164
+ return this.inner({ label: pattern(text), ...exact(opts) });
165
+ }
166
+ placeholder(text, opts = {}) {
167
+ return this.inner({ placeholder: pattern(text), ...exact(opts) });
168
+ }
169
+ testId(id) {
170
+ return this.inner({ testId: id });
171
+ }
172
+ frame() {
173
+ return new FrameScope(this);
174
+ }
175
+ inFrame(frame) {
176
+ return new Locator({ ...this.spec, frame: frame.spec });
177
+ }
178
+ click(opts = {}) {
179
+ return act(this, "click", undefined, opts);
180
+ }
181
+ dblclick(opts = {}) {
182
+ return act(this, "dblclick", undefined, opts);
183
+ }
184
+ hover(opts = {}) {
185
+ return act(this, "hover", undefined, opts);
186
+ }
187
+ fill(value, opts = {}) {
188
+ return act(this, "fill", value, opts);
189
+ }
190
+ type(text, opts = {}) {
191
+ return act(this, "type", text, opts);
192
+ }
193
+ press(key, opts = {}) {
194
+ return act(this, "press", key, opts);
195
+ }
196
+ check(opts = {}) {
197
+ return act(this, "check", undefined, opts);
198
+ }
199
+ uncheck(opts = {}) {
200
+ return act(this, "uncheck", undefined, opts);
201
+ }
202
+ focus() {
203
+ return act(this, "focus", undefined, { settle: false });
204
+ }
205
+ blur() {
206
+ return act(this, "blur", undefined, { settle: false });
207
+ }
208
+ upload(files, opts = {}) {
209
+ return typeof files === "string" ? act(this, "upload", files, opts) : act(this, "upload", undefined, { ...opts, files });
210
+ }
211
+ dropFiles(files, opts = {}) {
212
+ return typeof files === "string" ? act(this, "dropFiles", files, opts) : act(this, "dropFiles", undefined, { ...opts, files });
213
+ }
214
+ drag(to, opts = {}) {
215
+ return act(this, "drag", undefined, { ...opts, to: to.spec });
216
+ }
217
+ wheel(deltaX, deltaY, opts = {}) {
218
+ return act(this, "wheel", undefined, { ...opts, deltaX, deltaY });
219
+ }
220
+ matchScreenshot(name, opts = {}) {
221
+ return new ScreenshotStep(name, { ...opts, locator: this.spec }, callerDir());
222
+ }
223
+ read(as, what = "text", property) {
224
+ if (what === "css" && !property)
225
+ throw new Error("read(as, 'css', property) needs the property");
226
+ const body = { locator: this.spec, read: what, as, ...property === undefined ? {} : { property } };
227
+ return { toStep: () => ({ read: body }) };
228
+ }
229
+ }
230
+
231
+ class FrameScope {
232
+ frameLocator;
233
+ constructor(frameLocator) {
234
+ this.frameLocator = frameLocator;
235
+ }
236
+ locator(css) {
237
+ return css_(css).inFrame(this.frameLocator);
238
+ }
239
+ role(role, name, opts = {}) {
240
+ return new Locator(roleSpec(role, name, opts)).inFrame(this.frameLocator);
241
+ }
242
+ button(name, opts) {
243
+ return this.role("button", name, opts);
244
+ }
245
+ link(name, opts) {
246
+ return this.role("link", name, opts);
247
+ }
248
+ textbox(name, opts) {
249
+ return this.role("textbox", name, opts);
250
+ }
251
+ text(text, opts = {}) {
252
+ return new Locator(textSpec(text, opts)).inFrame(this.frameLocator);
253
+ }
254
+ label(text, opts = {}) {
255
+ return new Locator({ label: pattern(text), ...exact(opts) }).inFrame(this.frameLocator);
256
+ }
257
+ placeholder(text, opts = {}) {
258
+ return new Locator({ placeholder: pattern(text), ...exact(opts) }).inFrame(this.frameLocator);
259
+ }
260
+ testId(id) {
261
+ return new Locator({ testId: id }).inFrame(this.frameLocator);
262
+ }
263
+ frame(inner) {
264
+ return inner.inFrame(this.frameLocator).frame();
265
+ }
266
+ }
267
+ var exact = (opts) => opts.exact ? { exact: true } : {};
268
+ var roleSpec = (role, name, opts) => ({
269
+ role,
270
+ ...name === undefined ? {} : { name: pattern(name) },
271
+ ...exact(opts)
272
+ });
273
+ var textSpec = (text, opts) => ({ text: pattern(text), ...exact(opts) });
274
+ function act(loc, action, value, opts) {
275
+ return { toStep: () => ({ act: { locator: loc.spec, action, ...value === undefined ? {} : { value }, ...opts } }) };
276
+ }
277
+ var css_ = (selector) => new Locator({ css: selector });
278
+ var css = css_;
279
+ var testId = (id, attribute) => new Locator(attribute ? { testId: id, testIdAttribute: attribute } : { testId: id });
280
+ var text = (t, opts = {}) => new Locator(textSpec(t, opts));
281
+ var label = (t, opts = {}) => new Locator({ label: pattern(t), ...exact(opts) });
282
+ var placeholder = (t, opts = {}) => new Locator({ placeholder: pattern(t), ...exact(opts) });
283
+ var role = (r, name, opts = {}) => new Locator(roleSpec(r, name, opts));
284
+ var button = (name, opts) => role("button", name, opts);
285
+ var link = (name, opts) => role("link", name, opts);
286
+ var dialog = (name, opts) => role("dialog", name, opts);
287
+ var heading = (name, opts) => role("heading", name, opts);
288
+ var textbox = (name, opts) => role("textbox", name, opts);
289
+ var checkbox = (name, opts) => role("checkbox", name, opts);
290
+ var combobox = (name, opts) => role("combobox", name, opts);
291
+ var option = (name, opts) => role("option", name, opts);
292
+ var row = (name, opts) => role("row", name, opts);
293
+ var cell = (name, opts) => role("cell", name, opts);
294
+ var tab = (name, opts) => role("tab", name, opts);
295
+ var menuitem = (name, opts) => role("menuitem", name, opts);
296
+ var inFrame = (frame) => frame.frame();
297
+
298
+ class Check {
299
+ loc;
300
+ negate;
301
+ constructor(loc, negate = false) {
302
+ this.loc = loc;
303
+ this.negate = negate;
304
+ }
305
+ get not() {
306
+ return new Check(this.loc, !this.negate);
307
+ }
308
+ step(assert, expected, opts = {}, extra = {}) {
309
+ const body = {
310
+ locator: this.loc.spec,
311
+ assert,
312
+ ...expected === undefined ? {} : { expected },
313
+ ...this.negate ? { not: true } : {},
314
+ ...extra,
315
+ ...opts
316
+ };
317
+ return { toStep: () => ({ check: body }) };
318
+ }
319
+ visible(opts) {
320
+ return this.step("visible", undefined, opts);
321
+ }
322
+ hidden(opts) {
323
+ return this.step("hidden", undefined, opts);
324
+ }
325
+ text(expected, opts) {
326
+ return this.step("text", expected, opts);
327
+ }
328
+ containsText(expected, opts) {
329
+ return this.step("containsText", expected, opts);
330
+ }
331
+ value(expected, opts) {
332
+ return this.step("value", expected, opts);
333
+ }
334
+ count(expected, opts) {
335
+ return this.step("count", expected, opts);
336
+ }
337
+ checked(expected = true, opts) {
338
+ return this.step("checked", expected, opts);
339
+ }
340
+ css(property, expected, opts) {
341
+ return this.step("css", expected, opts, { property });
342
+ }
343
+ focused(expected = true, opts) {
344
+ return this.step("focused", expected, opts);
345
+ }
346
+ name(expected, opts) {
347
+ return this.step("name", expected, opts);
348
+ }
349
+ enabled(expected = true, opts) {
350
+ return this.step("enabled", expected, opts);
351
+ }
352
+ disabled(opts) {
353
+ return this.step("enabled", false, opts);
354
+ }
355
+ }
356
+
357
+ class RequestCheck {
358
+ m;
359
+ constructor(m) {
360
+ this.m = m;
361
+ }
362
+ status(status) {
363
+ return new RequestCheck({ ...this.m, status });
364
+ }
365
+ count(count) {
366
+ return new RequestCheck({ ...this.m, count });
367
+ }
368
+ body(body) {
369
+ return new RequestCheck({ ...this.m, body: pattern(body) });
370
+ }
371
+ bodyKeys(keys) {
372
+ return new RequestCheck({ ...this.m, bodyKeys: keys });
373
+ }
374
+ headers(headers) {
375
+ const wire = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, pattern(v)]));
376
+ return new RequestCheck({ ...this.m, headers: wire });
377
+ }
378
+ state(state) {
379
+ return new RequestCheck({ ...this.m, state });
380
+ }
381
+ scope(scope) {
382
+ return new RequestCheck({ ...this.m, scope });
383
+ }
384
+ timeout(timeout) {
385
+ return new RequestCheck({ ...this.m, timeout });
386
+ }
387
+ toStep() {
388
+ return { request: this.m };
389
+ }
390
+ }
391
+ var check = Object.assign((loc) => new Check(loc), {
392
+ request: (method, path) => new RequestCheck(typeof path === "string" ? { method, path } : { method, url: pattern(path) })
393
+ });
394
+ var goto = (url, waitUntil = "load") => ({
395
+ toStep: () => ({ goto: { url, waitUntil } })
396
+ });
397
+ var route = (url, opts = {}) => ({ toStep: () => ({ route: { url, ...opts } }) });
398
+ var evaluate = (expression, as, opts = {}) => ({
399
+ toStep: () => ({ eval: { expression, ...as ? { as } : {}, ...opts } })
400
+ });
401
+ var loaded = (opts = {}) => ({ toStep: () => ({ loaded: opts }) });
402
+ var idle = (opts = {}) => ({
403
+ toStep: () => ({ idle: opts.timeout === undefined ? {} : { timeout: opts.timeout } })
404
+ });
405
+ var reload = (opts = {}) => ({ toStep: () => ({ reload: opts }) });
406
+ var waitForUrl = (url, opts = {}) => ({
407
+ toStep: () => ({ url: { url: pattern(url), ...opts } })
408
+ });
409
+ var mock = (url, opts = {}) => ({
410
+ toStep: () => ({ mock: { url: pattern(url), ...opts } })
411
+ });
412
+ var unmock = (url) => ({
413
+ toStep: () => ({ unmock: url === undefined ? {} : { url: pattern(url) } })
414
+ });
415
+ var release = (hold) => ({ toStep: () => ({ release: { hold } }) });
416
+ var viewport = (width, height) => ({
417
+ toStep: () => ({ viewport: { width, height } })
418
+ });
419
+ var media = (opts) => ({ toStep: () => ({ media: opts }) });
420
+ var downloadCount = (as) => ({
421
+ toStep: () => ({ downloadCount: { as } })
422
+ });
423
+ var download = (opts = {}) => ({
424
+ toStep: () => ({ download: opts })
425
+ });
426
+ var switchTo = (target) => ({ toStep: () => ({ page: { target } }) });
427
+ var popup = (as, opts = {}) => ({
428
+ toStep: () => ({ popup: { ...as ? { as } : {}, ...opts } })
429
+ });
430
+ var mouse = (action, opts = {}) => ({
431
+ toStep: () => ({ mouse: { action, ...opts } })
432
+ });
433
+ var wheel = (loc, deltaX, deltaY, opts = {}) => loc.wheel(deltaX, deltaY, opts);
434
+
435
+ class ScreenshotStep {
436
+ name;
437
+ opts;
438
+ testDir;
439
+ wire = {};
440
+ constructor(name, opts, testDir) {
441
+ this.name = name;
442
+ this.opts = opts;
443
+ this.testDir = testDir;
444
+ if (!/^[\w.@-][\w.@/-]*$/.test(name) || name.includes("..")) {
445
+ throw new Error(`screenshot name '${name}': use letters, digits, '.', '-', '_', '@' and '/'`);
446
+ }
447
+ }
448
+ toStep() {
449
+ const { dir: _dir, ...opts } = this.opts;
450
+ return { matchScreenshot: { name: this.name, ...opts, ...this.wire } };
451
+ }
452
+ }
453
+ var matchScreenshot = (name, opts = {}) => new ScreenshotStep(name, opts, callerDir());
454
+ function callerDir() {
455
+ const here = import.meta.url.replace(/^file:\/\//, "").replace(/[^/]*$/, "");
456
+ for (const line of (new Error().stack ?? "").split(`
457
+ `).slice(1)) {
458
+ const m = /(?:\(|at )(?:file:\/\/)?(\/[^():]+):\d+:\d+\)?\s*$/.exec(line);
459
+ if (m && !m[1].startsWith(here))
460
+ return m[1].replace(/\/[^/]*$/, "");
461
+ }
462
+ return process.cwd();
463
+ }
464
+
465
+ // src/screenshots.ts
466
+ import { createHash } from "node:crypto";
467
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
468
+ import { dirname, join as join2 } from "node:path";
469
+ var updateMode = () => ["1", "true", "all"].includes(process.env.LP_UPDATE_SNAPSHOTS ?? "") || process.argv.includes("--update-snapshots") || process.argv.includes("-u");
470
+
471
+ class ScreenshotMismatchError extends Error {
472
+ result;
473
+ constructor(message, result) {
474
+ super(message);
475
+ this.result = result;
476
+ }
477
+ }
478
+ var paths = (step) => {
479
+ const base = join2(step.opts.dir ?? join2(step.testDir, "__screenshots__"), step.name);
480
+ return { png: `${base}.png`, meta: `${base}.json`, actual: `${base}.actual.png`, diff: `${base}.diff.png` };
481
+ };
482
+ function prepare(step, sent) {
483
+ const p = paths(step);
484
+ step.wire = {};
485
+ if (updateMode() || !existsSync(p.png)) {
486
+ step.wire = { update: true };
487
+ return;
488
+ }
489
+ const png = readFileSync(p.png);
490
+ const digest = createHash("sha1").update(png).digest("hex");
491
+ const key = `${p.png}`;
492
+ step.wire.name = key;
493
+ if (sent.get(key) !== digest) {
494
+ step.wire.baseline = png.toString("base64");
495
+ try {
496
+ const meta = JSON.parse(readFileSync(p.meta, "utf8"));
497
+ if (meta.sceneHash)
498
+ step.wire.sceneHash = meta.sceneHash;
499
+ } catch {}
500
+ sent.set(key, digest);
501
+ }
502
+ }
503
+ var toResult = (step, r) => ({
504
+ name: step.name,
505
+ pass: r.pass,
506
+ hashHit: r.hashHit,
507
+ recorded: r.recorded,
508
+ diffPixels: r.diffPixels,
509
+ sceneHash: r.sceneHash,
510
+ width: r.width,
511
+ height: r.height,
512
+ ms: r.ms
513
+ });
514
+ function settle(step, r) {
515
+ const p = paths(step);
516
+ const result = toResult(step, r);
517
+ rmSync(p.actual, { force: true });
518
+ rmSync(p.diff, { force: true });
519
+ if (r.recorded) {
520
+ const existed = existsSync(p.png);
521
+ mkdirSync(dirname(p.png), { recursive: true });
522
+ writeFileSync(p.png, Buffer.from(r.actual, "base64"));
523
+ writeFileSync(p.meta, JSON.stringify({ sceneHash: r.sceneHash, width: r.width, height: r.height }, null, 2) + `
524
+ `);
525
+ result.files = { baseline: p.png };
526
+ if (!updateMode()) {
527
+ const what = existed ? "was replaced" : "did not exist";
528
+ throw new ScreenshotMismatchError(`screenshot '${step.name}': the baseline ${what}; wrote ${p.png}`, result);
529
+ }
530
+ }
531
+ return result;
532
+ }
533
+ function mismatch(step, data, message) {
534
+ const p = paths(step);
535
+ const result = toResult(step, data);
536
+ mkdirSync(dirname(p.actual), { recursive: true });
537
+ writeFileSync(p.actual, Buffer.from(data.actual, "base64"));
538
+ writeFileSync(p.diff, Buffer.from(data.diff, "base64"));
539
+ result.files = { baseline: p.png, actual: p.actual, diff: p.diff };
540
+ return new ScreenshotMismatchError(`${message}
541
+ actual: ${p.actual}
542
+ diff: ${p.diff}`, result);
543
+ }
544
+
545
+ // src/index.ts
546
+ class RunError extends Error {
547
+ step;
548
+ constructor(message, step) {
549
+ super(message);
550
+ this.step = step;
551
+ }
552
+ }
553
+ var storageScript = (entries) => `(() => { try { const s = window.localStorage; for (const [k, v] of Object.entries(${JSON.stringify(entries)})) s.setItem(k, v); } catch {} })();`;
554
+
555
+ class Lightningpanda {
556
+ wsUrl;
557
+ conns;
558
+ child;
559
+ profiles = new Map;
560
+ profileMutation = Promise.resolve();
561
+ turn = 0;
562
+ fork = false;
563
+ stats = { prepared: 0, preparedBooting: 0, fresh: 0 };
564
+ constructor(wsUrl, conns, child) {
565
+ this.wsUrl = wsUrl;
566
+ this.conns = conns;
567
+ this.child = child;
568
+ }
569
+ async detect() {
570
+ try {
571
+ const { fork } = await this.cdp.send("Lightningpanda.capabilities");
572
+ this.fork = fork === true;
573
+ } catch (e) {
574
+ if (!(e instanceof CdpError))
575
+ throw e;
576
+ }
577
+ return this;
578
+ }
579
+ get cdp() {
580
+ return this.conns[0].cdp;
581
+ }
582
+ static async connect(wsUrl, opts = {}) {
583
+ return new Lightningpanda(wsUrl, await openConns(wsUrl, opts)).detect();
584
+ }
585
+ static async launch(opts = {}) {
586
+ const port = await freePort();
587
+ const child = spawn(opts.bin ?? resolveBinary(), ["serve", "--host", "127.0.0.1", "--port", String(port), ...opts.args ?? []], {
588
+ stdio: ["ignore", "ignore", "inherit"],
589
+ detached: process.platform !== "win32"
590
+ });
591
+ let spawnError;
592
+ child.once("error", (error) => {
593
+ spawnError = error;
594
+ });
595
+ const deadline = Date.now() + 15000;
596
+ for (;; ) {
597
+ try {
598
+ const r = await fetch(`http://127.0.0.1:${port}/json/version`);
599
+ const v = await r.json();
600
+ const url = v.webSocketDebuggerUrl ?? `ws://127.0.0.1:${port}/`;
601
+ return await new Lightningpanda(url, await openConns(url, opts), child).detect();
602
+ } catch (e) {
603
+ if (spawnError || child.exitCode !== null || child.signalCode !== null || Date.now() > deadline) {
604
+ stopLaunchedBrowser(child);
605
+ throw new Error(`lightningpanda serve did not start: ${spawnError ?? e}`);
606
+ }
607
+ await new Promise((r) => setTimeout(r, 5));
608
+ }
609
+ }
610
+ }
611
+ updateProfiles(change) {
612
+ const result = this.profileMutation.then(change);
613
+ this.profileMutation = result.then(() => {
614
+ return;
615
+ }, () => {
616
+ return;
617
+ });
618
+ return result;
619
+ }
620
+ async profile(name, profile) {
621
+ return this.updateProfiles(async () => {
622
+ const base = profile.base === undefined ? undefined : this.profiles.get(profile.base)?.ids?.[0];
623
+ if (profile.base !== undefined && base === undefined)
624
+ throw new Error(`no prepared profile ${profile.base} to start from`);
625
+ const scripts = [...profile.localStorage ? [storageScript(profile.localStorage)] : [], ...profile.initScripts ?? []];
626
+ if (profile.prepare === false) {
627
+ this.profiles.set(name, { ids: null, profile, scripts });
628
+ return;
629
+ }
630
+ const conns = this.fork ? this.conns.slice(0, 1) : this.conns;
631
+ const ids = await Promise.all(conns.map(async (c) => {
632
+ const { profileId } = await c.cdp.send("Lightningpanda.registerPreparedPage", {
633
+ url: profile.url,
634
+ initScripts: scripts,
635
+ ...profile.viewport ? { viewport: profile.viewport } : {},
636
+ pool: profile.pool ?? 2,
637
+ ...profile.setup?.length ? { setup: profile.setup.map((s) => s.toStep()) } : {},
638
+ ...base !== undefined ? { base } : {}
639
+ });
640
+ return profileId;
641
+ }));
642
+ this.profiles.set(name, { ids, profile, scripts });
643
+ });
644
+ }
645
+ async releaseProfile(name) {
646
+ return this.updateProfiles(async () => {
647
+ const entry = this.profiles.get(name);
648
+ if (!entry)
649
+ return;
650
+ if (entry.ids) {
651
+ const conns = this.fork ? this.conns.slice(0, 1) : this.conns;
652
+ await Promise.all(conns.map(async (conn, i) => {
653
+ const profileId = entry.ids[i];
654
+ const shared = [...this.profiles].some(([alias, other]) => alias !== name && other.ids?.[i] === profileId);
655
+ if (!shared)
656
+ await conn.cdp.send("Lightningpanda.unregisterPreparedPage", { profileId });
657
+ }));
658
+ }
659
+ this.profiles.delete(name);
660
+ });
661
+ }
662
+ async warm(name, timeoutMs = 5000) {
663
+ const entry = this.profiles.get(name);
664
+ if (!entry?.ids)
665
+ return;
666
+ const deadline = Date.now() + timeoutMs;
667
+ if (this.fork) {
668
+ for (;; ) {
669
+ const cdp = await Cdp.open(this.forkUrl(entry.ids[0]));
670
+ const { pages } = await cdp.send("Lightningpanda.preparedPages").finally(() => cdp.close());
671
+ if (pages.some((p) => p.profileId === entry.ids[0] && p.ready))
672
+ return;
673
+ if (Date.now() > deadline)
674
+ throw new Error(`no booted page for ${name} after ${timeoutMs} ms`);
675
+ await new Promise((r) => setTimeout(r, 10));
676
+ }
677
+ }
678
+ await Promise.all(this.conns.map(async (c, i) => {
679
+ for (;; ) {
680
+ const { pages } = await c.cdp.send("Lightningpanda.preparedPages");
681
+ const ready = pages.filter((p) => p.profileId === entry.ids[i] && p.ready);
682
+ if (ready.length >= (entry.profile.pool ?? 2))
683
+ return;
684
+ if (Date.now() > deadline)
685
+ throw new Error(`no booted page for ${name} after ${timeoutMs} ms`);
686
+ await new Promise((r) => setTimeout(r, 2));
687
+ }
688
+ }));
689
+ }
690
+ pick() {
691
+ const n = this.conns.length;
692
+ let best = this.turn % n;
693
+ for (let k = 1;k < n; k++) {
694
+ const i = (this.turn + k) % n;
695
+ if (this.conns[i].open < this.conns[best].open)
696
+ best = i;
697
+ }
698
+ this.turn = best + 1;
699
+ return best;
700
+ }
701
+ async page(opts = {}) {
702
+ const entry = opts.as === undefined ? undefined : this.profiles.get(opts.as);
703
+ if (opts.as !== undefined && !entry)
704
+ throw new Error(`no profile ${opts.as}: declare it with lp.profile()`);
705
+ if (!this.fork) {
706
+ const index = this.pick();
707
+ return this.openPage(this.conns[index], index, entry, opts);
708
+ }
709
+ const conn = { cdp: await Cdp.open(this.forkUrl(entry?.ids?.[0])), open: 0, baselines: new Map, own: true };
710
+ try {
711
+ return await this.openPage(conn, 0, entry, opts);
712
+ } catch (e) {
713
+ conn.cdp.close();
714
+ throw e;
715
+ }
716
+ }
717
+ async openPage(conn, index, entry, opts) {
718
+ const first = [];
719
+ let page;
720
+ const extra = opts.initScripts ?? [];
721
+ if (entry?.ids && extra.length === 0) {
722
+ try {
723
+ const r = await conn.cdp.send("Lightningpanda.acquirePreparedPage", { profileId: entry.ids[index], attach: true });
724
+ page = new Page(conn, r.sessionId, r.targetId, r.browserContextId);
725
+ this.stats.prepared++;
726
+ if (!r.ready) {
727
+ this.stats.preparedBooting++;
728
+ first.push(loaded());
729
+ }
730
+ } catch (e) {
731
+ if (!(e instanceof CdpError) || e.code !== -32602)
732
+ throw e;
733
+ }
734
+ }
735
+ if (!page) {
736
+ page = await freshPage(conn, entry, extra);
737
+ this.stats.fresh++;
738
+ if (entry && !opts.url)
739
+ first.push(goto(entry.profile.url));
740
+ }
741
+ if (opts.url)
742
+ first.push(goto(opts.url));
743
+ if (opts.route)
744
+ first.push(route(opts.route));
745
+ page.prefix = first;
746
+ return page;
747
+ }
748
+ forkUrl(profileId) {
749
+ if (profileId === undefined)
750
+ return this.wsUrl;
751
+ const url = new URL(this.wsUrl);
752
+ url.searchParams.set("profile", String(profileId));
753
+ return url.toString();
754
+ }
755
+ close() {
756
+ for (const c of this.conns)
757
+ c.cdp.close();
758
+ if (this.child) {
759
+ const child = this.child;
760
+ this.child = undefined;
761
+ stopLaunchedBrowser(child);
762
+ }
763
+ }
764
+ }
765
+ function stopLaunchedBrowser(child) {
766
+ if (process.platform === "win32" || !child.pid) {
767
+ child.kill();
768
+ return;
769
+ }
770
+ try {
771
+ process.kill(-child.pid, "SIGTERM");
772
+ } catch (error) {
773
+ if (error.code !== "ESRCH")
774
+ throw error;
775
+ }
776
+ }
777
+ async function openConns(wsUrl, opts) {
778
+ const n = Math.max(1, opts.connections ?? 1);
779
+ const cdps = await Promise.all(Array.from({ length: n }, () => Cdp.open(wsUrl)));
780
+ return cdps.map((cdp) => ({ cdp, open: 0, baselines: new Map }));
781
+ }
782
+ async function freshPage(conn, entry, extra = []) {
783
+ const cdp = conn.cdp;
784
+ const { browserContextId } = await cdp.send("Target.createBrowserContext");
785
+ const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank", browserContextId });
786
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
787
+ for (const source of [...entry?.scripts ?? [], ...extra]) {
788
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source }, sessionId);
789
+ }
790
+ const v = entry?.profile.viewport;
791
+ if (v)
792
+ await cdp.send("Emulation.setDeviceMetricsOverride", { ...v, deviceScaleFactor: 1, mobile: false }, sessionId);
793
+ return new Page(conn, sessionId, targetId, browserContextId);
794
+ }
795
+
796
+ class Page {
797
+ conn;
798
+ sessionId;
799
+ targetId;
800
+ browserContextId;
801
+ prefix = [];
802
+ closed = false;
803
+ constructor(conn, sessionId, targetId, browserContextId) {
804
+ this.conn = conn;
805
+ this.sessionId = sessionId;
806
+ this.targetId = targetId;
807
+ this.browserContextId = browserContextId;
808
+ conn.open++;
809
+ }
810
+ get cdp() {
811
+ return this.conn.cdp;
812
+ }
813
+ async run(steps2, opts = {}) {
814
+ const prefix = this.prefix;
815
+ this.prefix = [];
816
+ const all = [...prefix, ...steps2];
817
+ for (const s of all)
818
+ if (s instanceof ScreenshotStep)
819
+ prepare(s, this.conn.baselines);
820
+ const wire = all.map((s) => s.toStep());
821
+ let r;
822
+ try {
823
+ r = await this.cdp.send("LP.run", { steps: wire, ...opts }, this.sessionId);
824
+ } catch (e) {
825
+ const m = e instanceof CdpError ? /step (\d+)/.exec(e.message) : null;
826
+ if (!m)
827
+ throw e;
828
+ const index = Number(m[1]) - prefix.length;
829
+ const where = index < 0 ? `opening the page (${prefix[Number(m[1])] && JSON.stringify(prefix[Number(m[1])].toStep())})` : `step ${index}`;
830
+ const message = e.message.replace(/^LP\.run: step \d+/, where);
831
+ const failed = all[Number(m[1])];
832
+ const data = e.data;
833
+ if (failed instanceof ScreenshotStep && data?.diff) {
834
+ throw mismatch(failed, data, message);
835
+ }
836
+ for (const s of all)
837
+ if (s instanceof ScreenshotStep)
838
+ this.conn.baselines.delete(String(s.wire.name ?? ""));
839
+ throw new RunError(message, index);
840
+ }
841
+ const screenshots = {};
842
+ all.forEach((s, i) => {
843
+ if (s instanceof ScreenshotStep)
844
+ screenshots[s.name] = settle(s, r.results[i]);
845
+ });
846
+ return { values: r.reads, screenshots, results: r.results.slice(prefix.length), requests: r.requests, ms: r.ms };
847
+ }
848
+ async matchScreenshot(name, opts = {}) {
849
+ const step = matchScreenshot(name, opts);
850
+ const r = await this.run([step]);
851
+ return r.screenshots[step.name];
852
+ }
853
+ async addInitScript(source) {
854
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source }, this.sessionId);
855
+ }
856
+ send(method, params = {}) {
857
+ return this.cdp.send(method, params, this.sessionId);
858
+ }
859
+ close() {
860
+ if (this.closed)
861
+ return;
862
+ this.closed = true;
863
+ this.conn.open--;
864
+ if (this.conn.own) {
865
+ this.cdp.close();
866
+ return;
867
+ }
868
+ this.cdp.post("Target.closeTarget", { targetId: this.targetId });
869
+ this.cdp.post("Target.disposeBrowserContext", { browserContextId: this.browserContextId });
870
+ }
871
+ async[Symbol.asyncDispose]() {
872
+ this.close();
873
+ }
874
+ }
875
+ function freePort() {
876
+ return new Promise((resolve, reject) => {
877
+ const s = createServer();
878
+ s.listen(0, "127.0.0.1", () => {
879
+ const port = s.address().port;
880
+ s.close(() => resolve(port));
881
+ });
882
+ s.on("error", reject);
883
+ });
884
+ }
885
+ var screenshotMatchers = {
886
+ async toMatchScreenshot(page, name, opts = {}) {
887
+ try {
888
+ const r = await page.matchScreenshot(name, opts);
889
+ return { pass: true, message: () => `expected screenshot '${name}' not to match (${r.diffPixels} pixels differ)` };
890
+ } catch (e) {
891
+ if (e instanceof ScreenshotMismatchError)
892
+ return { pass: false, message: () => e.message };
893
+ throw e;
894
+ }
895
+ }
896
+ };
897
+ export {
898
+ Cdp,
899
+ CdpError,
900
+ FrameScope,
901
+ Lightningpanda,
902
+ Locator,
903
+ Page,
904
+ RunError,
905
+ ScreenshotMismatchError,
906
+ ScreenshotStep,
907
+ button,
908
+ cell,
909
+ check,
910
+ checkbox,
911
+ combobox,
912
+ css,
913
+ dialog,
914
+ download,
915
+ downloadCount,
916
+ evaluate,
917
+ goto,
918
+ heading,
919
+ idle,
920
+ inFrame,
921
+ label,
922
+ link,
923
+ loaded,
924
+ matchScreenshot,
925
+ media,
926
+ menuitem,
927
+ mock,
928
+ mouse,
929
+ option,
930
+ placeholder,
931
+ popup,
932
+ release,
933
+ reload,
934
+ role,
935
+ route,
936
+ row,
937
+ screenshotMatchers,
938
+ switchTo,
939
+ tab,
940
+ testId,
941
+ text,
942
+ textbox,
943
+ unmock,
944
+ updateMode,
945
+ viewport,
946
+ waitForUrl,
947
+ wheel
948
+ };