walkwright 0.0.1

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/bin/walkwright.js +3 -0
  4. package/build/aria.d.ts +92 -0
  5. package/build/aria.js +215 -0
  6. package/build/aria.js.map +1 -0
  7. package/build/browser.d.ts +3 -0
  8. package/build/browser.js +18 -0
  9. package/build/browser.js.map +1 -0
  10. package/build/chunk-5CHKYNP7.js +507 -0
  11. package/build/chunk-5CHKYNP7.js.map +1 -0
  12. package/build/chunk-6YRKPGUO.js +1197 -0
  13. package/build/chunk-6YRKPGUO.js.map +1 -0
  14. package/build/chunk-A452S7R3.js +35 -0
  15. package/build/chunk-A452S7R3.js.map +1 -0
  16. package/build/chunk-BODO6HIH.js +60 -0
  17. package/build/chunk-BODO6HIH.js.map +1 -0
  18. package/build/chunk-FJI2CMCE.js +439 -0
  19. package/build/chunk-FJI2CMCE.js.map +1 -0
  20. package/build/chunk-JYHZVRB2.js +80 -0
  21. package/build/chunk-JYHZVRB2.js.map +1 -0
  22. package/build/chunk-KQN5IYN6.js +97 -0
  23. package/build/chunk-KQN5IYN6.js.map +1 -0
  24. package/build/chunk-KTXNERHT.js +72 -0
  25. package/build/chunk-KTXNERHT.js.map +1 -0
  26. package/build/chunk-MFTC7XL2.js +141 -0
  27. package/build/chunk-MFTC7XL2.js.map +1 -0
  28. package/build/chunk-N4SSLHMP.js +108 -0
  29. package/build/chunk-N4SSLHMP.js.map +1 -0
  30. package/build/chunk-RNNW7OW2.js +270 -0
  31. package/build/chunk-RNNW7OW2.js.map +1 -0
  32. package/build/chunk-UAWBPTDW.js +51 -0
  33. package/build/chunk-UAWBPTDW.js.map +1 -0
  34. package/build/chunk-Z7CW5CAY.js +64 -0
  35. package/build/chunk-Z7CW5CAY.js.map +1 -0
  36. package/build/cli.d.ts +1 -0
  37. package/build/cli.js +253 -0
  38. package/build/cli.js.map +1 -0
  39. package/build/config.d.ts +13 -0
  40. package/build/config.js +15 -0
  41. package/build/config.js.map +1 -0
  42. package/build/descriptor.d.ts +96 -0
  43. package/build/descriptor.js +27 -0
  44. package/build/descriptor.js.map +1 -0
  45. package/build/device-auth.d.ts +6 -0
  46. package/build/device-auth.js +11 -0
  47. package/build/device-auth.js.map +1 -0
  48. package/build/locator.d.ts +140 -0
  49. package/build/locator.js +92 -0
  50. package/build/locator.js.map +1 -0
  51. package/build/paths.d.ts +11 -0
  52. package/build/paths.js +22 -0
  53. package/build/paths.js.map +1 -0
  54. package/build/pom-kcSMxwX2.d.ts +349 -0
  55. package/build/pom.d.ts +6 -0
  56. package/build/pom.js +37 -0
  57. package/build/pom.js.map +1 -0
  58. package/build/project.d.ts +13 -0
  59. package/build/project.js +19 -0
  60. package/build/project.js.map +1 -0
  61. package/build/provisioner.d.ts +34 -0
  62. package/build/provisioner.js +14 -0
  63. package/build/provisioner.js.map +1 -0
  64. package/build/recorder.d.ts +6 -0
  65. package/build/recorder.js +15 -0
  66. package/build/recorder.js.map +1 -0
  67. package/build/reporter.d.ts +16 -0
  68. package/build/reporter.js +426 -0
  69. package/build/reporter.js.map +1 -0
  70. package/build/schema.d.ts +49 -0
  71. package/build/schema.js +16 -0
  72. package/build/schema.js.map +1 -0
  73. package/build/storage-state.d.ts +22 -0
  74. package/build/storage-state.js +42 -0
  75. package/build/storage-state.js.map +1 -0
  76. package/build/store.d.ts +17 -0
  77. package/build/store.js +9 -0
  78. package/build/store.js.map +1 -0
  79. package/build/ui.d.ts +49 -0
  80. package/build/ui.js +56 -0
  81. package/build/ui.js.map +1 -0
  82. package/build/upload.d.ts +10 -0
  83. package/build/upload.js +8 -0
  84. package/build/upload.js.map +1 -0
  85. package/build/wait.d.ts +14 -0
  86. package/build/wait.js +50 -0
  87. package/build/wait.js.map +1 -0
  88. package/package.json +52 -0
  89. package/src/aria.ts +326 -0
  90. package/src/browser.ts +15 -0
  91. package/src/cli.ts +295 -0
  92. package/src/config.ts +16 -0
  93. package/src/descriptor.ts +179 -0
  94. package/src/device-auth.ts +188 -0
  95. package/src/locator.ts +683 -0
  96. package/src/paths.ts +70 -0
  97. package/src/pom.ts +2363 -0
  98. package/src/project.ts +77 -0
  99. package/src/provisioner.ts +183 -0
  100. package/src/recorder.ts +877 -0
  101. package/src/reporter.ts +561 -0
  102. package/src/schema.ts +37 -0
  103. package/src/storage-state.ts +71 -0
  104. package/src/store.ts +96 -0
  105. package/src/ui.ts +333 -0
  106. package/src/upload.ts +116 -0
  107. package/src/wait.ts +63 -0
