@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/src/index.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// lightning-panda: E2E tests for `bun test` on Lightningpanda.
|
|
2
|
+
//
|
|
3
|
+
// const lp = await Lightningpanda.connect(wsUrl); // or Lightningpanda.launch()
|
|
4
|
+
// await lp.profile('employee', { url: `${app}/platform`, localStorage: tokens });
|
|
5
|
+
// await using page = await lp.page({ as: 'employee', route: `/positions/${id}` });
|
|
6
|
+
// const r = await page.run([
|
|
7
|
+
// button('Settings').click(),
|
|
8
|
+
// dialog().hasText('will be closed').button('Close Position').click(),
|
|
9
|
+
// check.request('POST', `/positions/${id}/close`).status(200),
|
|
10
|
+
// check(button('Reopen Position')).count(1),
|
|
11
|
+
// ]);
|
|
12
|
+
//
|
|
13
|
+
// A profile keeps signed-in pages booted ahead (Lightningpanda.registerPreparedPage);
|
|
14
|
+
// `lp.page()` takes one and attaches in one call, and its `route` becomes the
|
|
15
|
+
// first step of the first run. `page.run()` is one LP.run command: the browser
|
|
16
|
+
// runs every step, waiting in place, and replies once.
|
|
17
|
+
//
|
|
18
|
+
// Against `lightningpanda serve --fork` every page gets a connection of its own:
|
|
19
|
+
// the server forks it from a process that booted each profile once, so the
|
|
20
|
+
// page starts booted and its process exits when the page closes.
|
|
21
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
22
|
+
import { createServer } from 'node:net';
|
|
23
|
+
import { resolveBinary } from './binary.js';
|
|
24
|
+
import { Cdp, CdpError } from './cdp.js';
|
|
25
|
+
import { loaded, route as routeStep, goto, matchScreenshot, ScreenshotStep, type ScreenshotOptions, type Step, type StepLike } from './steps.js';
|
|
26
|
+
import { mismatch, prepare, settle, ScreenshotMismatchError, type ScreenshotResult, type SentBaselines } from './screenshots.js';
|
|
27
|
+
|
|
28
|
+
export * from './steps.js';
|
|
29
|
+
export { Cdp, CdpError } from './cdp.js';
|
|
30
|
+
export { ScreenshotMismatchError, updateMode, type ScreenshotResult } from './screenshots.js';
|
|
31
|
+
|
|
32
|
+
export type Profile = {
|
|
33
|
+
/** Where its pages boot (and stay until a test takes one). */
|
|
34
|
+
url: string;
|
|
35
|
+
/** Written to localStorage before the app's scripts run (e.g. auth tokens). */
|
|
36
|
+
localStorage?: Record<string, string>;
|
|
37
|
+
/** Run in every document before its scripts. */
|
|
38
|
+
initScripts?: string[];
|
|
39
|
+
viewport?: { width: number; height: number };
|
|
40
|
+
/** Pages kept booted (1-8). */
|
|
41
|
+
pool?: number;
|
|
42
|
+
/** Keep pages booted ahead (default); false boots each page when a test asks. */
|
|
43
|
+
prepare?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Steps run once on the booted page before tests get it (`serve --fork`
|
|
46
|
+
* only): every page of this profile then starts from their result.
|
|
47
|
+
*/
|
|
48
|
+
setup?: StepLike[];
|
|
49
|
+
/**
|
|
50
|
+
* Starts from another profile's snapshot (`serve --fork` only): its page is
|
|
51
|
+
* forked from that profile's, and only this profile's `setup` runs on it.
|
|
52
|
+
*/
|
|
53
|
+
base?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type RunResult = {
|
|
57
|
+
/** Values of `read(as)` and `evaluate(expr, as)` steps. */
|
|
58
|
+
values: Record<string, unknown>;
|
|
59
|
+
/** `matchScreenshot` steps by name. */
|
|
60
|
+
screenshots: Record<string, ScreenshotResult>;
|
|
61
|
+
/** One entry per step: what a check saw, a request it matched, settle time. */
|
|
62
|
+
results: unknown[];
|
|
63
|
+
/** Requests the page made during the run. */
|
|
64
|
+
requests: { method: string; url: string; status: number | null; error: string | null; ms: number | null }[];
|
|
65
|
+
/** Time in the browser. */
|
|
66
|
+
ms: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export class RunError extends Error {
|
|
70
|
+
constructor(
|
|
71
|
+
message: string,
|
|
72
|
+
readonly step: number,
|
|
73
|
+
) {
|
|
74
|
+
super(message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const storageScript = (entries: Record<string, string>) =>
|
|
79
|
+
`(() => { try { const s = window.localStorage; for (const [k, v] of Object.entries(${JSON.stringify(entries)})) s.setItem(k, v); } catch {} })();`;
|
|
80
|
+
|
|
81
|
+
type ProfileEntry = { ids: number[] | null; profile: Profile; scripts: string[] };
|
|
82
|
+
|
|
83
|
+
/** One CDP connection: one browser thread, with its own page pools and screenshot baselines. */
|
|
84
|
+
type Conn = {
|
|
85
|
+
cdp: Cdp;
|
|
86
|
+
open: number;
|
|
87
|
+
baselines: SentBaselines;
|
|
88
|
+
/** Opened for one page (fork mode): closed with it. */
|
|
89
|
+
own?: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type ConnectOptions = {
|
|
93
|
+
/**
|
|
94
|
+
* CDP connections to open (default 1). Each is its own browser thread with
|
|
95
|
+
* its own pools: a test takes a page from the connection with the fewest
|
|
96
|
+
* open pages, and a connection refills its pools as soon as it has no open
|
|
97
|
+
* page, in parallel with tests on the others.
|
|
98
|
+
*/
|
|
99
|
+
connections?: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export class Lightningpanda {
|
|
103
|
+
private profiles = new Map<string, ProfileEntry>();
|
|
104
|
+
private profileMutation: Promise<void> = Promise.resolve();
|
|
105
|
+
private turn = 0;
|
|
106
|
+
/** `serve --fork`: one connection (a forked process) per page. */
|
|
107
|
+
private fork = false;
|
|
108
|
+
/** Pages taken from a pool, and pages booted on demand because none was waiting. */
|
|
109
|
+
readonly stats = { prepared: 0, preparedBooting: 0, fresh: 0 };
|
|
110
|
+
|
|
111
|
+
private constructor(
|
|
112
|
+
private wsUrl: string,
|
|
113
|
+
private conns: Conn[],
|
|
114
|
+
private child?: ChildProcess,
|
|
115
|
+
) {}
|
|
116
|
+
|
|
117
|
+
/** Asks the server whether it forks connections (`serve --fork`). */
|
|
118
|
+
private async detect(): Promise<this> {
|
|
119
|
+
try {
|
|
120
|
+
const { fork } = await this.cdp.send('Lightningpanda.capabilities');
|
|
121
|
+
this.fork = fork === true;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (!(e instanceof CdpError)) throw e; // an older server: no fork mode
|
|
124
|
+
}
|
|
125
|
+
return this;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The first connection, for browser-level commands. */
|
|
129
|
+
get cdp(): Cdp {
|
|
130
|
+
return this.conns[0].cdp;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static async connect(wsUrl: string, opts: ConnectOptions = {}): Promise<Lightningpanda> {
|
|
134
|
+
return new Lightningpanda(wsUrl, await openConns(wsUrl, opts)).detect();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Starts `lightningpanda serve` on a free port and connects to it. The binary
|
|
139
|
+
* is `bin`, else `$LIGHTNINGPANDA_BIN`, else the bundled binary for this
|
|
140
|
+
* platform (or the sibling binary in a legacy release archive).
|
|
141
|
+
*/
|
|
142
|
+
static async launch(opts: ConnectOptions & { bin?: string; args?: string[] } = {}): Promise<Lightningpanda> {
|
|
143
|
+
const port = await freePort();
|
|
144
|
+
const child = spawn(opts.bin ?? resolveBinary(), ['serve', '--host', '127.0.0.1', '--port', String(port), ...(opts.args ?? [])], {
|
|
145
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
146
|
+
detached: process.platform !== 'win32',
|
|
147
|
+
});
|
|
148
|
+
let spawnError: Error | undefined;
|
|
149
|
+
child.once('error', (error) => { spawnError = error; });
|
|
150
|
+
const deadline = Date.now() + 15_000;
|
|
151
|
+
for (;;) {
|
|
152
|
+
try {
|
|
153
|
+
const r = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
154
|
+
const v = (await r.json()) as { webSocketDebuggerUrl?: string };
|
|
155
|
+
const url = v.webSocketDebuggerUrl ?? `ws://127.0.0.1:${port}/`;
|
|
156
|
+
return await new Lightningpanda(url, await openConns(url, opts), child).detect();
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (spawnError || child.exitCode !== null || child.signalCode !== null || Date.now() > deadline) {
|
|
159
|
+
stopLaunchedBrowser(child);
|
|
160
|
+
throw new Error(`lightningpanda serve did not start: ${spawnError ?? e}`);
|
|
161
|
+
}
|
|
162
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private updateProfiles<T>(change: () => Promise<T>): Promise<T> {
|
|
168
|
+
const result = this.profileMutation.then(change);
|
|
169
|
+
this.profileMutation = result.then(() => undefined, () => undefined);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Declares a kind of page tests ask for by name (`lp.page({ as: name })`). */
|
|
174
|
+
async profile(name: string, profile: Profile): Promise<void> {
|
|
175
|
+
return this.updateProfiles(async () => {
|
|
176
|
+
const base = profile.base === undefined ? undefined : this.profiles.get(profile.base)?.ids?.[0];
|
|
177
|
+
if (profile.base !== undefined && base === undefined) throw new Error(`no prepared profile ${profile.base} to start from`);
|
|
178
|
+
const scripts = [...(profile.localStorage ? [storageScript(profile.localStorage)] : []), ...(profile.initScripts ?? [])];
|
|
179
|
+
if (profile.prepare === false) {
|
|
180
|
+
this.profiles.set(name, { ids: null, profile, scripts });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Fork mode: the server's template boots the profile once for every connection.
|
|
184
|
+
const conns = this.fork ? this.conns.slice(0, 1) : this.conns;
|
|
185
|
+
const ids = await Promise.all(
|
|
186
|
+
conns.map(async (c) => {
|
|
187
|
+
const { profileId } = await c.cdp.send('Lightningpanda.registerPreparedPage', {
|
|
188
|
+
url: profile.url,
|
|
189
|
+
initScripts: scripts,
|
|
190
|
+
...(profile.viewport ? { viewport: profile.viewport } : {}),
|
|
191
|
+
pool: profile.pool ?? 2,
|
|
192
|
+
...(profile.setup?.length ? { setup: profile.setup.map((s) => s.toStep()) } : {}),
|
|
193
|
+
...(base !== undefined ? { base } : {}),
|
|
194
|
+
});
|
|
195
|
+
return profileId as number;
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
this.profiles.set(name, { ids, profile, scripts });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Releases one name, retiring shared native snapshots only after their last alias. Acquired pages remain independent. */
|
|
203
|
+
async releaseProfile(name: string): Promise<void> {
|
|
204
|
+
return this.updateProfiles(async () => {
|
|
205
|
+
const entry = this.profiles.get(name);
|
|
206
|
+
if (!entry) return;
|
|
207
|
+
if (entry.ids) {
|
|
208
|
+
const conns = this.fork ? this.conns.slice(0, 1) : this.conns;
|
|
209
|
+
await Promise.all(conns.map(async (conn, i) => {
|
|
210
|
+
const profileId = entry.ids![i];
|
|
211
|
+
const shared = [...this.profiles].some(([alias, other]) => alias !== name && other.ids?.[i] === profileId);
|
|
212
|
+
if (!shared) await conn.cdp.send('Lightningpanda.unregisterPreparedPage', { profileId });
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
this.profiles.delete(name);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Waits until every connection's pool of profile `name` is fully booted. */
|
|
220
|
+
async warm(name: string, timeoutMs = 5_000): Promise<void> {
|
|
221
|
+
const entry = this.profiles.get(name);
|
|
222
|
+
if (!entry?.ids) return;
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
224
|
+
if (this.fork) {
|
|
225
|
+
// A new connection is a fork of the template as it is now.
|
|
226
|
+
for (;;) {
|
|
227
|
+
const cdp = await Cdp.open(this.forkUrl(entry.ids[0]));
|
|
228
|
+
const { pages } = await cdp.send('Lightningpanda.preparedPages').finally(() => cdp.close());
|
|
229
|
+
if (pages.some((p: { profileId: number; ready: boolean }) => p.profileId === entry.ids![0] && p.ready)) return;
|
|
230
|
+
if (Date.now() > deadline) throw new Error(`no booted page for ${name} after ${timeoutMs} ms`);
|
|
231
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
await Promise.all(
|
|
235
|
+
this.conns.map(async (c, i) => {
|
|
236
|
+
for (;;) {
|
|
237
|
+
const { pages } = await c.cdp.send('Lightningpanda.preparedPages');
|
|
238
|
+
const ready = pages.filter((p: { profileId: number; ready: boolean }) => p.profileId === entry.ids![i] && p.ready);
|
|
239
|
+
// A partly booted pool is still booting on this thread, in the way of the next test.
|
|
240
|
+
if (ready.length >= (entry.profile.pool ?? 2)) return;
|
|
241
|
+
if (Date.now() > deadline) throw new Error(`no booted page for ${name} after ${timeoutMs} ms`);
|
|
242
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
243
|
+
}
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The connection with the fewest open pages (round-robin among equals). */
|
|
249
|
+
private pick(): number {
|
|
250
|
+
const n = this.conns.length;
|
|
251
|
+
let best = this.turn % n;
|
|
252
|
+
for (let k = 1; k < n; k++) {
|
|
253
|
+
const i = (this.turn + k) % n;
|
|
254
|
+
if (this.conns[i].open < this.conns[best].open) best = i;
|
|
255
|
+
}
|
|
256
|
+
this.turn = best + 1;
|
|
257
|
+
return best;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A page of profile `as` (signed in, booted ahead when one is waiting),
|
|
262
|
+
* or a blank page in its own context. `route` (same-document) or `url`
|
|
263
|
+
* (a full navigation) runs as the first step of the first `run`.
|
|
264
|
+
* `initScripts` run in every document of this page before its scripts
|
|
265
|
+
* (after the profile's); a booted page already ran its document, so the
|
|
266
|
+
* page then boots fresh instead of coming from the pool.
|
|
267
|
+
*/
|
|
268
|
+
async page(opts: { as?: string; route?: string; url?: string; initScripts?: string[] } = {}): Promise<Page> {
|
|
269
|
+
const entry = opts.as === undefined ? undefined : this.profiles.get(opts.as);
|
|
270
|
+
if (opts.as !== undefined && !entry) throw new Error(`no profile ${opts.as}: declare it with lp.profile()`);
|
|
271
|
+
if (!this.fork) {
|
|
272
|
+
const index = this.pick();
|
|
273
|
+
return this.openPage(this.conns[index], index, entry, opts);
|
|
274
|
+
}
|
|
275
|
+
// The server forks the connection from this profile's snapshot.
|
|
276
|
+
const conn: Conn = { cdp: await Cdp.open(this.forkUrl(entry?.ids?.[0])), open: 0, baselines: new Map(), own: true };
|
|
277
|
+
try {
|
|
278
|
+
return await this.openPage(conn, 0, entry, opts);
|
|
279
|
+
} catch (e) {
|
|
280
|
+
conn.cdp.close();
|
|
281
|
+
throw e;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async openPage(
|
|
286
|
+
conn: Conn,
|
|
287
|
+
index: number,
|
|
288
|
+
entry: ProfileEntry | undefined,
|
|
289
|
+
opts: { route?: string; url?: string; initScripts?: string[] },
|
|
290
|
+
): Promise<Page> {
|
|
291
|
+
const first: StepLike[] = [];
|
|
292
|
+
let page: Page | undefined;
|
|
293
|
+
const extra = opts.initScripts ?? [];
|
|
294
|
+
if (entry?.ids && extra.length === 0) {
|
|
295
|
+
try {
|
|
296
|
+
const r = await conn.cdp.send('Lightningpanda.acquirePreparedPage', { profileId: entry.ids[index], attach: true });
|
|
297
|
+
page = new Page(conn, r.sessionId, r.targetId, r.browserContextId);
|
|
298
|
+
this.stats.prepared++;
|
|
299
|
+
if (!r.ready) {
|
|
300
|
+
this.stats.preparedBooting++;
|
|
301
|
+
first.push(loaded());
|
|
302
|
+
}
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (!(e instanceof CdpError) || e.code !== -32602) throw e;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!page) {
|
|
308
|
+
page = await freshPage(conn, entry, extra);
|
|
309
|
+
this.stats.fresh++;
|
|
310
|
+
if (entry && !opts.url) first.push(goto(entry.profile.url));
|
|
311
|
+
}
|
|
312
|
+
if (opts.url) first.push(goto(opts.url));
|
|
313
|
+
if (opts.route) first.push(routeStep(opts.route));
|
|
314
|
+
page.prefix = first;
|
|
315
|
+
return page;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The connection URL that forks from `profileId`'s snapshot (fork mode). */
|
|
319
|
+
private forkUrl(profileId?: number): string {
|
|
320
|
+
if (profileId === undefined) return this.wsUrl;
|
|
321
|
+
const url = new URL(this.wsUrl);
|
|
322
|
+
url.searchParams.set('profile', String(profileId));
|
|
323
|
+
return url.toString();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
close(): void {
|
|
327
|
+
for (const c of this.conns) c.cdp.close();
|
|
328
|
+
if (this.child) {
|
|
329
|
+
const child = this.child;
|
|
330
|
+
this.child = undefined;
|
|
331
|
+
stopLaunchedBrowser(child);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The launched server and its forked pages are one owned process group. */
|
|
337
|
+
function stopLaunchedBrowser(child: ChildProcess): void {
|
|
338
|
+
if (process.platform === 'win32' || !child.pid) { child.kill(); return; }
|
|
339
|
+
try { process.kill(-child.pid, 'SIGTERM'); }
|
|
340
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function openConns(wsUrl: string, opts: ConnectOptions): Promise<Conn[]> {
|
|
344
|
+
const n = Math.max(1, opts.connections ?? 1);
|
|
345
|
+
const cdps = await Promise.all(Array.from({ length: n }, () => Cdp.open(wsUrl)));
|
|
346
|
+
return cdps.map((cdp) => ({ cdp, open: 0, baselines: new Map() }));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function freshPage(conn: Conn, entry?: ProfileEntry, extra: string[] = []): Promise<Page> {
|
|
350
|
+
const cdp = conn.cdp;
|
|
351
|
+
const { browserContextId } = await cdp.send('Target.createBrowserContext');
|
|
352
|
+
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId });
|
|
353
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
354
|
+
for (const source of [...(entry?.scripts ?? []), ...extra]) {
|
|
355
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId);
|
|
356
|
+
}
|
|
357
|
+
const v = entry?.profile.viewport;
|
|
358
|
+
if (v) await cdp.send('Emulation.setDeviceMetricsOverride', { ...v, deviceScaleFactor: 1, mobile: false }, sessionId);
|
|
359
|
+
return new Page(conn, sessionId, targetId, browserContextId);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export class Page {
|
|
363
|
+
/** Steps the next run starts with (the page's route or navigation). */
|
|
364
|
+
prefix: StepLike[] = [];
|
|
365
|
+
|
|
366
|
+
private closed = false;
|
|
367
|
+
|
|
368
|
+
constructor(
|
|
369
|
+
private conn: Conn,
|
|
370
|
+
readonly sessionId: string,
|
|
371
|
+
readonly targetId: string,
|
|
372
|
+
readonly browserContextId: string,
|
|
373
|
+
) {
|
|
374
|
+
conn.open++;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private get cdp(): Cdp {
|
|
378
|
+
return this.conn.cdp;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Runs the steps in the browser as one command; throws on the first failing
|
|
383
|
+
* step (a `ScreenshotMismatchError` for a screenshot that does not match).
|
|
384
|
+
*/
|
|
385
|
+
async run(steps: StepLike[], opts: { timeout?: number } = {}): Promise<RunResult> {
|
|
386
|
+
const prefix = this.prefix;
|
|
387
|
+
this.prefix = [];
|
|
388
|
+
const all = [...prefix, ...steps];
|
|
389
|
+
for (const s of all) if (s instanceof ScreenshotStep) prepare(s, this.conn.baselines);
|
|
390
|
+
const wire: Step[] = all.map((s) => s.toStep());
|
|
391
|
+
let r: any;
|
|
392
|
+
try {
|
|
393
|
+
r = await this.cdp.send('LP.run', { steps: wire, ...opts }, this.sessionId);
|
|
394
|
+
} catch (e) {
|
|
395
|
+
const m = e instanceof CdpError ? /step (\d+)/.exec(e.message) : null;
|
|
396
|
+
if (!m) throw e;
|
|
397
|
+
const index = Number(m[1]) - prefix.length;
|
|
398
|
+
const where = index < 0 ? `opening the page (${prefix[Number(m[1])] && JSON.stringify(prefix[Number(m[1])].toStep())})` : `step ${index}`;
|
|
399
|
+
const message = (e as Error).message.replace(/^LP\.run: step \d+/, where);
|
|
400
|
+
const failed = all[Number(m[1])];
|
|
401
|
+
const data = (e as CdpError).data as any;
|
|
402
|
+
if (failed instanceof ScreenshotStep && data?.diff) {
|
|
403
|
+
// The browser may have dropped the baseline with the connection's cache: resend next time.
|
|
404
|
+
throw mismatch(failed, data, message);
|
|
405
|
+
}
|
|
406
|
+
// The connection's cache entry may be stale after a failure: resend next time.
|
|
407
|
+
for (const s of all) if (s instanceof ScreenshotStep) this.conn.baselines.delete(String(s.wire.name ?? ''));
|
|
408
|
+
throw new RunError(message, index);
|
|
409
|
+
}
|
|
410
|
+
const screenshots: Record<string, ScreenshotResult> = {};
|
|
411
|
+
all.forEach((s, i) => {
|
|
412
|
+
if (s instanceof ScreenshotStep) screenshots[s.name] = settle(s, r.results[i]);
|
|
413
|
+
});
|
|
414
|
+
return { values: r.reads, screenshots, results: r.results.slice(prefix.length), requests: r.requests, ms: r.ms };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Expects the page (or `clip`, or `fullPage`) to match the baseline `name`:
|
|
419
|
+
* one `matchScreenshot` step. Throws `ScreenshotMismatchError` on a mismatch.
|
|
420
|
+
*/
|
|
421
|
+
async matchScreenshot(name: string, opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
|
422
|
+
const step = matchScreenshot(name, opts);
|
|
423
|
+
const r = await this.run([step]);
|
|
424
|
+
return r.screenshots[step.name];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Runs `source` in every document this page loads from now on, before its
|
|
429
|
+
* scripts (Playwright's `page.addInitScript`): follow it with `goto` or
|
|
430
|
+
* `reload` for the current page to see it.
|
|
431
|
+
*/
|
|
432
|
+
async addInitScript(source: string): Promise<void> {
|
|
433
|
+
await this.cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }, this.sessionId);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Any other CDP command on this page. */
|
|
437
|
+
send<T = any>(method: string, params: object = {}): Promise<T> {
|
|
438
|
+
return this.cdp.send(method, params, this.sessionId);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Closes the page and its context without waiting. */
|
|
442
|
+
close(): void {
|
|
443
|
+
if (this.closed) return;
|
|
444
|
+
this.closed = true;
|
|
445
|
+
this.conn.open--;
|
|
446
|
+
if (this.conn.own) {
|
|
447
|
+
// Its own forked process: closing the connection ends it, page and all.
|
|
448
|
+
this.cdp.close();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
this.cdp.post('Target.closeTarget', { targetId: this.targetId });
|
|
452
|
+
this.cdp.post('Target.disposeBrowserContext', { browserContextId: this.browserContextId });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
456
|
+
this.close();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function freePort(): Promise<number> {
|
|
461
|
+
return new Promise((resolve, reject) => {
|
|
462
|
+
const s = createServer();
|
|
463
|
+
s.listen(0, '127.0.0.1', () => {
|
|
464
|
+
const port = (s.address() as { port: number }).port;
|
|
465
|
+
s.close(() => resolve(port));
|
|
466
|
+
});
|
|
467
|
+
s.on('error', reject);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* `expect.extend(screenshotMatchers)`, then
|
|
473
|
+
* `await expect(page).toMatchScreenshot('home', { dir })`.
|
|
474
|
+
*/
|
|
475
|
+
export const screenshotMatchers = {
|
|
476
|
+
async toMatchScreenshot(page: Page, name: string, opts: ScreenshotOptions = {}) {
|
|
477
|
+
try {
|
|
478
|
+
const r = await page.matchScreenshot(name, opts);
|
|
479
|
+
return { pass: true, message: () => `expected screenshot '${name}' not to match (${r.diffPixels} pixels differ)` };
|
|
480
|
+
} catch (e) {
|
|
481
|
+
if (e instanceof ScreenshotMismatchError) return { pass: false, message: () => e.message };
|
|
482
|
+
throw e;
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Baselines on disk for `matchScreenshot` steps. Before a run: read each
|
|
2
|
+
// baseline (`<dir>/<name>.png` and its `<name>.json` with the display-list
|
|
3
|
+
// hash) and attach it, unless this connection already has those bytes. After
|
|
4
|
+
// it: write recorded images (missing baseline, update mode) and, on a
|
|
5
|
+
// mismatch, `<name>.actual.png` and `<name>.diff.png`.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import type { ScreenshotStep } from './steps.js';
|
|
10
|
+
|
|
11
|
+
/** Write new baselines instead of comparing (`LP_UPDATE_SNAPSHOTS=1`, or `bun test --update-snapshots` / `-u`). */
|
|
12
|
+
export const updateMode = (): boolean =>
|
|
13
|
+
['1', 'true', 'all'].includes(process.env.LP_UPDATE_SNAPSHOTS ?? '') ||
|
|
14
|
+
process.argv.includes('--update-snapshots') ||
|
|
15
|
+
process.argv.includes('-u');
|
|
16
|
+
|
|
17
|
+
export type ScreenshotResult = {
|
|
18
|
+
name: string;
|
|
19
|
+
pass: boolean;
|
|
20
|
+
/** Passed on the display-list hash: no raster, no pixel compare. */
|
|
21
|
+
hashHit: boolean;
|
|
22
|
+
/** No baseline was compared: the render was written as the baseline. */
|
|
23
|
+
recorded: boolean;
|
|
24
|
+
diffPixels: number;
|
|
25
|
+
sceneHash: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
/** Browser-side timings, ms. */
|
|
29
|
+
ms: Record<string, number>;
|
|
30
|
+
/** The written baseline (recorded) or the images written on a mismatch. */
|
|
31
|
+
files?: { baseline?: string; actual?: string; diff?: string };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class ScreenshotMismatchError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
readonly result: ScreenshotResult,
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A baseline as this connection last received it, by wire name. */
|
|
44
|
+
export type SentBaselines = Map<string, string>;
|
|
45
|
+
|
|
46
|
+
const paths = (step: ScreenshotStep) => {
|
|
47
|
+
const base = join(step.opts.dir ?? join(step.testDir, '__screenshots__'), step.name);
|
|
48
|
+
return { png: `${base}.png`, meta: `${base}.json`, actual: `${base}.actual.png`, diff: `${base}.diff.png` };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Fills `step.wire` from disk. */
|
|
52
|
+
export function prepare(step: ScreenshotStep, sent: SentBaselines): void {
|
|
53
|
+
const p = paths(step);
|
|
54
|
+
step.wire = {};
|
|
55
|
+
if (updateMode() || !existsSync(p.png)) {
|
|
56
|
+
step.wire = { update: true };
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const png = readFileSync(p.png);
|
|
60
|
+
const digest = createHash('sha1').update(png).digest('hex');
|
|
61
|
+
const key = `${p.png}`;
|
|
62
|
+
// The wire name is the baseline's path, so two tests' `home` never share a cache entry.
|
|
63
|
+
step.wire.name = key;
|
|
64
|
+
if (sent.get(key) !== digest) {
|
|
65
|
+
step.wire.baseline = png.toString('base64');
|
|
66
|
+
try {
|
|
67
|
+
const meta = JSON.parse(readFileSync(p.meta, 'utf8')) as { sceneHash?: string };
|
|
68
|
+
if (meta.sceneHash) step.wire.sceneHash = meta.sceneHash;
|
|
69
|
+
} catch {}
|
|
70
|
+
sent.set(key, digest);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toResult = (step: ScreenshotStep, r: any): ScreenshotResult => ({
|
|
75
|
+
name: step.name,
|
|
76
|
+
pass: r.pass,
|
|
77
|
+
hashHit: r.hashHit,
|
|
78
|
+
recorded: r.recorded,
|
|
79
|
+
diffPixels: r.diffPixels,
|
|
80
|
+
sceneHash: r.sceneHash,
|
|
81
|
+
width: r.width,
|
|
82
|
+
height: r.height,
|
|
83
|
+
ms: r.ms,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** A step's result from a successful run: writes recorded baselines. */
|
|
87
|
+
export function settle(step: ScreenshotStep, r: any): ScreenshotResult {
|
|
88
|
+
const p = paths(step);
|
|
89
|
+
const result = toResult(step, r);
|
|
90
|
+
rmSync(p.actual, { force: true });
|
|
91
|
+
rmSync(p.diff, { force: true });
|
|
92
|
+
if (r.recorded) {
|
|
93
|
+
const existed = existsSync(p.png);
|
|
94
|
+
mkdirSync(dirname(p.png), { recursive: true });
|
|
95
|
+
writeFileSync(p.png, Buffer.from(r.actual, 'base64'));
|
|
96
|
+
writeFileSync(p.meta, JSON.stringify({ sceneHash: r.sceneHash, width: r.width, height: r.height }, null, 2) + '\n');
|
|
97
|
+
result.files = { baseline: p.png };
|
|
98
|
+
if (!updateMode()) {
|
|
99
|
+
const what = existed ? 'was replaced' : 'did not exist';
|
|
100
|
+
throw new ScreenshotMismatchError(`screenshot '${step.name}': the baseline ${what}; wrote ${p.png}`, result);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A failed run's `error.data` for this step: writes the actual image and the diff. */
|
|
107
|
+
export function mismatch(step: ScreenshotStep, data: any, message: string): ScreenshotMismatchError {
|
|
108
|
+
const p = paths(step);
|
|
109
|
+
const result = toResult(step, data);
|
|
110
|
+
mkdirSync(dirname(p.actual), { recursive: true });
|
|
111
|
+
writeFileSync(p.actual, Buffer.from(data.actual, 'base64'));
|
|
112
|
+
writeFileSync(p.diff, Buffer.from(data.diff, 'base64'));
|
|
113
|
+
result.files = { baseline: p.png, actual: p.actual, diff: p.diff };
|
|
114
|
+
return new ScreenshotMismatchError(`${message}\n actual: ${p.actual}\n diff: ${p.diff}`, result);
|
|
115
|
+
}
|