@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/steps.ts
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
// Locators and steps as plain data: `page.run([...])` sends them to the
|
|
2
|
+
// browser in one LP.run command. Builders are Playwright-shaped
|
|
3
|
+
// (`button("Save")`, `dialog().hasText("…").button("OK")`, `.first()`), but
|
|
4
|
+
// nothing here talks to the browser. The wire shapes are those of
|
|
5
|
+
// `crates/lp-cdp/src/engine/driver.rs` (StepParams and friends).
|
|
6
|
+
|
|
7
|
+
export type TextMatch = string | RegExp;
|
|
8
|
+
type Pattern = string | { regex: string; flags?: string };
|
|
9
|
+
|
|
10
|
+
const pattern = (t: TextMatch): Pattern => {
|
|
11
|
+
if (typeof t === 'string') return t;
|
|
12
|
+
const flags = t.flags.replace(/[gyu]/g, '');
|
|
13
|
+
return flags ? { regex: t.source, flags } : { regex: t.source };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The wire form of a locator (see `crates/lp-cdp/src/engine/driver.rs`). */
|
|
17
|
+
export type LocatorSpec = {
|
|
18
|
+
css?: string;
|
|
19
|
+
testId?: string;
|
|
20
|
+
testIdAttribute?: string;
|
|
21
|
+
text?: Pattern;
|
|
22
|
+
role?: string;
|
|
23
|
+
name?: Pattern;
|
|
24
|
+
label?: Pattern;
|
|
25
|
+
placeholder?: Pattern;
|
|
26
|
+
exact?: boolean;
|
|
27
|
+
within?: LocatorSpec;
|
|
28
|
+
filter?: {
|
|
29
|
+
visible?: boolean;
|
|
30
|
+
hasText?: Pattern;
|
|
31
|
+
hasNotText?: Pattern;
|
|
32
|
+
has?: LocatorSpec;
|
|
33
|
+
hasNot?: LocatorSpec;
|
|
34
|
+
};
|
|
35
|
+
nth?: number;
|
|
36
|
+
/** Search inside the document of the `<iframe>` this locator matches (in the parent document). */
|
|
37
|
+
frame?: LocatorSpec;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Step = Record<string, unknown>;
|
|
41
|
+
type Exact = { exact?: boolean };
|
|
42
|
+
|
|
43
|
+
/** Anything that becomes one step of a run. */
|
|
44
|
+
export interface StepLike {
|
|
45
|
+
toStep(): Step;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ActOptions = { settle?: boolean; timeout?: number };
|
|
49
|
+
/**
|
|
50
|
+
* Pointer options of `click`, `dblclick`, `hover` and `wheel` (engine 0.0.10):
|
|
51
|
+
* `button` (left), `position` relative to the element's top-left corner
|
|
52
|
+
* (its center), `force` skips the actionability checks.
|
|
53
|
+
*/
|
|
54
|
+
export type PointerOptions = ActOptions & {
|
|
55
|
+
button?: 'left' | 'right' | 'middle';
|
|
56
|
+
position?: { x: number; y: number };
|
|
57
|
+
force?: boolean;
|
|
58
|
+
};
|
|
59
|
+
/** A file for `upload`: inline contents (`text` or `base64`) and an optional type. */
|
|
60
|
+
export type UploadFile = { name: string; text?: string; base64?: string; mimeType?: string };
|
|
61
|
+
export type ReadWhat = 'text' | 'value' | 'count' | 'visible' | 'box' | 'css' | 'name' | 'focused' | 'enabled';
|
|
62
|
+
|
|
63
|
+
export class Locator {
|
|
64
|
+
constructor(readonly spec: LocatorSpec) {}
|
|
65
|
+
|
|
66
|
+
// ---- narrowing (each returns a new locator) ----
|
|
67
|
+
|
|
68
|
+
private with(filter: NonNullable<LocatorSpec['filter']>): Locator {
|
|
69
|
+
// The browser filters the matches, then picks nth.
|
|
70
|
+
if (this.spec.nth !== undefined) throw new Error('filter before first() / last() / nth()');
|
|
71
|
+
return new Locator({ ...this.spec, filter: { ...this.spec.filter, ...filter } });
|
|
72
|
+
}
|
|
73
|
+
visible(visible = true): Locator {
|
|
74
|
+
return this.with({ visible });
|
|
75
|
+
}
|
|
76
|
+
hasText(text: TextMatch): Locator {
|
|
77
|
+
return this.with({ hasText: pattern(text) });
|
|
78
|
+
}
|
|
79
|
+
hasNotText(text: TextMatch): Locator {
|
|
80
|
+
return this.with({ hasNotText: pattern(text) });
|
|
81
|
+
}
|
|
82
|
+
has(inner: Locator): Locator {
|
|
83
|
+
return this.with({ has: inner.spec });
|
|
84
|
+
}
|
|
85
|
+
hasNot(inner: Locator): Locator {
|
|
86
|
+
return this.with({ hasNot: inner.spec });
|
|
87
|
+
}
|
|
88
|
+
nth(index: number): Locator {
|
|
89
|
+
return new Locator({ ...this.spec, nth: index });
|
|
90
|
+
}
|
|
91
|
+
first(): Locator {
|
|
92
|
+
return this.nth(0);
|
|
93
|
+
}
|
|
94
|
+
last(): Locator {
|
|
95
|
+
return this.nth(-1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- locators inside this one ----
|
|
99
|
+
|
|
100
|
+
/** `spec` scoped to this locator; a `frame` stays on the outermost locator. */
|
|
101
|
+
private inner(spec: LocatorSpec): Locator {
|
|
102
|
+
const { frame, ...within } = this.spec;
|
|
103
|
+
return new Locator({ ...spec, within, ...(frame ? { frame } : {}) });
|
|
104
|
+
}
|
|
105
|
+
locator(css: string): Locator {
|
|
106
|
+
return this.inner({ css });
|
|
107
|
+
}
|
|
108
|
+
role(role: string, name?: TextMatch, opts: Exact = {}): Locator {
|
|
109
|
+
return this.inner(roleSpec(role, name, opts));
|
|
110
|
+
}
|
|
111
|
+
button(name?: TextMatch, opts?: Exact): Locator {
|
|
112
|
+
return this.role('button', name, opts);
|
|
113
|
+
}
|
|
114
|
+
link(name?: TextMatch, opts?: Exact): Locator {
|
|
115
|
+
return this.role('link', name, opts);
|
|
116
|
+
}
|
|
117
|
+
textbox(name?: TextMatch, opts?: Exact): Locator {
|
|
118
|
+
return this.role('textbox', name, opts);
|
|
119
|
+
}
|
|
120
|
+
text(text: TextMatch, opts: Exact = {}): Locator {
|
|
121
|
+
return this.inner(textSpec(text, opts));
|
|
122
|
+
}
|
|
123
|
+
label(text: TextMatch, opts: Exact = {}): Locator {
|
|
124
|
+
return this.inner({ label: pattern(text), ...exact(opts) });
|
|
125
|
+
}
|
|
126
|
+
placeholder(text: TextMatch, opts: Exact = {}): Locator {
|
|
127
|
+
return this.inner({ placeholder: pattern(text), ...exact(opts) });
|
|
128
|
+
}
|
|
129
|
+
testId(id: string): Locator {
|
|
130
|
+
return this.inner({ testId: id });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- frames ----
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Locators inside the document of the `<iframe>` this locator matches:
|
|
137
|
+
* `css('#editor').frame().button('Bold')` (Playwright's `frameLocator`).
|
|
138
|
+
*/
|
|
139
|
+
frame(): FrameScope {
|
|
140
|
+
return new FrameScope(this);
|
|
141
|
+
}
|
|
142
|
+
/** This locator, searched in the document of the `<iframe>` `frame` matches. */
|
|
143
|
+
inFrame(frame: Locator): Locator {
|
|
144
|
+
return new Locator({ ...this.spec, frame: frame.spec });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- steps ----
|
|
148
|
+
|
|
149
|
+
click(opts: PointerOptions = {}): StepLike {
|
|
150
|
+
return act(this, 'click', undefined, opts);
|
|
151
|
+
}
|
|
152
|
+
/** Two clicks at the center (the second with click count 2). */
|
|
153
|
+
dblclick(opts: PointerOptions = {}): StepLike {
|
|
154
|
+
return act(this, 'dblclick', undefined, opts);
|
|
155
|
+
}
|
|
156
|
+
/** Moves the pointer to the element (tooltips, hover toolbars). */
|
|
157
|
+
hover(opts: PointerOptions = {}): StepLike {
|
|
158
|
+
return act(this, 'hover', undefined, opts);
|
|
159
|
+
}
|
|
160
|
+
fill(value: string, opts: ActOptions = {}): StepLike {
|
|
161
|
+
return act(this, 'fill', value, opts);
|
|
162
|
+
}
|
|
163
|
+
/** Focuses, then one key press per character (Playwright's `pressSequentially`). */
|
|
164
|
+
type(text: string, opts: ActOptions = {}): StepLike {
|
|
165
|
+
return act(this, 'type', text, opts);
|
|
166
|
+
}
|
|
167
|
+
press(key: string, opts: ActOptions = {}): StepLike {
|
|
168
|
+
return act(this, 'press', key, opts);
|
|
169
|
+
}
|
|
170
|
+
check(opts: ActOptions = {}): StepLike {
|
|
171
|
+
return act(this, 'check', undefined, opts);
|
|
172
|
+
}
|
|
173
|
+
uncheck(opts: ActOptions = {}): StepLike {
|
|
174
|
+
return act(this, 'uncheck', undefined, opts);
|
|
175
|
+
}
|
|
176
|
+
focus(): StepLike {
|
|
177
|
+
return act(this, 'focus', undefined, { settle: false });
|
|
178
|
+
}
|
|
179
|
+
blur(): StepLike {
|
|
180
|
+
return act(this, 'blur', undefined, { settle: false });
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Sets the files of an `<input type=file>`: inline files, or a path (several:
|
|
184
|
+
* one per line) on the machine running Lightningpanda.
|
|
185
|
+
*/
|
|
186
|
+
upload(files: UploadFile[] | string, opts: ActOptions = {}): StepLike {
|
|
187
|
+
return typeof files === 'string'
|
|
188
|
+
? act(this, 'upload', files, opts)
|
|
189
|
+
: act(this, 'upload', undefined, { ...opts, files });
|
|
190
|
+
}
|
|
191
|
+
dropFiles(files: UploadFile[] | string, opts: ActOptions = {}): StepLike {
|
|
192
|
+
return typeof files === 'string'
|
|
193
|
+
? act(this, 'dropFiles', files, opts)
|
|
194
|
+
: act(this, 'dropFiles', undefined, { ...opts, files });
|
|
195
|
+
}
|
|
196
|
+
/** Drags the element onto `to`: native drag and drop when it is a drag source, else a pointer drag. */
|
|
197
|
+
drag(to: Locator, opts: ActOptions = {}): StepLike {
|
|
198
|
+
return act(this, 'drag', undefined, { ...opts, to: to.spec });
|
|
199
|
+
}
|
|
200
|
+
/** Scrolls with the mouse wheel over the element (engine 0.0.10). */
|
|
201
|
+
wheel(deltaX: number, deltaY: number, opts: ActOptions = {}): StepLike {
|
|
202
|
+
return act(this, 'wheel', undefined, { ...opts, deltaX, deltaY });
|
|
203
|
+
}
|
|
204
|
+
/** Compares this element's box with the baseline `name` (see `matchScreenshot`). */
|
|
205
|
+
matchScreenshot(name: string, opts: ScreenshotOptions = {}): ScreenshotStep {
|
|
206
|
+
return new ScreenshotStep(name, { ...opts, locator: this.spec }, callerDir());
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Reads a value into the run's `values[as]`: `read('n', 'css', 'color')`
|
|
210
|
+
* for a computed style property, `'name'` for the accessible name.
|
|
211
|
+
*/
|
|
212
|
+
read(as: string, what: ReadWhat = 'text', property?: string): StepLike {
|
|
213
|
+
if (what === 'css' && !property) throw new Error("read(as, 'css', property) needs the property");
|
|
214
|
+
const body = { locator: this.spec, read: what, as, ...(property === undefined ? {} : { property }) };
|
|
215
|
+
return { toStep: () => ({ read: body }) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Locators inside an `<iframe>`'s document (`loc.frame()`). */
|
|
220
|
+
export class FrameScope {
|
|
221
|
+
constructor(private frameLocator: Locator) {}
|
|
222
|
+
locator(css: string): Locator {
|
|
223
|
+
return css_(css).inFrame(this.frameLocator);
|
|
224
|
+
}
|
|
225
|
+
role(role: string, name?: TextMatch, opts: Exact = {}): Locator {
|
|
226
|
+
return new Locator(roleSpec(role, name, opts)).inFrame(this.frameLocator);
|
|
227
|
+
}
|
|
228
|
+
button(name?: TextMatch, opts?: Exact): Locator {
|
|
229
|
+
return this.role('button', name, opts);
|
|
230
|
+
}
|
|
231
|
+
link(name?: TextMatch, opts?: Exact): Locator {
|
|
232
|
+
return this.role('link', name, opts);
|
|
233
|
+
}
|
|
234
|
+
textbox(name?: TextMatch, opts?: Exact): Locator {
|
|
235
|
+
return this.role('textbox', name, opts);
|
|
236
|
+
}
|
|
237
|
+
text(text: TextMatch, opts: Exact = {}): Locator {
|
|
238
|
+
return new Locator(textSpec(text, opts)).inFrame(this.frameLocator);
|
|
239
|
+
}
|
|
240
|
+
label(text: TextMatch, opts: Exact = {}): Locator {
|
|
241
|
+
return new Locator({ label: pattern(text), ...exact(opts) }).inFrame(this.frameLocator);
|
|
242
|
+
}
|
|
243
|
+
placeholder(text: TextMatch, opts: Exact = {}): Locator {
|
|
244
|
+
return new Locator({ placeholder: pattern(text), ...exact(opts) }).inFrame(this.frameLocator);
|
|
245
|
+
}
|
|
246
|
+
testId(id: string): Locator {
|
|
247
|
+
return new Locator({ testId: id }).inFrame(this.frameLocator);
|
|
248
|
+
}
|
|
249
|
+
/** A nested `<iframe>` inside this one. */
|
|
250
|
+
frame(inner: Locator): FrameScope {
|
|
251
|
+
return inner.inFrame(this.frameLocator).frame();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const exact = (opts: Exact) => (opts.exact ? { exact: true } : {});
|
|
256
|
+
const roleSpec = (role: string, name: TextMatch | undefined, opts: Exact): LocatorSpec => ({
|
|
257
|
+
role,
|
|
258
|
+
...(name === undefined ? {} : { name: pattern(name) }),
|
|
259
|
+
...exact(opts),
|
|
260
|
+
});
|
|
261
|
+
const textSpec = (text: TextMatch, opts: Exact): LocatorSpec => ({ text: pattern(text), ...exact(opts) });
|
|
262
|
+
|
|
263
|
+
function act(loc: Locator, action: string, value: string | undefined, opts: Record<string, unknown>): StepLike {
|
|
264
|
+
return { toStep: () => ({ act: { locator: loc.spec, action, ...(value === undefined ? {} : { value }), ...opts } }) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- top-level locators ----
|
|
268
|
+
|
|
269
|
+
const css_ = (selector: string) => new Locator({ css: selector });
|
|
270
|
+
export const css = css_;
|
|
271
|
+
export const testId = (id: string, attribute?: string) =>
|
|
272
|
+
new Locator(attribute ? { testId: id, testIdAttribute: attribute } : { testId: id });
|
|
273
|
+
export const text = (t: TextMatch, opts: Exact = {}) => new Locator(textSpec(t, opts));
|
|
274
|
+
export const label = (t: TextMatch, opts: Exact = {}) => new Locator({ label: pattern(t), ...exact(opts) });
|
|
275
|
+
export const placeholder = (t: TextMatch, opts: Exact = {}) => new Locator({ placeholder: pattern(t), ...exact(opts) });
|
|
276
|
+
export const role = (r: string, name?: TextMatch, opts: Exact = {}) => new Locator(roleSpec(r, name, opts));
|
|
277
|
+
export const button = (name?: TextMatch, opts?: Exact) => role('button', name, opts);
|
|
278
|
+
export const link = (name?: TextMatch, opts?: Exact) => role('link', name, opts);
|
|
279
|
+
export const dialog = (name?: TextMatch, opts?: Exact) => role('dialog', name, opts);
|
|
280
|
+
export const heading = (name?: TextMatch, opts?: Exact) => role('heading', name, opts);
|
|
281
|
+
export const textbox = (name?: TextMatch, opts?: Exact) => role('textbox', name, opts);
|
|
282
|
+
export const checkbox = (name?: TextMatch, opts?: Exact) => role('checkbox', name, opts);
|
|
283
|
+
export const combobox = (name?: TextMatch, opts?: Exact) => role('combobox', name, opts);
|
|
284
|
+
export const option = (name?: TextMatch, opts?: Exact) => role('option', name, opts);
|
|
285
|
+
export const row = (name?: TextMatch, opts?: Exact) => role('row', name, opts);
|
|
286
|
+
export const cell = (name?: TextMatch, opts?: Exact) => role('cell', name, opts);
|
|
287
|
+
export const tab = (name?: TextMatch, opts?: Exact) => role('tab', name, opts);
|
|
288
|
+
export const menuitem = (name?: TextMatch, opts?: Exact) => role('menuitem', name, opts);
|
|
289
|
+
/** Locators inside the `<iframe>` `frame` matches: `inFrame(css('#editor')).button('Bold')`. */
|
|
290
|
+
export const inFrame = (frame: Locator) => frame.frame();
|
|
291
|
+
|
|
292
|
+
// ---- checks: each waits in the browser until it holds ----
|
|
293
|
+
|
|
294
|
+
type CheckOpts = { timeout?: number };
|
|
295
|
+
|
|
296
|
+
class Check {
|
|
297
|
+
constructor(
|
|
298
|
+
private loc: Locator,
|
|
299
|
+
private negate = false,
|
|
300
|
+
) {}
|
|
301
|
+
get not(): Check {
|
|
302
|
+
return new Check(this.loc, !this.negate);
|
|
303
|
+
}
|
|
304
|
+
private step(assert: string, expected?: unknown, opts: CheckOpts = {}, extra: Record<string, unknown> = {}): StepLike {
|
|
305
|
+
const body = {
|
|
306
|
+
locator: this.loc.spec,
|
|
307
|
+
assert,
|
|
308
|
+
...(expected === undefined ? {} : { expected }),
|
|
309
|
+
...(this.negate ? { not: true } : {}),
|
|
310
|
+
...extra,
|
|
311
|
+
...opts,
|
|
312
|
+
};
|
|
313
|
+
return { toStep: () => ({ check: body }) };
|
|
314
|
+
}
|
|
315
|
+
visible(opts?: CheckOpts) {
|
|
316
|
+
return this.step('visible', undefined, opts);
|
|
317
|
+
}
|
|
318
|
+
hidden(opts?: CheckOpts) {
|
|
319
|
+
return this.step('hidden', undefined, opts);
|
|
320
|
+
}
|
|
321
|
+
text(expected: string, opts?: CheckOpts) {
|
|
322
|
+
return this.step('text', expected, opts);
|
|
323
|
+
}
|
|
324
|
+
containsText(expected: string, opts?: CheckOpts) {
|
|
325
|
+
return this.step('containsText', expected, opts);
|
|
326
|
+
}
|
|
327
|
+
value(expected: string, opts?: CheckOpts) {
|
|
328
|
+
return this.step('value', expected, opts);
|
|
329
|
+
}
|
|
330
|
+
count(expected: number, opts?: CheckOpts) {
|
|
331
|
+
return this.step('count', expected, opts);
|
|
332
|
+
}
|
|
333
|
+
checked(expected = true, opts?: CheckOpts) {
|
|
334
|
+
return this.step('checked', expected, opts);
|
|
335
|
+
}
|
|
336
|
+
/** A computed style property: `check(button('Save')).css('background-color', 'rgb(1, 2, 3)')`. */
|
|
337
|
+
css(property: string, expected: string, opts?: CheckOpts) {
|
|
338
|
+
return this.step('css', expected, opts, { property });
|
|
339
|
+
}
|
|
340
|
+
/** The element is the document's focused element. */
|
|
341
|
+
focused(expected = true, opts?: CheckOpts) {
|
|
342
|
+
return this.step('focused', expected, opts);
|
|
343
|
+
}
|
|
344
|
+
/** The accessible name (exact after whitespace normalization). */
|
|
345
|
+
name(expected: string, opts?: CheckOpts) {
|
|
346
|
+
return this.step('name', expected, opts);
|
|
347
|
+
}
|
|
348
|
+
enabled(expected = true, opts?: CheckOpts) {
|
|
349
|
+
return this.step('enabled', expected, opts);
|
|
350
|
+
}
|
|
351
|
+
disabled(opts?: CheckOpts) {
|
|
352
|
+
return this.step('enabled', false, opts);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
class RequestCheck implements StepLike {
|
|
357
|
+
constructor(private m: Record<string, unknown>) {}
|
|
358
|
+
/** One status, or any of several (`[200, 304]`). */
|
|
359
|
+
status(status: number | number[]): RequestCheck {
|
|
360
|
+
return new RequestCheck({ ...this.m, status });
|
|
361
|
+
}
|
|
362
|
+
count(count: number): RequestCheck {
|
|
363
|
+
return new RequestCheck({ ...this.m, count });
|
|
364
|
+
}
|
|
365
|
+
/** The request body contains (or matches) `body` (engine 0.0.10). */
|
|
366
|
+
body(body: TextMatch): RequestCheck {
|
|
367
|
+
return new RequestCheck({ ...this.m, body: pattern(body) });
|
|
368
|
+
}
|
|
369
|
+
/** The request body is a JSON object with exactly these top-level keys (engine 0.0.10). */
|
|
370
|
+
bodyKeys(keys: string[]): RequestCheck {
|
|
371
|
+
return new RequestCheck({ ...this.m, bodyKeys: keys });
|
|
372
|
+
}
|
|
373
|
+
/** Request headers (case-insensitive names) whose values contain (or match) these (engine 0.0.10). */
|
|
374
|
+
headers(headers: Record<string, TextMatch>): RequestCheck {
|
|
375
|
+
const wire = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, pattern(v)]));
|
|
376
|
+
return new RequestCheck({ ...this.m, headers: wire });
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* `started` also counts requests still in flight (`count` included);
|
|
380
|
+
* `finished` (the default) only those with a response or an error (engine 0.0.10).
|
|
381
|
+
*/
|
|
382
|
+
state(state: 'started' | 'finished'): RequestCheck {
|
|
383
|
+
return new RequestCheck({ ...this.m, state });
|
|
384
|
+
}
|
|
385
|
+
/** Include prior runs of this page only; batch scope is the default. */
|
|
386
|
+
scope(scope: 'batch' | 'context'): RequestCheck {
|
|
387
|
+
return new RequestCheck({ ...this.m, scope });
|
|
388
|
+
}
|
|
389
|
+
timeout(timeout: number): RequestCheck {
|
|
390
|
+
return new RequestCheck({ ...this.m, timeout });
|
|
391
|
+
}
|
|
392
|
+
toStep(): Step {
|
|
393
|
+
return { request: this.m };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* `check(locator).visible()`, `.text("…")`, `.count(n)`, `.not.visible()`;
|
|
399
|
+
* `check.request("POST", "/path").status(200)` checks the run's request log
|
|
400
|
+
* (`path` is exact, a RegExp tests the full URL).
|
|
401
|
+
*/
|
|
402
|
+
export const check = Object.assign((loc: Locator) => new Check(loc), {
|
|
403
|
+
request: (method: string, path: string | RegExp) =>
|
|
404
|
+
new RequestCheck(typeof path === 'string' ? { method, path } : { method, url: pattern(path) }),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ---- navigation and escape hatches ----
|
|
408
|
+
|
|
409
|
+
export const goto = (url: string, waitUntil: 'load' | 'domcontentloaded' = 'load'): StepLike => ({
|
|
410
|
+
toStep: () => ({ goto: { url, waitUntil } }),
|
|
411
|
+
});
|
|
412
|
+
/**
|
|
413
|
+
* Same-document navigation (pushState + popstate), as a client-side router
|
|
414
|
+
* sees a link. Settles on the requests it starts unless `settle: false`.
|
|
415
|
+
*/
|
|
416
|
+
export const route = (url: string, opts: ActOptions = {}): StepLike => ({ toStep: () => ({ route: { url, ...opts } }) });
|
|
417
|
+
/**
|
|
418
|
+
* Evaluates an expression in the page; its JSON value lands in `values[as]`.
|
|
419
|
+
* A returned promise is awaited (within `timeout`); a rejection fails the step.
|
|
420
|
+
*/
|
|
421
|
+
export const evaluate = (expression: string, as?: string, opts: CheckOpts = {}): StepLike => ({
|
|
422
|
+
toStep: () => ({ eval: { expression, ...(as ? { as } : {}), ...opts } }),
|
|
423
|
+
});
|
|
424
|
+
/**
|
|
425
|
+
* Waits for the current document's `load`. On a page taken from a pool while
|
|
426
|
+
* still booting (`lp.page()` adds it), it waits for the profile URL's
|
|
427
|
+
* navigation to commit and load, never for the `about:blank` before it.
|
|
428
|
+
*/
|
|
429
|
+
export const loaded = (opts: CheckOpts = {}): StepLike => ({ toStep: () => ({ loaded: opts }) });
|
|
430
|
+
/**
|
|
431
|
+
* Waits until the page is quiet: no queued task, request in flight, timeout due
|
|
432
|
+
* within 50 ms, pending animation frame or unfinished navigation. Never fails;
|
|
433
|
+
* when `timeout` passes first its result has `settled: false` and `pending`.
|
|
434
|
+
*/
|
|
435
|
+
export const idle = (opts: { timeout?: number } = {}): StepLike => ({
|
|
436
|
+
toStep: () => ({ idle: opts.timeout === undefined ? {} : { timeout: opts.timeout } }),
|
|
437
|
+
});
|
|
438
|
+
/** Reloads the current document and waits for its `load`. */
|
|
439
|
+
export const reload = (opts: CheckOpts = {}): StepLike => ({ toStep: () => ({ reload: opts }) });
|
|
440
|
+
/** Waits until the page URL contains (or matches) `url` (Playwright's `waitForURL`). */
|
|
441
|
+
export const waitForUrl = (url: TextMatch, opts: CheckOpts = {}): StepLike => ({
|
|
442
|
+
toStep: () => ({ url: { url: pattern(url), ...opts } }),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ---- network mocks ----
|
|
446
|
+
|
|
447
|
+
export type MockOptions = {
|
|
448
|
+
method?: string;
|
|
449
|
+
status?: number;
|
|
450
|
+
/** Serialized as the body (content type `application/json`). */
|
|
451
|
+
json?: unknown;
|
|
452
|
+
body?: string;
|
|
453
|
+
contentType?: string;
|
|
454
|
+
headers?: Record<string, string>;
|
|
455
|
+
/** Milliseconds before the response (loading states). */
|
|
456
|
+
delay?: number;
|
|
457
|
+
/** Fail the request instead, with a `Network.ErrorReason` such as `Failed` or `ConnectionRefused`. */
|
|
458
|
+
abort?: string;
|
|
459
|
+
/** Answer only the first n matching requests. */
|
|
460
|
+
times?: number;
|
|
461
|
+
/** Let matching requests reach the network unchanged (an exception to a broader mock; engine 0.0.10). */
|
|
462
|
+
passthrough?: boolean;
|
|
463
|
+
/** Hold matching requests under this name until a `release(name)` step (engine 0.0.10). */
|
|
464
|
+
hold?: string;
|
|
465
|
+
/** Let the request reach the server, then rewrite the fields given of its response (engine 0.0.10). */
|
|
466
|
+
patch?: { status?: number; headers?: Record<string, string>; json?: unknown; body?: string };
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Answers requests of this page's context whose URL contains `url` (or
|
|
471
|
+
* matches the RegExp) with the given response (Playwright's `page.route` +
|
|
472
|
+
* `fulfill`): `mock('/api/items', { json: [] })`.
|
|
473
|
+
*/
|
|
474
|
+
export const mock = (url: TextMatch, opts: MockOptions = {}): StepLike => ({
|
|
475
|
+
toStep: () => ({ mock: { url: pattern(url), ...opts } }),
|
|
476
|
+
});
|
|
477
|
+
/** Removes the mocks matching `url` (all of them without one). */
|
|
478
|
+
export const unmock = (url?: TextMatch): StepLike => ({
|
|
479
|
+
toStep: () => ({ unmock: url === undefined ? {} : { url: pattern(url) } }),
|
|
480
|
+
});
|
|
481
|
+
/** Lets the requests held by `mock(url, { hold: name })` go on (engine 0.0.10). */
|
|
482
|
+
export const release = (hold: string): StepLike => ({ toStep: () => ({ release: { hold } }) });
|
|
483
|
+
|
|
484
|
+
// ---- emulation ----
|
|
485
|
+
|
|
486
|
+
/** Resizes the viewport mid-test; layout and `resize` listeners see the new size (engine 0.0.10). */
|
|
487
|
+
export const viewport = (width: number, height: number): StepLike => ({
|
|
488
|
+
toStep: () => ({ viewport: { width, height } }),
|
|
489
|
+
});
|
|
490
|
+
export type MediaOptions = {
|
|
491
|
+
colorScheme: 'light' | 'dark' | 'no-preference';
|
|
492
|
+
reducedMotion?: 'reduce' | 'no-preference';
|
|
493
|
+
/** `screen` or `print`. */
|
|
494
|
+
media?: string;
|
|
495
|
+
};
|
|
496
|
+
/** Emulates media features (`prefers-color-scheme` and friends) (engine 0.0.10). */
|
|
497
|
+
export const media = (opts: MediaOptions): StepLike => ({ toStep: () => ({ media: opts }) });
|
|
498
|
+
|
|
499
|
+
// ---- downloads, popups and the raw mouse ----
|
|
500
|
+
|
|
501
|
+
/** Reads this page's native initiated-download count without consuming download records. */
|
|
502
|
+
export const downloadCount = (as: string): StepLike => ({
|
|
503
|
+
toStep: () => ({ downloadCount: { as } }),
|
|
504
|
+
});
|
|
505
|
+
/**
|
|
506
|
+
* Waits for the next download the page starts during the run; its
|
|
507
|
+
* `{ url, filename, text, ... }` lands in `values[as]`, and the file in
|
|
508
|
+
* `saveTo` (a directory) when given.
|
|
509
|
+
*/
|
|
510
|
+
export const download = (opts: { saveTo?: string; as?: string; timeout?: number } = {}): StepLike => ({
|
|
511
|
+
toStep: () => ({ download: opts }),
|
|
512
|
+
});
|
|
513
|
+
/**
|
|
514
|
+
* The following steps drive another page of the context (engine 0.0.10):
|
|
515
|
+
* the `as` name of an earlier `popup(as)`, a target id, or `'main'` for the
|
|
516
|
+
* page the run started on. Add `loaded()` or `waitForUrl()` after switching
|
|
517
|
+
* to a fresh popup.
|
|
518
|
+
*/
|
|
519
|
+
export const switchTo = (target: string): StepLike => ({ toStep: () => ({ page: { target } }) });
|
|
520
|
+
/** Waits for the next popup the page opens during the run; its target id lands in `values[as]`. */
|
|
521
|
+
export const popup = (as?: string, opts: CheckOpts = {}): StepLike => ({
|
|
522
|
+
toStep: () => ({ popup: { ...(as ? { as } : {}), ...opts } }),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
export type MouseAction = 'click' | 'dblclick' | 'move' | 'down' | 'up' | 'wheel';
|
|
526
|
+
export type MouseOptions = {
|
|
527
|
+
/** Viewport point; required for `click`, `dblclick` and `move`, else the pointer stays where it is. */
|
|
528
|
+
x?: number;
|
|
529
|
+
y?: number;
|
|
530
|
+
button?: 'left' | 'right' | 'middle';
|
|
531
|
+
/** `wheel`: scroll deltas. */
|
|
532
|
+
deltaX?: number;
|
|
533
|
+
deltaY?: number;
|
|
534
|
+
/** `move`: intermediate `mousemove` events. */
|
|
535
|
+
steps?: number;
|
|
536
|
+
settle?: boolean;
|
|
537
|
+
timeout?: number;
|
|
538
|
+
};
|
|
539
|
+
/** A raw mouse event at viewport coordinates (engine 0.0.10): `mouse('click', { x: 10, y: 20 })`. */
|
|
540
|
+
export const mouse = (action: MouseAction, opts: MouseOptions = {}): StepLike => ({
|
|
541
|
+
toStep: () => ({ mouse: { action, ...opts } }),
|
|
542
|
+
});
|
|
543
|
+
/** Mouse wheel over an element (engine 0.0.10). */
|
|
544
|
+
export const wheel = (loc: Locator, deltaX: number, deltaY: number, opts: ActOptions = {}): StepLike =>
|
|
545
|
+
loc.wheel(deltaX, deltaY, opts);
|
|
546
|
+
|
|
547
|
+
// ---- screenshots ----
|
|
548
|
+
|
|
549
|
+
export type ScreenshotOptions = {
|
|
550
|
+
/** 0-1: how far a pixel's channels may differ and still match (default 0: exact). */
|
|
551
|
+
threshold?: number;
|
|
552
|
+
/** Differing pixels allowed (default 0). */
|
|
553
|
+
maxDiffPixels?: number;
|
|
554
|
+
/** Differing pixels allowed as a fraction of the image. */
|
|
555
|
+
maxDiffPixelRatio?: number;
|
|
556
|
+
/** A rectangle of the document, CSS px. */
|
|
557
|
+
clip?: { x: number; y: number; width: number; height: number };
|
|
558
|
+
/** The whole document, not only the viewport. */
|
|
559
|
+
fullPage?: boolean;
|
|
560
|
+
/** Where baselines live (default: `__screenshots__` beside the test file). */
|
|
561
|
+
dir?: string;
|
|
562
|
+
timeout?: number;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* A `matchScreenshot` step: the page renders in the browser and is compared
|
|
567
|
+
* there with the baseline `<dir>/<name>.png`. `page.run` reads the baseline
|
|
568
|
+
* (sent once per connection), writes it when missing or in update mode, and
|
|
569
|
+
* writes `<name>.actual.png` and `<name>.diff.png` on a mismatch.
|
|
570
|
+
*/
|
|
571
|
+
export class ScreenshotStep implements StepLike {
|
|
572
|
+
/** Baseline fields `page.run` adds (`baseline`, `sceneHash`, `update`). */
|
|
573
|
+
wire: Record<string, unknown> = {};
|
|
574
|
+
|
|
575
|
+
constructor(
|
|
576
|
+
readonly name: string,
|
|
577
|
+
readonly opts: ScreenshotOptions & { locator?: LocatorSpec },
|
|
578
|
+
/** Directory of the test file that built the step. */
|
|
579
|
+
readonly testDir: string,
|
|
580
|
+
) {
|
|
581
|
+
if (!/^[\w.@-][\w.@/-]*$/.test(name) || name.includes('..')) {
|
|
582
|
+
throw new Error(`screenshot name '${name}': use letters, digits, '.', '-', '_', '@' and '/'`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
toStep(): Step {
|
|
587
|
+
const { dir: _dir, ...opts } = this.opts;
|
|
588
|
+
return { matchScreenshot: { name: this.name, ...opts, ...this.wire } };
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Compares the viewport (or `clip`, or `fullPage`) with the baseline `name`. */
|
|
593
|
+
export const matchScreenshot = (name: string, opts: ScreenshotOptions = {}): ScreenshotStep =>
|
|
594
|
+
new ScreenshotStep(name, opts, callerDir());
|
|
595
|
+
|
|
596
|
+
/** Directory of the first stack frame outside this package (the test file). */
|
|
597
|
+
function callerDir(): string {
|
|
598
|
+
const here = import.meta.url.replace(/^file:\/\//, '').replace(/[^/]*$/, '');
|
|
599
|
+
for (const line of (new Error().stack ?? '').split('\n').slice(1)) {
|
|
600
|
+
const m = /(?:\(|at )(?:file:\/\/)?(\/[^():]+):\d+:\d+\)?\s*$/.exec(line);
|
|
601
|
+
if (m && !m[1].startsWith(here)) return m[1].replace(/\/[^/]*$/, '');
|
|
602
|
+
}
|
|
603
|
+
return process.cwd();
|
|
604
|
+
}
|