@@ -0,0 +1,877 @@
1
+ import { mkdirSync } from "node:fs";
2
+
3
+ import type { Fixtures, Page, PlaywrightTestArgs } from "@playwright/test";
4
+
5
+ import type {
6
+ AppDescriptor,
7
+ ElementDescriptor,
8
+ OverlayDescriptor,
9
+ PageDescriptor,
10
+ RegexDescriptor,
11
+ SurfaceDescriptor,
12
+ } from "./descriptor.js";
13
+ import type {
14
+ LocatorDescriptor,
15
+ LocatorFilter,
16
+ LocatorNode,
17
+ Text as LocatorText,
18
+ Refinement,
19
+ } from "./locator.js";
20
+ import { paramNamesOf, resolveLocator } from "./locator.js";
21
+ import type { PageMeta } from "./pom.js";
22
+
23
+ export interface ShotRecord {
24
+ file: string;
25
+ state: string;
26
+ page: string;
27
+ path?: string;
28
+ group?: string[];
29
+ }
30
+
31
+ export interface Recorder {
32
+ record: {
33
+ <Target extends VisitablePage<never>>(
34
+ target: PageOrFactory<Target>,
35
+ state?: string,
36
+ options?: ShotOptions,
37
+ ): Promise<Target>;
38
+ <Params, Target extends VisitablePage<Params>>(
39
+ target: PageOrFactory<Target>,
40
+ params: Params,
41
+ state?: string,
42
+ options?: ShotOptions,
43
+ ): Promise<Target>;
44
+ (dialog: OverlayDialog, options?: ShotOptions): Promise<void>;
45
+ (
46
+ state: string,
47
+ dialog: OverlayDialog,
48
+ options?: ShotOptions,
49
+ ): Promise<void>;
50
+ (state?: string, options?: ShotOptions): Promise<void>;
51
+ };
52
+ shots: () => ShotRecord[];
53
+ }
54
+
55
+ export interface ShotOptions {
56
+ viewport?: { width?: number; height?: number };
57
+ page?: string;
58
+ group?: string[];
59
+ state?: string;
60
+ }
61
+
62
+ export type PageFactory<Target> = (page: Page) => Target;
63
+
64
+ export type PageOrFactory<Target> = Target | PageFactory<Target>;
65
+
66
+ export interface VisitablePage<Params> {
67
+ path: string;
68
+ name: string;
69
+ url: (params: Params) => string;
70
+ goto: (params: Params) => Promise<void>;
71
+ }
72
+
73
+ export interface OverlayDialog {
74
+ label?: string;
75
+ waitFor: (state?: "visible" | "hidden") => Promise<unknown>;
76
+ close: () => Promise<unknown>;
77
+ descriptor?: OverlayDescriptor;
78
+ self?: { chain: LocatorDescriptor };
79
+ }
80
+
81
+ interface Identity {
82
+ page: string;
83
+ path?: string;
84
+ group?: string[];
85
+ descriptor?: PageDescriptor;
86
+ }
87
+
88
+ interface CaptureOptions extends ShotOptions {
89
+ path?: string;
90
+ }
91
+
92
+ interface RecorderOptions {
93
+ enabled?: boolean;
94
+ }
95
+
96
+ const defaultTimeoutMs = 30_000;
97
+ const transitionSettleMs = 300;
98
+ const overlaySettleMs = 500;
99
+ const failedState = "failed";
100
+
101
+ function createRecorder(
102
+ page: Page,
103
+ runDir: string,
104
+ descriptor: AppDescriptor,
105
+ { enabled = true }: RecorderOptions = {},
106
+ ): Recorder {
107
+ page.setDefaultTimeout(defaultTimeoutMs);
108
+
109
+ const pages = pageMetasOf(descriptor);
110
+ const shots: ShotRecord[] = [];
111
+ const usedNames = new Map<string, number>();
112
+
113
+ const identityOf = (
114
+ target: VisitablePage<never> | VisitablePage<unknown>,
115
+ options?: ShotOptions,
116
+ ): CaptureOptions => {
117
+ const meta = pages.find((candidate) => candidate.path === target.path);
118
+ const group = options?.group ?? meta?.group;
119
+
120
+ return {
121
+ ...options,
122
+ page: options?.page ?? meta?.name ?? target.name,
123
+ path: target.path,
124
+ ...(group === undefined ? {} : { group }),
125
+ };
126
+ };
127
+
128
+ const capture = async (
129
+ state: string | undefined,
130
+ options?: CaptureOptions,
131
+ failure = false,
132
+ dialog?: OverlayDialog,
133
+ ): Promise<void> => {
134
+ if (!enabled) {
135
+ return;
136
+ }
137
+
138
+ const identity = identify(page.url(), descriptor.pages);
139
+ const shotPage = options?.page ?? identity.page;
140
+ const path =
141
+ options?.path ??
142
+ (options?.page === undefined ? identity.path : undefined);
143
+ const group = options?.group ?? identity.group;
144
+ const label = options?.state ?? state ?? "";
145
+
146
+ const name = slugParts([group?.[0], shotPage, label]);
147
+
148
+ const count = (usedNames.get(name) ?? 0) + 1;
149
+ usedNames.set(name, count);
150
+
151
+ const file = `${name}${count > 1 ? `-${String(count)}` : ""}.png`;
152
+
153
+ if (!failure) {
154
+ shots.push({
155
+ file,
156
+ state: label === "" ? "" : slugify(label),
157
+ page: shotPage,
158
+ ...(path === undefined ? {} : { path }),
159
+ ...(group === undefined ? {} : { group }),
160
+ });
161
+ }
162
+
163
+ const viewport = page.viewportSize();
164
+ const resize = options?.viewport !== undefined && viewport !== null;
165
+ if (resize) {
166
+ await page.setViewportSize({
167
+ width: options.viewport?.width ?? viewport.width,
168
+ height: options.viewport?.height ?? viewport.height,
169
+ });
170
+ }
171
+
172
+ try {
173
+ await page.waitForTimeout(transitionSettleMs);
174
+
175
+ const rules = collectRules(descriptor, identity.descriptor, dialog);
176
+
177
+ try {
178
+ if (rules.length > 0) {
179
+ await applyCaptureRules(page, rules);
180
+ }
181
+
182
+ await page.screenshot({ path: `${runDir}/${file}`, fullPage: true });
183
+ } finally {
184
+ if (rules.length > 0) {
185
+ await restoreCaptureRules(page).catch(() => undefined);
186
+ }
187
+ }
188
+ } finally {
189
+ if (resize) {
190
+ await page.setViewportSize(viewport);
191
+ await page.waitForTimeout(200);
192
+ }
193
+ }
194
+ };
195
+
196
+ const attempt = async <Result>(
197
+ what: string,
198
+ fn: () => Promise<Result>,
199
+ ): Promise<Result> => {
200
+ try {
201
+ return await fn();
202
+ } catch (error) {
203
+ await capture(failedState, undefined, true).catch(() => undefined);
204
+
205
+ throw new Error(`recording ${what}: ${describeError(error)}`, {
206
+ cause: error,
207
+ });
208
+ }
209
+ };
210
+
211
+ const alreadyAt = (url: string): boolean => {
212
+ try {
213
+ return pathOf(page.url()) === pathOf(new URL(url, page.url()).href);
214
+ } catch {
215
+ return false;
216
+ }
217
+ };
218
+
219
+ const visitPage = async <Params, Target extends VisitablePage<Params>>(
220
+ target: PageOrFactory<Target>,
221
+ params: Params,
222
+ state?: string,
223
+ options?: ShotOptions,
224
+ ): Promise<Target> => {
225
+ const visiting = instantiate(page, target);
226
+
227
+ await attempt(describe(visiting.name, state), async () => {
228
+ if (!alreadyAt(visiting.url(params))) {
229
+ await visiting.goto(params);
230
+ }
231
+
232
+ await capture(state, identityOf(visiting, options));
233
+ });
234
+
235
+ return visiting;
236
+ };
237
+
238
+ const shootCurrentPage = async <Target extends VisitablePage<never>>(
239
+ target: PageOrFactory<Target>,
240
+ state?: string,
241
+ options?: ShotOptions,
242
+ ): Promise<Target> => {
243
+ const shooting = instantiate(page, target);
244
+
245
+ await attempt(describe(shooting.name, state), async () => {
246
+ const path = pathOf(page.url());
247
+
248
+ if (!patternFor(shooting.path).test(path)) {
249
+ throw new Error(
250
+ `the "${shooting.name}" page is at ${shooting.path}, but the browser is at ${path}`,
251
+ );
252
+ }
253
+
254
+ await capture(state, identityOf(shooting, options));
255
+ });
256
+
257
+ return shooting;
258
+ };
259
+
260
+ const overlay = async (
261
+ state: string,
262
+ dialog: OverlayDialog,
263
+ options?: ShotOptions,
264
+ ): Promise<void> => {
265
+ await attempt(`the "${state}" overlay`, async () => {
266
+ await dialog.waitFor();
267
+ await page.waitForTimeout(overlaySettleMs);
268
+ await capture(state, options, false, dialog);
269
+ await dialog.close();
270
+ await page.waitForTimeout(overlaySettleMs);
271
+ });
272
+ };
273
+
274
+ const record = (async (
275
+ target?: string | PageOrFactory<VisitablePage<never>> | OverlayDialog,
276
+ second?: unknown,
277
+ third?: unknown,
278
+ options?: ShotOptions,
279
+ ) => {
280
+ if (isPageTarget(target)) {
281
+ if (second === undefined || typeof second === "string") {
282
+ return await shootCurrentPage(
283
+ target,
284
+ second,
285
+ third as ShotOptions | undefined,
286
+ );
287
+ }
288
+
289
+ return await visitPage(
290
+ target as PageOrFactory<VisitablePage<unknown>>,
291
+ second,
292
+ third as string | undefined,
293
+ options,
294
+ );
295
+ }
296
+
297
+ if (target !== undefined && typeof target !== "string") {
298
+ await overlay(
299
+ dialogState(target),
300
+ target,
301
+ second as ShotOptions | undefined,
302
+ );
303
+
304
+ return undefined;
305
+ }
306
+
307
+ if (isDialog(second)) {
308
+ await overlay(
309
+ target ?? dialogState(second),
310
+ second,
311
+ third as ShotOptions | undefined,
312
+ );
313
+
314
+ return undefined;
315
+ }
316
+
317
+ await capture(target, second as ShotOptions | undefined);
318
+ return undefined;
319
+ }) as Recorder["record"];
320
+
321
+ return {
322
+ record,
323
+ shots: () => shots,
324
+ };
325
+ }
326
+
327
+ export const runDirVariable = "WALKWRIGHT_RUN_DIR";
328
+
329
+ export const shotsAttachment = "walkwright-shots";
330
+
331
+ export interface RecordedShots {
332
+ pages: PageMeta[];
333
+ shots: ShotRecord[];
334
+ }
335
+
336
+ export interface RecorderFixtures {
337
+ recording: boolean;
338
+ runDir: string;
339
+ record: Recorder["record"];
340
+ }
341
+
342
+ export interface RecordedApp {
343
+ descriptor: AppDescriptor;
344
+ }
345
+
346
+ function pageMetasOf(descriptor: AppDescriptor): PageMeta[] {
347
+ return descriptor.pages.map(({ name, path, recorder }) => ({
348
+ name,
349
+ path,
350
+ ...(recorder?.group === undefined ? {} : { group: recorder.group }),
351
+ }));
352
+ }
353
+
354
+ export function createRecorderFixtures(
355
+ app: RecordedApp,
356
+ ): Fixtures<RecorderFixtures, object, PlaywrightTestArgs> {
357
+ return {
358
+ recording: [false, { option: true }],
359
+
360
+ runDir: async ({ recording }, use, testInfo): Promise<void> => {
361
+ if (!recording) {
362
+ await use(testInfo.outputDir);
363
+ return;
364
+ }
365
+
366
+ const runDir = process.env[runDirVariable];
367
+
368
+ if (runDir === undefined) {
369
+ throw new Error(
370
+ `no ${runDirVariable} in the environment — add walkwright's reporter to the Playwright config`,
371
+ );
372
+ }
373
+
374
+ mkdirSync(runDir, { recursive: true });
375
+
376
+ await use(runDir);
377
+ },
378
+
379
+ record: async (
380
+ { page, runDir, recording },
381
+ use,
382
+ testInfo,
383
+ ): Promise<void> => {
384
+ const recorder = createRecorder(page, runDir, app.descriptor, {
385
+ enabled: recording,
386
+ });
387
+
388
+ await use(recorder.record);
389
+
390
+ if (!recording) {
391
+ return;
392
+ }
393
+
394
+ const recorded: RecordedShots = {
395
+ pages: pageMetasOf(app.descriptor),
396
+ shots: recorder.shots(),
397
+ };
398
+
399
+ await testInfo.attach(shotsAttachment, {
400
+ contentType: "application/json",
401
+ body: Buffer.from(JSON.stringify(recorded)),
402
+ });
403
+ },
404
+ };
405
+ }
406
+
407
+ function describe(name: string, state?: string): string {
408
+ return state === undefined ? `the "${name}" page` : `"${name}" ${state}`;
409
+ }
410
+
411
+ function instantiate<Target>(
412
+ page: Page,
413
+ target: PageOrFactory<Target>,
414
+ ): Target {
415
+ return typeof target === "function"
416
+ ? (target as PageFactory<Target>)(page)
417
+ : target;
418
+ }
419
+
420
+ function isPageTarget(
421
+ value: unknown,
422
+ ): value is PageOrFactory<VisitablePage<never>> {
423
+ if (typeof value === "function") {
424
+ return true;
425
+ }
426
+
427
+ return (
428
+ typeof value === "object" &&
429
+ value !== null &&
430
+ "goto" in value &&
431
+ "path" in value
432
+ );
433
+ }
434
+
435
+ function isDialog(value: unknown): value is OverlayDialog {
436
+ return (
437
+ typeof value === "object" &&
438
+ value !== null &&
439
+ "close" in value &&
440
+ "waitFor" in value
441
+ );
442
+ }
443
+
444
+ function dialogState(dialog: OverlayDialog): string {
445
+ if (dialog.label === undefined) {
446
+ throw new Error("the overlay needs an explicit state: it has no label");
447
+ }
448
+
449
+ return dialog.label;
450
+ }
451
+
452
+ function identify(url: string, pages: PageDescriptor[]): Identity {
453
+ const path = pathOf(url);
454
+
455
+ const matched = pages
456
+ .filter((candidate) => patternFor(candidate.path).test(path))
457
+ .sort(
458
+ (a, b) =>
459
+ paramCount(a.path) - paramCount(b.path) ||
460
+ b.path.length - a.path.length,
461
+ )
462
+ .at(0);
463
+
464
+ if (matched === undefined) {
465
+ return { page: pathSlug(path) };
466
+ }
467
+
468
+ return {
469
+ page: matched.name,
470
+ path: matched.path,
471
+ ...(matched.recorder?.group === undefined
472
+ ? {}
473
+ : { group: matched.recorder.group }),
474
+ descriptor: matched,
475
+ };
476
+ }
477
+
478
+ interface CollectedRule {
479
+ scope: LocatorDescriptor;
480
+ match?: RegexDescriptor;
481
+ text: string;
482
+ }
483
+
484
+ function chainForElement(
485
+ path: string[],
486
+ root: LocatorDescriptor,
487
+ elements: Record<string, ElementDescriptor>,
488
+ ): LocatorDescriptor {
489
+ let chain = root;
490
+ let current = elements;
491
+
492
+ for (const segment of path) {
493
+ const found = current[segment];
494
+
495
+ if (found === undefined) {
496
+ throw new Error(`capture rule references unknown element "${segment}"`);
497
+ }
498
+
499
+ if ("locator" in found) {
500
+ chain = [...chain, ...found.locator];
501
+ continue;
502
+ }
503
+
504
+ if ("surface" in found) {
505
+ chain = [...chain, ...(found.surface.root ?? [])];
506
+ current = found.surface.elements;
507
+ continue;
508
+ }
509
+
510
+ if ("overlay" in found) {
511
+ chain = [...chain, ...found.overlay.root];
512
+ current = found.overlay.elements;
513
+ continue;
514
+ }
515
+
516
+ throw new Error(`capture rule targets "${segment}", which has no locator`);
517
+ }
518
+
519
+ return chain;
520
+ }
521
+
522
+ function collectAt(
523
+ root: LocatorDescriptor,
524
+ elements: Record<string, ElementDescriptor>,
525
+ rule: { element?: string[]; match?: RegexDescriptor; text: string },
526
+ ): CollectedRule {
527
+ const chain =
528
+ rule.element === undefined
529
+ ? root
530
+ : chainForElement(rule.element, root, elements);
531
+
532
+ return {
533
+ scope: widenChain(chain),
534
+ ...(rule.match === undefined ? {} : { match: rule.match }),
535
+ text: rule.text,
536
+ };
537
+ }
538
+
539
+ function collectSurfaceRules(
540
+ surface: SurfaceDescriptor,
541
+ root: LocatorDescriptor,
542
+ out: CollectedRule[],
543
+ ): void {
544
+ for (const element of Object.values(surface.elements)) {
545
+ if ("surface" in element) {
546
+ collectSurfaceRules(
547
+ element.surface,
548
+ [...root, ...(element.surface.root ?? [])],
549
+ out,
550
+ );
551
+ } else if ("overlay" in element) {
552
+ collectSurfaceRules(
553
+ element.overlay,
554
+ [...root, ...element.overlay.root],
555
+ out,
556
+ );
557
+ }
558
+ }
559
+
560
+ for (const rule of surface.capture?.rules ?? []) {
561
+ out.push(collectAt(root, surface.elements, rule));
562
+ }
563
+ }
564
+
565
+ function collectRules(
566
+ app: AppDescriptor,
567
+ page: PageDescriptor | undefined,
568
+ dialog: OverlayDialog | undefined,
569
+ ): CollectedRule[] {
570
+ const out: CollectedRule[] = [];
571
+
572
+ if (dialog?.descriptor !== undefined && dialog.self !== undefined) {
573
+ collectSurfaceRules(dialog.descriptor, dialog.self.chain, out);
574
+ }
575
+
576
+ if (page !== undefined) {
577
+ collectSurfaceRules(page, [], out);
578
+ }
579
+
580
+ for (const rule of app.capture?.rules ?? []) {
581
+ out.push({ scope: [], match: rule.match, text: rule.text });
582
+ }
583
+
584
+ return out;
585
+ }
586
+
587
+ function textCarriesParam(text: LocatorText | undefined): boolean {
588
+ if (text === undefined) {
589
+ return false;
590
+ }
591
+
592
+ return paramNamesOf([{ by: "text", text }]).length > 0;
593
+ }
594
+
595
+ function widenFilter(filter: LocatorFilter): LocatorFilter | undefined {
596
+ const widened: LocatorFilter = {
597
+ ...(filter.hasText === undefined || textCarriesParam(filter.hasText)
598
+ ? {}
599
+ : { hasText: filter.hasText }),
600
+ ...(filter.has === undefined ? {} : { has: widenChain(filter.has) }),
601
+ ...(filter.hasNot === undefined
602
+ ? {}
603
+ : { hasNot: widenChain(filter.hasNot) }),
604
+ };
605
+
606
+ return Object.keys(widened).length === 0 ? undefined : widened;
607
+ }
608
+
609
+ function widenRefinement(refinement: Refinement): Refinement | undefined {
610
+ if ("nth" in refinement) {
611
+ return refinement;
612
+ }
613
+
614
+ const filter = widenFilter(refinement.filter);
615
+ return filter === undefined ? undefined : { filter };
616
+ }
617
+
618
+ function widenRefine(
619
+ refine: Refinement[] | undefined,
620
+ ): Refinement[] | undefined {
621
+ if (refine === undefined) {
622
+ return undefined;
623
+ }
624
+
625
+ const widened = refine
626
+ .map(widenRefinement)
627
+ .filter((refinement): refinement is Refinement => refinement !== undefined);
628
+
629
+ return widened.length === 0 ? undefined : widened;
630
+ }
631
+
632
+ function widenNode(node: LocatorNode): LocatorNode | undefined {
633
+ if ("or" in node) {
634
+ return { or: node.or.map(widenChain) };
635
+ }
636
+
637
+ if ("and" in node) {
638
+ return { and: node.and.map(widenChain) };
639
+ }
640
+
641
+ if (
642
+ (node.by === "text" ||
643
+ node.by === "label" ||
644
+ node.by === "placeholder" ||
645
+ node.by === "title") &&
646
+ textCarriesParam(node.text)
647
+ ) {
648
+ return undefined;
649
+ }
650
+
651
+ const { refine, ...base } = node;
652
+ const widenedRefine = widenRefine(refine);
653
+
654
+ if (base.by === "role" && textCarriesParam(base.name)) {
655
+ const { name, ...rest } = base;
656
+ return {
657
+ ...rest,
658
+ ...(widenedRefine === undefined ? {} : { refine: widenedRefine }),
659
+ };
660
+ }
661
+
662
+ return {
663
+ ...base,
664
+ ...(widenedRefine === undefined ? {} : { refine: widenedRefine }),
665
+ };
666
+ }
667
+
668
+ function widenChain(chain: LocatorDescriptor): LocatorDescriptor {
669
+ return chain
670
+ .map(widenNode)
671
+ .filter((node): node is LocatorNode => node !== undefined);
672
+ }
673
+
674
+ declare global {
675
+ interface Window {
676
+ __walkwrightRestore?: (() => void)[];
677
+ }
678
+ }
679
+
680
+ function applyRule(
681
+ nodes: (SVGElement | HTMLElement)[],
682
+ rule: { text: string; match?: { pattern: string; flags: string } },
683
+ ): void {
684
+ function isField(
685
+ node: Element,
686
+ ): node is HTMLInputElement | HTMLTextAreaElement {
687
+ return (
688
+ node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement
689
+ );
690
+ }
691
+
692
+ function textNodesUnder(root: Node): Text[] {
693
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
694
+ acceptNode: (candidate) => {
695
+ const parent = candidate.parentElement;
696
+ return parent !== null &&
697
+ (parent.tagName === "SCRIPT" || parent.tagName === "STYLE")
698
+ ? NodeFilter.FILTER_REJECT
699
+ : NodeFilter.FILTER_ACCEPT;
700
+ },
701
+ });
702
+
703
+ const found: Text[] = [];
704
+ for (
705
+ let current = walker.nextNode();
706
+ current !== null;
707
+ current = walker.nextNode()
708
+ ) {
709
+ found.push(current as Text);
710
+ }
711
+
712
+ return found;
713
+ }
714
+
715
+ function pushRestore(undo: () => void): void {
716
+ window.__walkwrightRestore ??= [];
717
+ window.__walkwrightRestore.push(undo);
718
+ }
719
+
720
+ function write(
721
+ get: () => string,
722
+ set: (value: string) => void,
723
+ value = rule.text,
724
+ ): void {
725
+ const previous = get();
726
+ const next =
727
+ rule.match === undefined
728
+ ? value
729
+ : previous.replace(
730
+ new RegExp(rule.match.pattern, rule.match.flags),
731
+ rule.text,
732
+ );
733
+ if (next === previous) {
734
+ return;
735
+ }
736
+ set(next);
737
+ pushRestore(() => {
738
+ set(previous);
739
+ });
740
+ }
741
+
742
+ function writeField(field: HTMLInputElement | HTMLTextAreaElement): void {
743
+ write(
744
+ () => field.value,
745
+ (value) => {
746
+ field.value = value;
747
+ },
748
+ );
749
+ }
750
+
751
+ function writeText(text: Text, value?: string): void {
752
+ write(
753
+ () => text.data,
754
+ (next) => {
755
+ text.data = next;
756
+ },
757
+ value,
758
+ );
759
+ }
760
+
761
+ for (const node of nodes) {
762
+ if (isField(node)) {
763
+ writeField(node);
764
+ continue;
765
+ }
766
+
767
+ const [first, ...rest] = textNodesUnder(node);
768
+
769
+ if (rule.match !== undefined) {
770
+ for (const text of first === undefined ? [] : [first, ...rest]) {
771
+ writeText(text);
772
+ }
773
+ for (const field of node.querySelectorAll("input,textarea")) {
774
+ writeField(field as HTMLInputElement | HTMLTextAreaElement);
775
+ }
776
+ continue;
777
+ }
778
+
779
+ if (first === undefined) {
780
+ const created = document.createTextNode(rule.text);
781
+ node.append(created);
782
+ pushRestore(() => {
783
+ created.remove();
784
+ });
785
+ continue;
786
+ }
787
+
788
+ writeText(first);
789
+ for (const extra of rest) {
790
+ writeText(extra, "");
791
+ }
792
+ }
793
+ }
794
+
795
+ function restoreAll(): void {
796
+ const list = window.__walkwrightRestore ?? [];
797
+ for (let index = list.length - 1; index >= 0; index -= 1) {
798
+ list[index]?.();
799
+ }
800
+ window.__walkwrightRestore = [];
801
+ }
802
+
803
+ async function applyCaptureRules(
804
+ page: Page,
805
+ rules: CollectedRule[],
806
+ ): Promise<void> {
807
+ for (const rule of rules) {
808
+ const locator =
809
+ rule.scope.length === 0
810
+ ? page.locator("body")
811
+ : resolveLocator(page, rule.scope);
812
+
813
+ await locator.evaluateAll(applyRule, {
814
+ text: rule.text,
815
+ ...(rule.match === undefined
816
+ ? {}
817
+ : {
818
+ match: {
819
+ pattern: rule.match.pattern,
820
+ flags: rule.match.flags ?? "",
821
+ },
822
+ }),
823
+ });
824
+ }
825
+ }
826
+
827
+ async function restoreCaptureRules(page: Page): Promise<void> {
828
+ await page.evaluate(restoreAll);
829
+ }
830
+
831
+ function patternFor(path: string): RegExp {
832
+ return new RegExp(`^${path.replaceAll(/:[A-Za-z]+/g, "[^/]+")}/?$`);
833
+ }
834
+
835
+ function paramCount(path: string): number {
836
+ return path.match(/:[A-Za-z]+/g)?.length ?? 0;
837
+ }
838
+
839
+ function pathOf(url: string): string {
840
+ return url.replace(/^[a-z]+:\/\/[^/]+/, "").replace(/[?#].*$/, "");
841
+ }
842
+
843
+ const idSegment = /^(?:\d+|[\dA-Za-z_-]{12,})$/;
844
+
845
+ function pathSlug(path: string): string {
846
+ const parts = path
847
+ .split("/")
848
+ .filter((part) => part !== "" && !idSegment.test(part));
849
+
850
+ return parts.length === 0 ? "root" : slugify(parts.join("-"));
851
+ }
852
+
853
+ function slugParts(parts: (string | undefined)[]): string {
854
+ return (
855
+ parts
856
+ .filter((part): part is string => part !== undefined && part !== "")
857
+ .map(slugify)
858
+ .join("-") || "shot"
859
+ );
860
+ }
861
+
862
+ function slugify(value: string): string {
863
+ return (
864
+ value
865
+ .toLowerCase()
866
+ .replace(/[^a-z0-9]+/g, "-")
867
+ .replace(/^-|-$/g, "") || "shot"
868
+ );
869
+ }
870
+
871
+ export function describeError(error: unknown): string {
872
+ if (error instanceof Error) {
873
+ return error.message;
874
+ }
875
+
876
+ return String(error);
877
+ }