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
package/src/pom.ts ADDED
@@ -0,0 +1,2363 @@
1
+ import type {
2
+ Locator,
3
+ Page,
4
+ PlaywrightTestArgs,
5
+ PlaywrightTestOptions,
6
+ PlaywrightWorkerArgs,
7
+ PlaywrightWorkerOptions,
8
+ TestType,
9
+ } from "@playwright/test";
10
+ import { test as playwrightTest } from "@playwright/test";
11
+
12
+ import type {
13
+ ActionDescriptor,
14
+ AppDescriptor,
15
+ CaptureDescriptor,
16
+ CaptureRuleDescriptor,
17
+ ElementDescriptor,
18
+ NestedSurfaceDescriptor,
19
+ OverlayDescriptor,
20
+ PageDescriptor,
21
+ ParamDecl,
22
+ RegexDescriptor,
23
+ } from "./descriptor.js";
24
+ import { appDescriptorSchema } from "./descriptor.js";
25
+ import type {
26
+ LazyLocator,
27
+ LocatorDescriptor,
28
+ LocatorFilter,
29
+ LocatorNode,
30
+ Mount,
31
+ Refinement,
32
+ Scope,
33
+ Text,
34
+ } from "./locator.js";
35
+ import {
36
+ isLazyLocator,
37
+ lazyLocatorOf,
38
+ pageOf,
39
+ paramNamesOf,
40
+ plainTextOf,
41
+ resolveLocator,
42
+ textFrom,
43
+ } from "./locator.js";
44
+ import type { AnyProvision, ProvisionMap } from "./provisioner.js";
45
+ import { createProvisioner } from "./provisioner.js";
46
+ import type { RecorderFixtures } from "./recorder.js";
47
+ import { createRecorderFixtures } from "./recorder.js";
48
+
49
+ export type { Mount, Scope };
50
+ export { pageOf };
51
+
52
+ export type CaptureRule =
53
+ | { element: string; text: string; match?: RegExp }
54
+ | { match: RegExp; text: string };
55
+
56
+ export interface CaptureRules {
57
+ rules: CaptureRule[];
58
+ }
59
+
60
+ type ElementPath<E> =
61
+ Extract<keyof E, string> | `${Extract<keyof E, string>}.${string}`;
62
+
63
+ type TypedCaptureRule<E> =
64
+ | { element: ElementPath<E>; text: string; match?: RegExp }
65
+ | { match: RegExp; text: string };
66
+
67
+ interface TypedCaptureRules<E> {
68
+ rules: TypedCaptureRule<E>[];
69
+ }
70
+
71
+ export type Factory<T> = (page: Page) => T;
72
+
73
+ export interface Rooted {
74
+ self: LazyLocator;
75
+ root: Locator;
76
+ }
77
+
78
+ export interface SurfaceInstance {
79
+ page: Page;
80
+ elements: object;
81
+ }
82
+
83
+ export interface Surface<I extends SurfaceInstance = SurfaceInstance> {
84
+ (scope: LazyLocator): Surface<I & Rooted>;
85
+ (scope: Page): I;
86
+ readonly descriptor: NestedSurfaceDescriptor;
87
+ }
88
+
89
+ export type AnySurface = Surface;
90
+
91
+ export interface OverlayEmbed {
92
+ overlay: OverlayFactory;
93
+ open?: () => Promise<void>;
94
+ }
95
+
96
+ export type ElementMap = Record<
97
+ string,
98
+ | LazyLocator
99
+ | ((...params: never[]) => LazyLocator)
100
+ | AnySurface
101
+ | ((...params: never[]) => AnySurface)
102
+ | OverlayEmbed
103
+ | ((scope: Mount) => unknown)
104
+ | ((...params: never[]) => (scope: Mount) => unknown)
105
+ >;
106
+
107
+ type ThisElements<Self> = Record<
108
+ string,
109
+ ElementMap[string] | (OverlayEmbed & ThisType<Self>)
110
+ >;
111
+
112
+ export type Instantiated<M extends ElementMap> = {
113
+ [Key in keyof M]: M[Key] extends { overlay: infer O extends AnySurface }
114
+ ? SurfaceInstanceOf<O>
115
+ : M[Key] extends LazyLocator
116
+ ? Locator
117
+ : M[Key] extends (...args: infer A) => LazyLocator
118
+ ? (...args: A) => Locator
119
+ : M[Key] extends {
120
+ (...params: infer Params): infer S;
121
+ parameterized: true;
122
+ }
123
+ ? S extends AnySurface
124
+ ? (...params: Params) => SurfaceInstanceOf<S>
125
+ : never
126
+ : M[Key] extends AnySurface
127
+ ? SurfaceInstanceOf<M[Key]>
128
+ : M[Key] extends (...args: infer A) => infer S
129
+ ? S extends AnySurface
130
+ ? (...args: A) => SurfaceInstanceOf<S>
131
+ : S extends (scope: never) => infer R
132
+ ? (...args: A) => R
133
+ : S
134
+ : never;
135
+ };
136
+
137
+ export type ActionMap = Record<string, (...params: never[]) => unknown>;
138
+
139
+ export type Parameterized<Params extends unknown[], F> = ((
140
+ ...params: Params
141
+ ) => F) & { parameterized: true };
142
+
143
+ export type Component<
144
+ E extends object,
145
+ A extends object = Record<never, never>,
146
+ > = { page: Page; elements: E; self?: LazyLocator; root?: Locator } & A;
147
+
148
+ export type RootedComponent<
149
+ E extends object,
150
+ A extends object = Record<never, never>,
151
+ > = Component<E, A> & Rooted;
152
+
153
+ type SurfaceInstanceOf<S> = S extends (scope: Page) => infer I ? I : never;
154
+
155
+ type ActionsOf<I> = Omit<I, keyof SurfaceInstance | keyof Rooted>;
156
+
157
+ type IncludeShape<Inc extends readonly AnySurface[]> = Inc extends readonly [
158
+ infer First,
159
+ ...infer Rest,
160
+ ]
161
+ ? Rest extends readonly AnySurface[]
162
+ ? SurfaceInstanceOf<First> extends infer I extends SurfaceInstance
163
+ ? {
164
+ elements: I["elements"] & IncludeShape<Rest>["elements"];
165
+ actions: ActionsOf<I> & IncludeShape<Rest>["actions"];
166
+ }
167
+ : never
168
+ : never
169
+ : { elements: unknown; actions: unknown };
170
+
171
+ export type IncludeElements<Inc extends readonly AnySurface[]> =
172
+ IncludeShape<Inc>["elements"];
173
+
174
+ export type IncludeActions<Inc extends readonly AnySurface[]> =
175
+ IncludeShape<Inc>["actions"];
176
+
177
+ export function rootOf(instance: { root?: Locator }): Locator {
178
+ if (instance.root === undefined) {
179
+ throw new Error(
180
+ "this surface is not mounted at a chain — scope it with a locator",
181
+ );
182
+ }
183
+
184
+ return instance.root;
185
+ }
186
+
187
+ interface SurfaceCore {
188
+ root: LocatorDescriptor;
189
+ elements: BoundElements;
190
+ actions: ActionMap;
191
+ descriptor: NestedSurfaceDescriptor;
192
+ }
193
+
194
+ const coreKey = Symbol("walkwright.surface");
195
+
196
+ interface SurfaceBrand {
197
+ [coreKey]: SurfaceCore;
198
+ }
199
+
200
+ function isSurface(value: unknown): value is AnySurface & SurfaceBrand {
201
+ return typeof value === "function" && coreKey in value;
202
+ }
203
+
204
+ function isOverlayFactory(value: unknown): value is OverlayFactory {
205
+ return (
206
+ typeof value === "function" &&
207
+ (value as { overlay?: unknown }).overlay === true
208
+ );
209
+ }
210
+
211
+ interface AnyParameterizedFactory {
212
+ (...params: never[]): unknown;
213
+ parameterized: true;
214
+ }
215
+
216
+ const isParameterized = (value: unknown): value is AnyParameterizedFactory =>
217
+ typeof value === "function" &&
218
+ (value as Partial<AnyParameterizedFactory>).parameterized === true;
219
+
220
+ function definer<Def>(
221
+ definition: Def | ((...params: never[]) => Def),
222
+ factoryOf: (def: Def) => object,
223
+ ): object {
224
+ if (typeof definition !== "function") {
225
+ return factoryOf(definition);
226
+ }
227
+
228
+ const build = definition as (...params: never[]) => Def;
229
+
230
+ return Object.assign(
231
+ (...params: never[]): object => factoryOf(build(...params)),
232
+ { parameterized: true as const },
233
+ );
234
+ }
235
+
236
+ const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
237
+
238
+ function paramsSourceOf(source: string): string | undefined {
239
+ const paren = /^\s*(?:async\s+)?\(([^)]*)\)\s*=>/.exec(source);
240
+ if (paren !== null) {
241
+ return paren[1] ?? "";
242
+ }
243
+
244
+ const bare = /^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/.exec(source);
245
+ if (bare !== null) {
246
+ return bare[1] ?? "";
247
+ }
248
+
249
+ const keyword =
250
+ /^\s*(?:async\s+)?function\s*[A-Za-z_$][\w$]*?\s*\(([^)]*)\)/.exec(source);
251
+
252
+ return keyword === null ? undefined : (keyword[1] ?? "");
253
+ }
254
+
255
+ function paramNamesFrom(source: string, arity: number): string[] {
256
+ const fallback = Array.from({ length: arity }, (_, index) => `p${index}`);
257
+ const params = paramsSourceOf(source);
258
+
259
+ if (params === undefined) {
260
+ return fallback;
261
+ }
262
+
263
+ const pieces =
264
+ params.trim() === "" ? [] : params.split(",").map((piece) => piece.trim());
265
+
266
+ if (pieces.length < arity) {
267
+ return fallback;
268
+ }
269
+
270
+ const sliced = pieces.slice(0, arity);
271
+
272
+ return sliced.every((piece) => IDENTIFIER_RE.test(piece)) ? sliced : fallback;
273
+ }
274
+
275
+ function sentinelFor(index: number, tag: string): string {
276
+ return ` walkwright-param-${index}-${tag} `;
277
+ }
278
+
279
+ function tracedText(
280
+ text: Text | undefined,
281
+ sentinelToName: Map<string, string>,
282
+ ): Text | undefined {
283
+ if (text === undefined || typeof text !== "string") {
284
+ return text;
285
+ }
286
+
287
+ const exact = sentinelToName.get(text);
288
+ if (exact !== undefined) {
289
+ return { param: exact };
290
+ }
291
+
292
+ const parts: (string | { param: string })[] = [];
293
+ let remainder = text;
294
+
295
+ for (;;) {
296
+ let earliestIndex = -1;
297
+ let earliestSentinel = "";
298
+ let earliestName = "";
299
+
300
+ for (const [sentinel, name] of sentinelToName) {
301
+ const index = remainder.indexOf(sentinel);
302
+ if (index !== -1 && (earliestIndex === -1 || index < earliestIndex)) {
303
+ earliestIndex = index;
304
+ earliestSentinel = sentinel;
305
+ earliestName = name;
306
+ }
307
+ }
308
+
309
+ if (earliestIndex === -1) {
310
+ break;
311
+ }
312
+
313
+ const literal = remainder.slice(0, earliestIndex);
314
+ if (literal !== "") {
315
+ parts.push(literal);
316
+ }
317
+ parts.push({ param: earliestName });
318
+ remainder = remainder.slice(earliestIndex + earliestSentinel.length);
319
+ }
320
+
321
+ if (remainder !== "") {
322
+ parts.push(remainder);
323
+ }
324
+
325
+ if (parts.length === 0) {
326
+ return text;
327
+ }
328
+
329
+ return parts.length === 1 && typeof parts[0] === "string"
330
+ ? text
331
+ : { template: parts };
332
+ }
333
+
334
+ function tracedFilter(
335
+ filter: LocatorFilter,
336
+ sentinelToName: Map<string, string>,
337
+ ): LocatorFilter | null {
338
+ const hasText = tracedText(filter.hasText, sentinelToName);
339
+
340
+ let has: LocatorDescriptor | undefined;
341
+ if (filter.has !== undefined) {
342
+ const traced = tracedDescriptor(filter.has, sentinelToName);
343
+ if (traced === undefined) {
344
+ return null;
345
+ }
346
+ has = traced;
347
+ }
348
+
349
+ let hasNot: LocatorDescriptor | undefined;
350
+ if (filter.hasNot !== undefined) {
351
+ const traced = tracedDescriptor(filter.hasNot, sentinelToName);
352
+ if (traced === undefined) {
353
+ return null;
354
+ }
355
+ hasNot = traced;
356
+ }
357
+
358
+ return {
359
+ ...(hasText === undefined ? {} : { hasText }),
360
+ ...(has === undefined ? {} : { has }),
361
+ ...(hasNot === undefined ? {} : { hasNot }),
362
+ };
363
+ }
364
+
365
+ function tracedNode(
366
+ node: LocatorNode,
367
+ sentinelToName: Map<string, string>,
368
+ ): LocatorNode | undefined {
369
+ if ("or" in node) {
370
+ const branches = tracedBranches(node.or, sentinelToName);
371
+ return branches === undefined ? undefined : { or: branches };
372
+ }
373
+
374
+ if ("and" in node) {
375
+ const branches = tracedBranches(node.and, sentinelToName);
376
+ return branches === undefined ? undefined : { and: branches };
377
+ }
378
+
379
+ const name =
380
+ "name" in node ? tracedText(node.name, sentinelToName) : undefined;
381
+
382
+ const text =
383
+ "text" in node ? tracedText(node.text, sentinelToName) : undefined;
384
+
385
+ const refine = tracedRefine(node.refine, sentinelToName);
386
+ if (refine === null) {
387
+ return undefined;
388
+ }
389
+
390
+ return {
391
+ ...node,
392
+ ...(name === undefined ? {} : { name }),
393
+ ...(text === undefined ? {} : { text }),
394
+ ...(refine === undefined ? {} : { refine }),
395
+ };
396
+ }
397
+
398
+ function tracedRefine(
399
+ refine: Refinement[] | undefined,
400
+ sentinelToName: Map<string, string>,
401
+ ): Refinement[] | undefined | null {
402
+ if (refine === undefined) {
403
+ return undefined;
404
+ }
405
+
406
+ const traced: Refinement[] = [];
407
+ for (const refinement of refine) {
408
+ if ("nth" in refinement) {
409
+ traced.push(refinement);
410
+ continue;
411
+ }
412
+
413
+ const filter = tracedFilter(refinement.filter, sentinelToName);
414
+ if (filter === null) {
415
+ return null;
416
+ }
417
+ traced.push({ filter });
418
+ }
419
+
420
+ return traced;
421
+ }
422
+
423
+ function tracedBranches(
424
+ branches: LocatorDescriptor[],
425
+ sentinelToName: Map<string, string>,
426
+ ): LocatorDescriptor[] | undefined {
427
+ const traced: LocatorDescriptor[] = [];
428
+
429
+ for (const branch of branches) {
430
+ const next = tracedDescriptor(branch, sentinelToName);
431
+ if (next === undefined) {
432
+ return undefined;
433
+ }
434
+ traced.push(next);
435
+ }
436
+
437
+ return traced;
438
+ }
439
+
440
+ function tracedDescriptor(
441
+ descriptor: LocatorDescriptor,
442
+ sentinelToName: Map<string, string>,
443
+ ): LocatorDescriptor | undefined {
444
+ const traced: LocatorNode[] = [];
445
+
446
+ for (const node of descriptor) {
447
+ const next = tracedNode(node, sentinelToName);
448
+ if (next === undefined) {
449
+ return undefined;
450
+ }
451
+ traced.push(next);
452
+ }
453
+
454
+ return traced;
455
+ }
456
+
457
+ interface ParametricElement {
458
+ build: (...values: string[]) => unknown;
459
+ locator: LocatorDescriptor;
460
+ params: ParamDecl[];
461
+ }
462
+
463
+ interface ParametricOpaqueElement {
464
+ build: (...values: string[]) => unknown;
465
+ }
466
+
467
+ function probeParametric(value: unknown): BoundElement | undefined {
468
+ if (typeof value !== "function") {
469
+ return undefined;
470
+ }
471
+
472
+ const fn = value as (...args: string[]) => unknown;
473
+ const names = paramNamesFrom(fn.toString(), fn.length);
474
+ const tag = Math.random().toString(36).slice(2, 10);
475
+ const sentinelToName = new Map(
476
+ names.map((name, index) => [sentinelFor(index, tag), name] as const),
477
+ );
478
+
479
+ let result: unknown;
480
+ try {
481
+ result = fn(...sentinelToName.keys());
482
+ } catch {
483
+ return undefined;
484
+ }
485
+
486
+ if (isLazyLocator(result)) {
487
+ const locator = tracedDescriptor(result.chain, sentinelToName);
488
+ if (locator === undefined) {
489
+ return { parametricOpaque: { build: fn } };
490
+ }
491
+
492
+ const used = new Set(paramNamesOf(locator));
493
+ if (!names.every((name) => used.has(name))) {
494
+ return { parametricOpaque: { build: fn } };
495
+ }
496
+
497
+ return {
498
+ parametric: {
499
+ build: fn,
500
+ locator,
501
+ params: names
502
+ .filter((name) => used.has(name))
503
+ .map((name) => ({ name })),
504
+ },
505
+ };
506
+ }
507
+
508
+ if (typeof result === "function") {
509
+ return { parametricOpaque: { build: fn } };
510
+ }
511
+
512
+ return undefined;
513
+ }
514
+
515
+ interface NestedSurface {
516
+ factory: AnySurface;
517
+ descriptor: NestedSurfaceDescriptor;
518
+ }
519
+
520
+ interface NestedOverlay {
521
+ factory: AnySurface;
522
+ descriptor: Extract<ElementDescriptor, { overlay: OverlayDescriptor }>;
523
+ open?: () => Promise<void>;
524
+ }
525
+
526
+ function isOverlayEmbed(value: unknown): value is OverlayEmbed {
527
+ return (
528
+ typeof value === "object" &&
529
+ value !== null &&
530
+ isOverlayFactory((value as { overlay?: unknown }).overlay)
531
+ );
532
+ }
533
+
534
+ function embedOf(
535
+ factory: OverlayFactory,
536
+ open: (() => Promise<void>) | undefined,
537
+ ): NestedOverlay {
538
+ return {
539
+ factory,
540
+ descriptor: {
541
+ overlay: factory.descriptor,
542
+ ...(open === undefined
543
+ ? {}
544
+ : { open: { opaque: true as const, provenance: "hand" as const } }),
545
+ },
546
+ ...(open === undefined ? {} : { open }),
547
+ };
548
+ }
549
+
550
+ type BoundElement =
551
+ | { locator: LocatorDescriptor; params?: ParamDecl[] }
552
+ | { parametric: ParametricElement }
553
+ | { parametricOpaque: ParametricOpaqueElement }
554
+ | { surface: NestedSurface }
555
+ | { overlay: NestedOverlay }
556
+ | { opaque: unknown; provenance: "hand" };
557
+
558
+ type BoundElements = Record<string, BoundElement>;
559
+
560
+ function bindElement(value: unknown): BoundElement {
561
+ if (isLazyLocator(value)) {
562
+ return value.params.length === 0
563
+ ? { locator: value.chain }
564
+ : {
565
+ locator: value.chain,
566
+ params: value.params.map((name) => ({ name })),
567
+ };
568
+ }
569
+
570
+ if (isOverlayFactory(value)) {
571
+ return { overlay: embedOf(value, undefined) };
572
+ }
573
+
574
+ if (isOverlayEmbed(value)) {
575
+ return { overlay: embedOf(value.overlay, value.open) };
576
+ }
577
+
578
+ if (isSurface(value)) {
579
+ return { surface: { factory: value, descriptor: value.descriptor } };
580
+ }
581
+
582
+ if (isParameterized(value)) {
583
+ return { opaque: value, provenance: "hand" };
584
+ }
585
+
586
+ const parametric = probeParametric(value);
587
+ if (parametric !== undefined) {
588
+ return parametric;
589
+ }
590
+
591
+ return { opaque: value, provenance: "hand" };
592
+ }
593
+
594
+ function projectElement(bound: BoundElement): ElementDescriptor {
595
+ if ("locator" in bound) {
596
+ return bound.params === undefined
597
+ ? { locator: bound.locator }
598
+ : { locator: bound.locator, params: bound.params };
599
+ }
600
+
601
+ if ("parametric" in bound) {
602
+ return bound.parametric.params.length === 0
603
+ ? { locator: bound.parametric.locator }
604
+ : {
605
+ locator: bound.parametric.locator,
606
+ params: bound.parametric.params,
607
+ };
608
+ }
609
+
610
+ if ("parametricOpaque" in bound) {
611
+ return { opaque: true, provenance: "hand" };
612
+ }
613
+
614
+ if ("surface" in bound) {
615
+ return { surface: bound.surface.descriptor };
616
+ }
617
+
618
+ if ("overlay" in bound) {
619
+ return bound.overlay.descriptor;
620
+ }
621
+
622
+ return { opaque: true, provenance: "hand" };
623
+ }
624
+
625
+ function projectElements(
626
+ elements: BoundElements,
627
+ ): Record<string, ElementDescriptor> {
628
+ return Object.fromEntries(
629
+ Object.entries(elements).map(([key, bound]) => [
630
+ key,
631
+ projectElement(bound),
632
+ ]),
633
+ );
634
+ }
635
+
636
+ function classifyActions(actions: ActionMap): ActionDescriptor[] | undefined {
637
+ const names = Object.keys(actions);
638
+
639
+ return names.length === 0
640
+ ? undefined
641
+ : names.map((name) => ({
642
+ name,
643
+ impl: { opaque: true as const, provenance: "hand" as const },
644
+ }));
645
+ }
646
+
647
+ function regexDescriptorOf(re: RegExp): RegexDescriptor {
648
+ return {
649
+ pattern: re.source,
650
+ ...(re.flags === "" ? {} : { flags: re.flags }),
651
+ };
652
+ }
653
+
654
+ function captureRuleDescriptorOf(rule: CaptureRule): CaptureRuleDescriptor {
655
+ if ("element" in rule) {
656
+ return {
657
+ element: rule.element.split("."),
658
+ text: rule.text,
659
+ ...(rule.match === undefined
660
+ ? {}
661
+ : { match: regexDescriptorOf(rule.match) }),
662
+ };
663
+ }
664
+
665
+ return { match: regexDescriptorOf(rule.match), text: rule.text };
666
+ }
667
+
668
+ type CaptureLookup =
669
+ | { kind: "opaque" }
670
+ | { kind: "locator" }
671
+ | { kind: "nested"; elements: Record<string, ElementDescriptor> };
672
+
673
+ function captureLookupOf(descriptor: ElementDescriptor): CaptureLookup {
674
+ if ("opaque" in descriptor) {
675
+ return { kind: "opaque" };
676
+ }
677
+
678
+ if ("locator" in descriptor) {
679
+ return { kind: "locator" };
680
+ }
681
+
682
+ if ("surface" in descriptor) {
683
+ return { kind: "nested", elements: descriptor.surface.elements };
684
+ }
685
+
686
+ return { kind: "nested", elements: descriptor.overlay.elements };
687
+ }
688
+
689
+ function validateCaptureElement(
690
+ path: string,
691
+ owner: string,
692
+ elements: Record<string, ElementDescriptor>,
693
+ hasMatch: boolean,
694
+ ): void {
695
+ const segments = path.split(".");
696
+ let current = elements;
697
+ let consumed: string[] = [];
698
+
699
+ segments.forEach((segment, index) => {
700
+ consumed = [...consumed, segment];
701
+ const descriptor = current[segment];
702
+
703
+ if (descriptor === undefined) {
704
+ throw new Error(
705
+ `capture rule "${path}" of ${owner} references unknown element "${consumed.join(".")}"`,
706
+ );
707
+ }
708
+
709
+ const isFinal = index === segments.length - 1;
710
+ const lookup = captureLookupOf(descriptor);
711
+
712
+ if (lookup.kind === "opaque") {
713
+ throw new Error(
714
+ `capture rule "${path}" of ${owner} targets "${consumed.join(".")}", which has no locator to resolve`,
715
+ );
716
+ }
717
+
718
+ if (lookup.kind === "locator") {
719
+ if (!isFinal) {
720
+ throw new Error(
721
+ `capture rule "${path}" of ${owner} walks through "${consumed.join(".")}", which is not a surface`,
722
+ );
723
+ }
724
+ return;
725
+ }
726
+
727
+ if (isFinal && !hasMatch) {
728
+ throw new Error(
729
+ `capture rule "${path}" of ${owner} targets "${consumed.join(".")}", a surface — give it match to scope a replace over its subtree`,
730
+ );
731
+ }
732
+
733
+ current = lookup.elements;
734
+ });
735
+ }
736
+
737
+ function captureRulesOf(
738
+ rules: CaptureRule[] | undefined,
739
+ owner: string,
740
+ elements: Record<string, ElementDescriptor>,
741
+ ): CaptureRuleDescriptor[] {
742
+ return (rules ?? []).map((rule) => {
743
+ if ("element" in rule) {
744
+ validateCaptureElement(
745
+ rule.element,
746
+ owner,
747
+ elements,
748
+ rule.match !== undefined,
749
+ );
750
+ }
751
+
752
+ return captureRuleDescriptorOf(rule);
753
+ });
754
+ }
755
+
756
+ function captureDescriptorOf(
757
+ rules: CaptureRuleDescriptor[],
758
+ ): CaptureDescriptor | undefined {
759
+ return rules.length === 0 ? undefined : { rules };
760
+ }
761
+
762
+ function projectSurface(
763
+ root: LocatorDescriptor,
764
+ elements: BoundElements,
765
+ actions: ActionMap,
766
+ captureRules: CaptureRuleDescriptor[],
767
+ ): NestedSurfaceDescriptor {
768
+ const projectedActions = classifyActions(actions);
769
+ const capture = captureDescriptorOf(captureRules);
770
+
771
+ return {
772
+ ...(root.length === 0 ? {} : { root }),
773
+ elements: projectElements(elements),
774
+ ...(projectedActions === undefined ? {} : { actions: projectedActions }),
775
+ ...(capture === undefined ? {} : { capture }),
776
+ };
777
+ }
778
+
779
+ export interface SurfaceDefinition {
780
+ elements?: ElementMap;
781
+ actions?: ActionMap;
782
+ include?: readonly AnySurface[];
783
+ capture?: CaptureRules;
784
+ }
785
+
786
+ interface Assembled {
787
+ elements: BoundElements;
788
+ actions: ActionMap;
789
+ capture: CaptureRuleDescriptor[];
790
+ }
791
+
792
+ function assemble(
793
+ def: SurfaceDefinition,
794
+ owner: string,
795
+ reserved: readonly string[],
796
+ ): Assembled {
797
+ const elements: BoundElements = {};
798
+ const actions: ActionMap = {};
799
+ const capture: CaptureRuleDescriptor[] = [];
800
+ const elementSources = new Map<string, string>();
801
+ const actionSources = new Map<string, string>();
802
+
803
+ const claim = (
804
+ sources: Map<string, string>,
805
+ key: string,
806
+ source: string,
807
+ what: string,
808
+ ): void => {
809
+ const holder = sources.get(key);
810
+ if (holder !== undefined) {
811
+ throw new Error(
812
+ `${holder} and ${source} of ${owner} both define ${what} "${key}"`,
813
+ );
814
+ }
815
+ sources.set(key, source);
816
+ };
817
+
818
+ for (const [index, included] of (def.include ?? []).entries()) {
819
+ const source = `include[${String(index)}]`;
820
+
821
+ if (!isSurface(included)) {
822
+ throw new Error(`${source} of ${owner} is not a surface`);
823
+ }
824
+
825
+ const core = included[coreKey];
826
+ if (core.root.length > 0) {
827
+ throw new Error(
828
+ `${source} of ${owner} has a root — embed it as an element to keep its boundary; only rootless surfaces can be merged`,
829
+ );
830
+ }
831
+
832
+ for (const [key, bound] of Object.entries(core.elements)) {
833
+ claim(elementSources, key, source, "element");
834
+ elements[key] = bound;
835
+ }
836
+
837
+ for (const [key, action] of Object.entries(core.actions)) {
838
+ claim(actionSources, key, source, "action");
839
+ if (reserved.includes(key)) {
840
+ throw new Error(
841
+ `action "${key}" of ${source} of ${owner} shadows a built-in field of its instances`,
842
+ );
843
+ }
844
+ actions[key] = action;
845
+ }
846
+
847
+ capture.push(...(core.descriptor.capture?.rules ?? []));
848
+ }
849
+
850
+ for (const [key, value] of Object.entries(def.elements ?? {})) {
851
+ claim(elementSources, key, "the definition", "element");
852
+ elements[key] = bindElement(value);
853
+ }
854
+
855
+ for (const [key, action] of Object.entries(def.actions ?? {})) {
856
+ claim(actionSources, key, "the definition", "action");
857
+ if (reserved.includes(key)) {
858
+ throw new Error(
859
+ `action "${key}" of ${owner} shadows a built-in field of its instances`,
860
+ );
861
+ }
862
+ actions[key] = action;
863
+ }
864
+
865
+ return { elements, actions, capture };
866
+ }
867
+
868
+ interface MountContext {
869
+ page: Page;
870
+ chain: LocatorDescriptor;
871
+ mount: Mount;
872
+ instance: () => object;
873
+ }
874
+
875
+ function mountNested(factory: AnySurface, context: MountContext): unknown {
876
+ return context.chain.length === 0
877
+ ? factory(context.page)
878
+ : factory(lazyLocatorOf(context.chain))(context.page);
879
+ }
880
+
881
+ function mountBuilt(built: unknown, context: MountContext): unknown {
882
+ if (isSurface(built)) {
883
+ return mountNested(built, context);
884
+ }
885
+
886
+ if (typeof built === "function") {
887
+ return (built as (scope: Mount) => unknown).call(
888
+ context.instance(),
889
+ context.mount,
890
+ );
891
+ }
892
+
893
+ return built;
894
+ }
895
+
896
+ function instantiateBoundElement(
897
+ bound: BoundElement,
898
+ context: MountContext,
899
+ ): unknown {
900
+ if ("locator" in bound) {
901
+ const { locator, params } = bound;
902
+ return params === undefined
903
+ ? resolveLocator(context.mount, locator)
904
+ : (...values: string[]): Locator =>
905
+ resolveLocator(
906
+ context.mount,
907
+ locator,
908
+ Object.fromEntries(
909
+ params.flatMap((param, index) => {
910
+ const value = values[index];
911
+ return value === undefined
912
+ ? []
913
+ : [[param.name, value] as const];
914
+ }),
915
+ ),
916
+ );
917
+ }
918
+
919
+ if ("parametric" in bound) {
920
+ const { build } = bound.parametric;
921
+ return (...values: string[]): Locator => {
922
+ const built = build(...values);
923
+ if (!isLazyLocator(built)) {
924
+ throw new Error("parametric element did not build a lazy locator");
925
+ }
926
+ return resolveLocator(context.mount, built.chain);
927
+ };
928
+ }
929
+
930
+ if ("parametricOpaque" in bound) {
931
+ const { build } = bound.parametricOpaque;
932
+ return (...values: string[]): unknown => {
933
+ const built = build(...values);
934
+ if (typeof built !== "function") {
935
+ throw new Error("parametric element did not build a function");
936
+ }
937
+ return mountBuilt(built, context);
938
+ };
939
+ }
940
+
941
+ if ("surface" in bound) {
942
+ return mountNested(bound.surface.factory, context);
943
+ }
944
+
945
+ if ("overlay" in bound) {
946
+ const mounted = mountNested(bound.overlay.factory, context) as object;
947
+ const { open } = bound.overlay;
948
+
949
+ return open === undefined
950
+ ? mounted
951
+ : Object.assign(mounted, {
952
+ open: async (): Promise<void> => {
953
+ await open.call(context.instance());
954
+ },
955
+ });
956
+ }
957
+
958
+ const factory = bound.opaque;
959
+ return isParameterized(factory)
960
+ ? (...params: never[]): unknown => mountBuilt(factory(...params), context)
961
+ : mountBuilt(factory, context);
962
+ }
963
+
964
+ function instantiateBound(
965
+ elements: BoundElements,
966
+ context: MountContext,
967
+ ): Record<string, unknown> {
968
+ const instance = {} as Record<string, unknown>;
969
+ const settled = new Map<string, unknown>();
970
+ const resolving = new Set<string>();
971
+
972
+ for (const [key, bound] of Object.entries(elements)) {
973
+ Object.defineProperty(instance, key, {
974
+ enumerable: true,
975
+ configurable: true,
976
+ get(): unknown {
977
+ if (settled.has(key)) {
978
+ return settled.get(key);
979
+ }
980
+
981
+ if (resolving.has(key)) {
982
+ throw new Error(`element "${key}" is part of a reference cycle`);
983
+ }
984
+
985
+ resolving.add(key);
986
+ try {
987
+ const value = instantiateBoundElement(bound, context);
988
+ settled.set(key, value);
989
+ return value;
990
+ } finally {
991
+ resolving.delete(key);
992
+ }
993
+ },
994
+ });
995
+ }
996
+
997
+ return instance;
998
+ }
999
+
1000
+ interface Grounded {
1001
+ page: Page;
1002
+ chain: LocatorDescriptor;
1003
+ self?: LazyLocator;
1004
+ root?: Locator;
1005
+ }
1006
+
1007
+ type Mounted<D extends object> = SurfaceInstance & Partial<Rooted> & D;
1008
+
1009
+ function instantiateSurface<D extends object>(
1010
+ core: SurfaceCore,
1011
+ page: Page,
1012
+ prefix: LocatorDescriptor,
1013
+ decorate: (grounded: Grounded) => D,
1014
+ mountOnly: BoundElements = {},
1015
+ ): Mounted<D> {
1016
+ const chain = [...prefix, ...core.root];
1017
+ const self = chain.length === 0 ? undefined : lazyLocatorOf(chain);
1018
+ const root = self === undefined ? undefined : resolveLocator(page, chain);
1019
+
1020
+ const instance: SurfaceInstance & Partial<Rooted> & Record<string, unknown> =
1021
+ { page, elements: {} };
1022
+ const grounded: Grounded = {
1023
+ page,
1024
+ chain,
1025
+ ...(self === undefined ? {} : { self, root }),
1026
+ };
1027
+
1028
+ instance.elements = instantiateBound(
1029
+ { ...core.elements, ...mountOnly },
1030
+ { page, chain, mount: root ?? page, instance: () => instance },
1031
+ );
1032
+
1033
+ const decorated = Object.assign(
1034
+ instance,
1035
+ self === undefined ? {} : { self, root },
1036
+ decorate(grounded),
1037
+ );
1038
+
1039
+ for (const [key, action] of Object.entries(core.actions)) {
1040
+ instance[key] = (...params: never[]): unknown =>
1041
+ action.call(instance, ...params);
1042
+ }
1043
+
1044
+ return decorated;
1045
+ }
1046
+
1047
+ type Branded<F, X, C extends SurfaceCore> = F &
1048
+ X & { descriptor: C["descriptor"] } & SurfaceBrand;
1049
+
1050
+ function surfaceOf<F extends object, X extends object, C extends SurfaceCore>(
1051
+ core: C,
1052
+ factory: F,
1053
+ extra: X,
1054
+ ): Branded<F, X, C> {
1055
+ return Object.assign(factory, extra, {
1056
+ descriptor: core.descriptor,
1057
+ [coreKey]: core,
1058
+ });
1059
+ }
1060
+
1061
+ type PointedSurface = Surface<SurfaceInstance & Rooted>;
1062
+
1063
+ function rootedOf(grounded: Grounded): Rooted {
1064
+ if (grounded.self === undefined || grounded.root === undefined) {
1065
+ throw new Error("this surface is not mounted at a chain");
1066
+ }
1067
+
1068
+ return { self: grounded.self, root: grounded.root };
1069
+ }
1070
+
1071
+ function composedCore(core: SurfaceCore, scope: LazyLocator): SurfaceCore {
1072
+ const root = [...scope.chain, ...core.root];
1073
+
1074
+ return { ...core, root, descriptor: { ...core.descriptor, root } };
1075
+ }
1076
+
1077
+ function pointedFactoryOf(core: SurfaceCore): PointedSurface & SurfaceBrand {
1078
+ function factory(scope: LazyLocator): PointedSurface;
1079
+ function factory(scope: Page): SurfaceInstance & Rooted;
1080
+ function factory(scope: Scope): PointedSurface | (SurfaceInstance & Rooted) {
1081
+ return isLazyLocator(scope)
1082
+ ? pointedFactoryOf(composedCore(core, scope))
1083
+ : instantiateSurface(core, scope, [], rootedOf);
1084
+ }
1085
+
1086
+ return surfaceOf(core, factory, {});
1087
+ }
1088
+
1089
+ function componentFactoryOf(core: SurfaceCore): AnySurface & SurfaceBrand {
1090
+ function factory(scope: LazyLocator): PointedSurface;
1091
+ function factory(scope: Page): SurfaceInstance;
1092
+ function factory(scope: Scope): PointedSurface | SurfaceInstance {
1093
+ return isLazyLocator(scope)
1094
+ ? pointedFactoryOf(composedCore(core, scope))
1095
+ : instantiateSurface(core, scope, [], () => ({}));
1096
+ }
1097
+
1098
+ return surfaceOf(core, factory, {});
1099
+ }
1100
+
1101
+ const componentReserved = ["elements", "page", "self", "root", "pages"];
1102
+
1103
+ export interface ComponentDefinition<
1104
+ Inc extends readonly AnySurface[],
1105
+ M extends ElementMap,
1106
+ A extends ActionMap,
1107
+ Self,
1108
+ > {
1109
+ include?: readonly [...Inc];
1110
+ elements?: M & ThisElements<Self> & ThisType<Self>;
1111
+ actions?: A & ActionMap & ThisType<Self>;
1112
+ capture?: TypedCaptureRules<IncludeElements<Inc> & Instantiated<M>>;
1113
+ }
1114
+
1115
+ interface DefinitionShape {
1116
+ root?: LazyLocator;
1117
+ }
1118
+
1119
+ type ComponentInstanceOf<
1120
+ Def extends DefinitionShape,
1121
+ Inc extends readonly AnySurface[],
1122
+ M extends ElementMap,
1123
+ A extends ActionMap,
1124
+ > = Def extends { root: LazyLocator }
1125
+ ? RootedComponent<
1126
+ IncludeElements<Inc> & Instantiated<M>,
1127
+ IncludeActions<Inc> & A
1128
+ >
1129
+ : Component<IncludeElements<Inc> & Instantiated<M>, IncludeActions<Inc> & A>;
1130
+
1131
+ export function defineComponent<
1132
+ Params extends unknown[],
1133
+ Def extends DefinitionShape,
1134
+ Inc extends readonly AnySurface[] = [],
1135
+ M extends ElementMap = Record<never, never>,
1136
+ A extends ActionMap = Record<never, never>,
1137
+ >(
1138
+ build: (
1139
+ ...params: Params
1140
+ ) => Def &
1141
+ ComponentDefinition<Inc, M, A, ComponentInstanceOf<Def, Inc, M, A>>,
1142
+ ): Parameterized<Params, Surface<ComponentInstanceOf<Def, Inc, M, A>>>;
1143
+
1144
+ export function defineComponent<
1145
+ Def extends DefinitionShape,
1146
+ Inc extends readonly AnySurface[] = [],
1147
+ M extends ElementMap = Record<never, never>,
1148
+ A extends ActionMap = Record<never, never>,
1149
+ >(
1150
+ definition: Def &
1151
+ ComponentDefinition<Inc, M, A, ComponentInstanceOf<Def, Inc, M, A>>,
1152
+ ): Surface<ComponentInstanceOf<Def, Inc, M, A>>;
1153
+
1154
+ export function defineComponent(
1155
+ definition:
1156
+ | (DefinitionShape & SurfaceDefinition)
1157
+ | ((...params: never[]) => DefinitionShape & SurfaceDefinition),
1158
+ ): unknown {
1159
+ return definer(definition, (def) => {
1160
+ const root = def.root?.chain ?? [];
1161
+ const owner = "the component";
1162
+ const { elements, actions, capture } = assemble(
1163
+ def,
1164
+ owner,
1165
+ componentReserved,
1166
+ );
1167
+
1168
+ const captureRules = [
1169
+ ...capture,
1170
+ ...captureRulesOf(def.capture?.rules, owner, projectElements(elements)),
1171
+ ];
1172
+
1173
+ return componentFactoryOf({
1174
+ root,
1175
+ elements,
1176
+ actions,
1177
+ descriptor: projectSurface(root, elements, actions, captureRules),
1178
+ });
1179
+ });
1180
+ }
1181
+
1182
+ export interface OverlayClose {
1183
+ esc?: boolean;
1184
+ button?: LazyLocator;
1185
+ }
1186
+
1187
+ export interface OverlayRecorder {
1188
+ label: string;
1189
+ }
1190
+
1191
+ export interface OverlayHandle {
1192
+ waitFor: (state?: "visible" | "hidden") => Promise<void>;
1193
+ open: () => Promise<void>;
1194
+ close: () => Promise<void>;
1195
+ }
1196
+
1197
+ export type Overlay<E extends object> = {
1198
+ page: Page;
1199
+ elements: E;
1200
+ label: string;
1201
+ } & Rooted &
1202
+ OverlayHandle;
1203
+
1204
+ export type Dialog<E extends object> = Overlay<E> & {
1205
+ name: string | RegExp;
1206
+ };
1207
+
1208
+ type AnyOverlayInstance = SurfaceInstance &
1209
+ Rooted &
1210
+ OverlayHandle & { label: string };
1211
+
1212
+ export interface OverlayFactory<
1213
+ I extends AnyOverlayInstance = AnyOverlayInstance,
1214
+ > extends Surface<I> {
1215
+ overlay: true;
1216
+ descriptor: OverlayDescriptor;
1217
+ }
1218
+
1219
+ type OverlayInstanceOf<
1220
+ Inc extends readonly AnySurface[],
1221
+ M extends ElementMap,
1222
+ A extends ActionMap,
1223
+ > = Overlay<IncludeElements<Inc> & Instantiated<M>> & IncludeActions<Inc> & A;
1224
+
1225
+ type DialogInstanceOf<
1226
+ Inc extends readonly AnySurface[],
1227
+ M extends ElementMap,
1228
+ A extends ActionMap,
1229
+ > = Dialog<IncludeElements<Inc> & Instantiated<M>> & IncludeActions<Inc> & A;
1230
+
1231
+ export interface OverlayDefinition<
1232
+ Inc extends readonly AnySurface[] = [],
1233
+ M extends ElementMap = Record<never, never>,
1234
+ A extends ActionMap = Record<never, never>,
1235
+ > extends ComponentDefinition<Inc, M, A, OverlayInstanceOf<Inc, M, A>> {
1236
+ root: LazyLocator;
1237
+ recorder: OverlayRecorder;
1238
+ close?: OverlayClose;
1239
+ }
1240
+
1241
+ export interface DialogDefinition<
1242
+ Inc extends readonly AnySurface[] = [],
1243
+ M extends ElementMap = Record<never, never>,
1244
+ A extends ActionMap = Record<never, never>,
1245
+ > extends ComponentDefinition<Inc, M, A, DialogInstanceOf<Inc, M, A>> {
1246
+ name: string | RegExp;
1247
+ role?: "dialog" | "alertdialog";
1248
+ recorder?: OverlayRecorder;
1249
+ close?: OverlayClose;
1250
+ }
1251
+
1252
+ type AnyOverlayDefinition = OverlayDefinition<
1253
+ readonly AnySurface[],
1254
+ ElementMap,
1255
+ ActionMap
1256
+ >;
1257
+
1258
+ type AnyDialogDefinition = DialogDefinition<
1259
+ readonly AnySurface[],
1260
+ ElementMap,
1261
+ ActionMap
1262
+ >;
1263
+
1264
+ const overlayReserved = [
1265
+ ...componentReserved,
1266
+ "label",
1267
+ "name",
1268
+ "waitFor",
1269
+ "open",
1270
+ "close",
1271
+ ];
1272
+
1273
+ interface OverlayCore extends SurfaceCore {
1274
+ close: { esc: boolean; button?: (root: Locator) => Locator };
1275
+ descriptor: OverlayDescriptor;
1276
+ }
1277
+
1278
+ function overlayDescriptorOf(
1279
+ root: LocatorDescriptor,
1280
+ name: Text | undefined,
1281
+ label: string,
1282
+ close: OverlayClose | undefined,
1283
+ elements: BoundElements,
1284
+ actions: ActionMap,
1285
+ captureRules: CaptureRuleDescriptor[],
1286
+ ): OverlayDescriptor {
1287
+ const projectedActions = classifyActions(actions);
1288
+ const capture = captureDescriptorOf(captureRules);
1289
+
1290
+ return {
1291
+ root,
1292
+ ...(name === undefined ? {} : { name }),
1293
+ recorder: { label },
1294
+ close: {
1295
+ esc: close?.esc ?? true,
1296
+ ...(close?.button === undefined ? {} : { button: close.button.chain }),
1297
+ },
1298
+ elements: projectElements(elements),
1299
+ ...(projectedActions === undefined ? {} : { actions: projectedActions }),
1300
+ ...(capture === undefined ? {} : { capture }),
1301
+ };
1302
+ }
1303
+
1304
+ function overlayFactoryOf(core: OverlayCore): OverlayFactory {
1305
+ const name =
1306
+ core.descriptor.name === undefined
1307
+ ? undefined
1308
+ : plainTextOf(core.descriptor.name, "overlay name");
1309
+ const { label } = core.descriptor.recorder;
1310
+
1311
+ function factory(scope: LazyLocator): OverlayFactory;
1312
+ function factory(scope: Page): AnyOverlayInstance;
1313
+ function factory(scope: Scope): OverlayFactory | AnyOverlayInstance {
1314
+ if (isLazyLocator(scope)) {
1315
+ return overlay;
1316
+ }
1317
+
1318
+ const instance = instantiateSurface(core, scope, [], (grounded) => {
1319
+ const { root, self } = grounded;
1320
+ if (root === undefined || self === undefined) {
1321
+ throw new Error("an overlay needs a root");
1322
+ }
1323
+
1324
+ const waitFor = async (
1325
+ state: "visible" | "hidden" = "visible",
1326
+ ): Promise<void> => {
1327
+ await root.waitFor({ state });
1328
+ };
1329
+
1330
+ const open = async (): Promise<void> => {
1331
+ await Promise.resolve();
1332
+ throw new Error(
1333
+ `the "${label}" overlay is evoked elsewhere — await waitFor() instead of open()`,
1334
+ );
1335
+ };
1336
+
1337
+ const close = async (): Promise<void> => {
1338
+ if (core.close.button !== undefined) {
1339
+ await core.close.button(root).click();
1340
+ } else if (core.close.esc) {
1341
+ await grounded.page.keyboard.press("Escape");
1342
+ } else {
1343
+ throw new Error(
1344
+ `declare how the "${label}" overlay closes: a close button or esc`,
1345
+ );
1346
+ }
1347
+
1348
+ await root.waitFor({ state: "hidden" });
1349
+ };
1350
+
1351
+ return {
1352
+ ...(name === undefined ? {} : { name }),
1353
+ label,
1354
+ self,
1355
+ root,
1356
+ waitFor,
1357
+ open,
1358
+ close,
1359
+ };
1360
+ });
1361
+
1362
+ Object.defineProperty(instance, "descriptor", {
1363
+ value: core.descriptor,
1364
+ enumerable: false,
1365
+ });
1366
+
1367
+ return instance;
1368
+ }
1369
+
1370
+ const overlay: OverlayFactory = surfaceOf(core, factory, {
1371
+ overlay: true as const,
1372
+ });
1373
+
1374
+ return overlay;
1375
+ }
1376
+
1377
+ function closeOf(close: OverlayClose | undefined): OverlayCore["close"] {
1378
+ const button = close?.button;
1379
+
1380
+ return {
1381
+ esc: close?.esc ?? true,
1382
+ ...(button === undefined
1383
+ ? {}
1384
+ : {
1385
+ button: (root: Locator): Locator =>
1386
+ resolveLocator(root, button.chain),
1387
+ }),
1388
+ };
1389
+ }
1390
+
1391
+ function overlayOf(
1392
+ def: AnyOverlayDefinition | AnyDialogDefinition,
1393
+ root: LocatorDescriptor,
1394
+ name: Text | undefined,
1395
+ label: string,
1396
+ owner: string,
1397
+ ): OverlayFactory {
1398
+ const { elements, actions, capture } = assemble(def, owner, overlayReserved);
1399
+
1400
+ const captureRules = [
1401
+ ...capture,
1402
+ ...captureRulesOf(def.capture?.rules, owner, projectElements(elements)),
1403
+ ];
1404
+
1405
+ return overlayFactoryOf({
1406
+ root,
1407
+ elements,
1408
+ actions,
1409
+ close: closeOf(def.close),
1410
+ descriptor: overlayDescriptorOf(
1411
+ root,
1412
+ name,
1413
+ label,
1414
+ def.close,
1415
+ elements,
1416
+ actions,
1417
+ captureRules,
1418
+ ),
1419
+ });
1420
+ }
1421
+
1422
+ export function defineOverlay<
1423
+ Params extends unknown[],
1424
+ Inc extends readonly AnySurface[] = [],
1425
+ M extends ElementMap = Record<never, never>,
1426
+ A extends ActionMap = Record<never, never>,
1427
+ >(
1428
+ build: (...params: Params) => OverlayDefinition<Inc, M, A>,
1429
+ ): Parameterized<Params, OverlayFactory<OverlayInstanceOf<Inc, M, A>>>;
1430
+
1431
+ export function defineOverlay<
1432
+ Inc extends readonly AnySurface[] = [],
1433
+ M extends ElementMap = Record<never, never>,
1434
+ A extends ActionMap = Record<never, never>,
1435
+ >(
1436
+ definition: OverlayDefinition<Inc, M, A>,
1437
+ ): OverlayFactory<OverlayInstanceOf<Inc, M, A>>;
1438
+
1439
+ export function defineOverlay(
1440
+ definition:
1441
+ AnyOverlayDefinition | ((...params: never[]) => AnyOverlayDefinition),
1442
+ ): unknown {
1443
+ return definer(definition, (def) =>
1444
+ overlayOf(
1445
+ def,
1446
+ def.root.chain,
1447
+ undefined,
1448
+ def.recorder.label,
1449
+ `the "${def.recorder.label}" overlay`,
1450
+ ),
1451
+ );
1452
+ }
1453
+
1454
+ export function defineDialog<
1455
+ Params extends unknown[],
1456
+ Inc extends readonly AnySurface[] = [],
1457
+ M extends ElementMap = Record<never, never>,
1458
+ A extends ActionMap = Record<never, never>,
1459
+ >(
1460
+ build: (...params: Params) => DialogDefinition<Inc, M, A>,
1461
+ ): Parameterized<Params, OverlayFactory<DialogInstanceOf<Inc, M, A>>>;
1462
+
1463
+ export function defineDialog<
1464
+ Inc extends readonly AnySurface[] = [],
1465
+ M extends ElementMap = Record<never, never>,
1466
+ A extends ActionMap = Record<never, never>,
1467
+ >(
1468
+ definition: DialogDefinition<Inc, M, A>,
1469
+ ): OverlayFactory<DialogInstanceOf<Inc, M, A>>;
1470
+
1471
+ export function defineDialog(
1472
+ definition:
1473
+ AnyDialogDefinition | ((...params: never[]) => AnyDialogDefinition),
1474
+ ): unknown {
1475
+ return definer(definition, (def) => {
1476
+ const name = textFrom(def.name);
1477
+ const root: LocatorDescriptor = [
1478
+ { by: "role", role: def.role ?? "dialog", name },
1479
+ ];
1480
+
1481
+ const owner = `the ${typeof def.name === "string" ? `"${def.name}"` : def.name.source} dialog`;
1482
+ const label = def.recorder?.label ?? def.name;
1483
+ if (typeof label !== "string") {
1484
+ throw new Error(`${owner} needs recorder.label: its name is a pattern`);
1485
+ }
1486
+
1487
+ return overlayOf(def, root, name, label, owner);
1488
+ });
1489
+ }
1490
+
1491
+ export type PathParams<Path extends string> =
1492
+ Path extends `${string}:${infer Param}/${infer Rest}`
1493
+ ? Record<Param, string> & PathParams<Rest>
1494
+ : Path extends `${string}:${infer Param}`
1495
+ ? Record<Param, string>
1496
+ : unknown;
1497
+
1498
+ export interface PageMeta {
1499
+ name: string;
1500
+ path: string;
1501
+ group?: string[];
1502
+ }
1503
+
1504
+ export interface PageRecorder {
1505
+ group?: string[];
1506
+ label?: string;
1507
+ }
1508
+
1509
+ export interface Register {}
1510
+
1511
+ export type RegisteredPages = Register extends {
1512
+ app: { pages: infer P extends AnyPages };
1513
+ }
1514
+ ? BoundPages<P>
1515
+ : Record<string, never>;
1516
+
1517
+ export interface PageObject<Path extends string, E> {
1518
+ page: Page;
1519
+ path: Path;
1520
+ name: string;
1521
+ goto: (params: PathParams<Path>) => Promise<void>;
1522
+ url: (params: PathParams<Path>) => string;
1523
+ params: () => PathParams<Path>;
1524
+ waitFor: () => Promise<void>;
1525
+ elements: E;
1526
+ pages: RegisteredPages;
1527
+ }
1528
+
1529
+ export interface PageFactory<Path extends string, E, A> {
1530
+ (page: Page): PageObject<Path, E> & A;
1531
+ descriptor: PageDescriptor;
1532
+ }
1533
+
1534
+ export type InstanceOf<T> = T extends (...args: never[]) => infer R
1535
+ ? R extends (...args: never[]) => infer R2
1536
+ ? R2
1537
+ : R
1538
+ : never;
1539
+
1540
+ export type HeaderElement<H extends string | undefined> = H extends string
1541
+ ? { header: Locator }
1542
+ : unknown;
1543
+
1544
+ export interface PageContext<E> {
1545
+ page: Page;
1546
+ elements: E;
1547
+ pages: RegisteredPages;
1548
+ }
1549
+
1550
+ type PageThis<
1551
+ Inc extends readonly AnySurface[],
1552
+ M extends ElementMap,
1553
+ H extends string | undefined,
1554
+ A extends ActionMap,
1555
+ > = PageContext<IncludeElements<Inc> & Instantiated<M> & HeaderElement<H>> &
1556
+ IncludeActions<Inc> &
1557
+ A;
1558
+
1559
+ export interface PageDefinition<
1560
+ Path extends string,
1561
+ Inc extends readonly AnySurface[] = [],
1562
+ M extends ElementMap = Record<never, never>,
1563
+ H extends string | undefined = undefined,
1564
+ A extends ActionMap = Record<never, never>,
1565
+ Ambient extends readonly AnySurface[] = [],
1566
+ > {
1567
+ path: Path;
1568
+ name: string;
1569
+ recorder?: PageRecorder;
1570
+ ready?: AppReady;
1571
+ header?: H;
1572
+ include?: readonly [...Inc];
1573
+ elements?: M &
1574
+ ThisElements<PageThis<[...Ambient, ...Inc], M, H, A>> &
1575
+ ThisType<PageThis<[...Ambient, ...Inc], M, H, A>>;
1576
+ anchor?: string | LazyLocator;
1577
+ actions?: A & ActionMap & ThisType<PageThis<[...Ambient, ...Inc], M, H, A>>;
1578
+ capture?: TypedCaptureRules<
1579
+ IncludeElements<Inc> & Instantiated<M> & HeaderElement<H>
1580
+ >;
1581
+ }
1582
+
1583
+ export interface PagePresetDefinition<
1584
+ Prefix extends string,
1585
+ Inc extends readonly AnySurface[] = [],
1586
+ > {
1587
+ path: Prefix;
1588
+ include?: readonly [...Inc];
1589
+ recorder?: { group?: string[] };
1590
+ ready?: AppReady;
1591
+ capture?: TypedCaptureRules<IncludeElements<Inc>>;
1592
+ }
1593
+
1594
+ export interface PagePreset<
1595
+ Prefix extends string,
1596
+ Inc extends readonly AnySurface[],
1597
+ > {
1598
+ readonly path: Prefix;
1599
+ readonly include: Inc;
1600
+
1601
+ <
1602
+ Params extends unknown[],
1603
+ Sub extends string,
1604
+ Own extends readonly AnySurface[] = [],
1605
+ M extends ElementMap = Record<never, never>,
1606
+ H extends string | undefined = undefined,
1607
+ A extends ActionMap = Record<never, never>,
1608
+ >(
1609
+ build: (...params: Params) => PageDefinition<Sub, Own, M, H, A, Inc>,
1610
+ ): Parameterized<
1611
+ Params,
1612
+ PageFactory<
1613
+ `${Prefix}${Sub}`,
1614
+ IncludeElements<[...Inc, ...Own]> & Instantiated<M> & HeaderElement<H>,
1615
+ IncludeActions<[...Inc, ...Own]> & A
1616
+ >
1617
+ >;
1618
+
1619
+ <
1620
+ Sub extends string,
1621
+ Own extends readonly AnySurface[] = [],
1622
+ M extends ElementMap = Record<never, never>,
1623
+ H extends string | undefined = undefined,
1624
+ A extends ActionMap = Record<never, never>,
1625
+ >(
1626
+ def: PageDefinition<Sub, Own, M, H, A, Inc>,
1627
+ ): PageFactory<
1628
+ `${Prefix}${Sub}`,
1629
+ IncludeElements<[...Inc, ...Own]> & Instantiated<M> & HeaderElement<H>,
1630
+ IncludeActions<[...Inc, ...Own]> & A
1631
+ >;
1632
+
1633
+ preset<SubPrefix extends string, SubInc extends readonly AnySurface[] = []>(
1634
+ base: PagePresetDefinition<SubPrefix, SubInc>,
1635
+ ): PagePreset<`${Prefix}${SubPrefix}`, [...Inc, ...SubInc]>;
1636
+ }
1637
+
1638
+ export type PresetPage<
1639
+ P extends PagePreset<string, readonly AnySurface[]>,
1640
+ Sub extends string,
1641
+ M extends ElementMap = Record<never, never>,
1642
+ H extends string | undefined = undefined,
1643
+ A extends ActionMap = Record<never, never>,
1644
+ Own extends readonly AnySurface[] = [],
1645
+ > = PageFactory<
1646
+ `${P["path"]}${Sub}`,
1647
+ IncludeElements<[...P["include"], ...Own]> &
1648
+ Instantiated<M> &
1649
+ HeaderElement<H>,
1650
+ IncludeActions<[...P["include"], ...Own]> & A
1651
+ >;
1652
+
1653
+ export interface DefinePage {
1654
+ <
1655
+ Params extends unknown[],
1656
+ Path extends string,
1657
+ Inc extends readonly AnySurface[] = [],
1658
+ M extends ElementMap = Record<never, never>,
1659
+ H extends string | undefined = undefined,
1660
+ A extends ActionMap = Record<never, never>,
1661
+ >(
1662
+ build: (...params: Params) => PageDefinition<Path, Inc, M, H, A>,
1663
+ ): Parameterized<
1664
+ Params,
1665
+ PageFactory<
1666
+ Path,
1667
+ IncludeElements<Inc> & Instantiated<M> & HeaderElement<H>,
1668
+ IncludeActions<Inc> & A
1669
+ >
1670
+ >;
1671
+
1672
+ <
1673
+ Path extends string,
1674
+ Inc extends readonly AnySurface[] = [],
1675
+ M extends ElementMap = Record<never, never>,
1676
+ H extends string | undefined = undefined,
1677
+ A extends ActionMap = Record<never, never>,
1678
+ >(
1679
+ def: PageDefinition<Path, Inc, M, H, A>,
1680
+ ): PageFactory<
1681
+ Path,
1682
+ IncludeElements<Inc> & Instantiated<M> & HeaderElement<H>,
1683
+ IncludeActions<Inc> & A
1684
+ >;
1685
+
1686
+ preset<Prefix extends string, Inc extends readonly AnySurface[] = []>(
1687
+ base: PagePresetDefinition<Prefix, Inc>,
1688
+ ): PagePreset<Prefix, Inc>;
1689
+ }
1690
+
1691
+ export type AppReady = { selector: string } | ((page: Page) => Promise<void>);
1692
+
1693
+ function readyOf(ready: AppReady | undefined): (page: Page) => Promise<void> {
1694
+ if (ready === undefined) {
1695
+ return waitForHydration;
1696
+ }
1697
+
1698
+ if (typeof ready === "function") {
1699
+ return ready;
1700
+ }
1701
+
1702
+ return async (page): Promise<void> => {
1703
+ await page.locator(ready.selector).waitFor({ state: "attached" });
1704
+ };
1705
+ }
1706
+
1707
+ function readyDescriptorOf(
1708
+ ready: AppReady | undefined,
1709
+ ): { selector: string } | { opaque: true } | undefined {
1710
+ if (ready === undefined) {
1711
+ return undefined;
1712
+ }
1713
+
1714
+ return typeof ready === "function" ? { opaque: true } : ready;
1715
+ }
1716
+
1717
+ type AnyPageDefinition = PageDefinition<
1718
+ string,
1719
+ readonly AnySurface[],
1720
+ ElementMap,
1721
+ string | undefined,
1722
+ ActionMap
1723
+ >;
1724
+
1725
+ type PageAnchor = { key: string } | { chain: LocatorDescriptor };
1726
+
1727
+ function classifyAnchor(
1728
+ pageName: string,
1729
+ anchor: string | LazyLocator | undefined,
1730
+ elements: BoundElements,
1731
+ hasHeader: boolean,
1732
+ ): { descriptor?: LocatorDescriptor | { opaque: true }; anchor?: PageAnchor } {
1733
+ if (anchor === undefined) {
1734
+ return hasHeader ? { anchor: { key: "header" } } : {};
1735
+ }
1736
+
1737
+ if (isLazyLocator(anchor)) {
1738
+ return { descriptor: anchor.chain, anchor: { chain: anchor.chain } };
1739
+ }
1740
+
1741
+ const bound = elements[anchor];
1742
+ if (bound === undefined) {
1743
+ throw new Error(
1744
+ `the "${pageName}" page's anchor references unknown element "${anchor}"`,
1745
+ );
1746
+ }
1747
+
1748
+ if ("locator" in bound && bound.params === undefined) {
1749
+ return { descriptor: bound.locator, anchor: { key: anchor } };
1750
+ }
1751
+
1752
+ if ("opaque" in bound) {
1753
+ return { descriptor: { opaque: true }, anchor: { key: anchor } };
1754
+ }
1755
+
1756
+ throw new Error(
1757
+ `the "${pageName}" page's anchor "${anchor}" must be a plain locator element`,
1758
+ );
1759
+ }
1760
+
1761
+ function anchorElement(
1762
+ pageName: string,
1763
+ key: string,
1764
+ elements: object,
1765
+ ): Locator {
1766
+ const candidate = (elements as Record<string, Partial<Locator> | undefined>)[
1767
+ key
1768
+ ];
1769
+ if (candidate === undefined || typeof candidate.waitFor !== "function") {
1770
+ throw new Error(
1771
+ `the "${pageName}" page's anchor "${key}" did not produce a locator`,
1772
+ );
1773
+ }
1774
+
1775
+ return candidate as Locator;
1776
+ }
1777
+
1778
+ const pageReserved = [
1779
+ ...componentReserved,
1780
+ "path",
1781
+ "name",
1782
+ "goto",
1783
+ "url",
1784
+ "params",
1785
+ "waitFor",
1786
+ ];
1787
+
1788
+ interface PageCore extends SurfaceCore {
1789
+ anchor?: PageAnchor;
1790
+ ready?: AppReady;
1791
+ descriptor: PageDescriptor;
1792
+ }
1793
+
1794
+ interface AppBinding {
1795
+ ready?: AppReady;
1796
+ pages: (page: Page) => Record<string, unknown>;
1797
+ }
1798
+
1799
+ const bindKey = Symbol("walkwright.bind");
1800
+
1801
+ export interface AnyPageFactory {
1802
+ (page: Page): unknown;
1803
+ descriptor: PageDescriptor;
1804
+ }
1805
+
1806
+ interface BindablePage extends AnyPageFactory {
1807
+ [bindKey]: (binding: AppBinding) => BindablePage;
1808
+ }
1809
+
1810
+ function isBindable(value: unknown): value is BindablePage {
1811
+ return typeof value === "function" && bindKey in value;
1812
+ }
1813
+
1814
+ type PageDecorations = Omit<
1815
+ PageObject<string, object>,
1816
+ "page" | "elements" | "pages"
1817
+ >;
1818
+
1819
+ function pageFactoryOf(core: PageCore, binding?: AppBinding): BindablePage {
1820
+ const { path, name } = core.descriptor;
1821
+ const ready = readyOf(core.ready ?? binding?.ready);
1822
+
1823
+ const anchorOf = (page: Page, elements: object): Locator | undefined => {
1824
+ const anchor = core.anchor;
1825
+ if (anchor === undefined) {
1826
+ return undefined;
1827
+ }
1828
+
1829
+ return "chain" in anchor
1830
+ ? resolveLocator(page, anchor.chain)
1831
+ : anchorElement(name, anchor.key, elements);
1832
+ };
1833
+
1834
+ const factory = (page: Page): PageObject<string, object> => {
1835
+ const url = (params: PathParams<string>): string =>
1836
+ fillPath(path, params as Record<string, string>);
1837
+
1838
+ const instance = instantiateSurface<PageDecorations>(
1839
+ core,
1840
+ page,
1841
+ [],
1842
+ () => ({
1843
+ path,
1844
+ name,
1845
+ async goto(
1846
+ this: PageObject<string, object>,
1847
+ params: PathParams<string>,
1848
+ ): Promise<void> {
1849
+ await page.goto(url(params));
1850
+ await ready(page);
1851
+ await anchorOf(page, this.elements)?.waitFor();
1852
+ },
1853
+ url,
1854
+ params: (): PathParams<string> => matchPath(path, page.url()),
1855
+ async waitFor(this: PageObject<string, object>): Promise<void> {
1856
+ await anchorOf(page, this.elements)?.waitFor();
1857
+ },
1858
+ }),
1859
+ );
1860
+
1861
+ let pages: Record<string, unknown> | undefined;
1862
+ Object.defineProperty(instance, "pages", {
1863
+ enumerable: true,
1864
+ get(): Record<string, unknown> {
1865
+ if (binding === undefined) {
1866
+ throw new Error(
1867
+ `the "${name}" page is not app-bound — access this page through the app`,
1868
+ );
1869
+ }
1870
+
1871
+ pages ??= binding.pages(page);
1872
+ return pages;
1873
+ },
1874
+ });
1875
+
1876
+ return instance as PageObject<string, object>;
1877
+ };
1878
+
1879
+ return Object.assign(factory, {
1880
+ descriptor: core.descriptor,
1881
+ [bindKey]: (next: AppBinding): BindablePage => pageFactoryOf(core, next),
1882
+ });
1883
+ }
1884
+
1885
+ function recorderOf(
1886
+ recorder: PageRecorder | undefined,
1887
+ ): PageRecorder | undefined {
1888
+ if (recorder === undefined) {
1889
+ return undefined;
1890
+ }
1891
+
1892
+ const projected: PageRecorder = {
1893
+ ...(recorder.group === undefined ? {} : { group: recorder.group }),
1894
+ ...(recorder.label === undefined ? {} : { label: recorder.label }),
1895
+ };
1896
+
1897
+ return Object.keys(projected).length === 0 ? undefined : projected;
1898
+ }
1899
+
1900
+ function pageFactoryFrom(def: AnyPageDefinition): BindablePage {
1901
+ const owner = `the "${def.name}" page`;
1902
+ const assembled = assemble(def, owner, pageReserved);
1903
+
1904
+ if (def.header !== undefined && "header" in assembled.elements) {
1905
+ throw new Error(`${owner} declares both header and elements.header`);
1906
+ }
1907
+
1908
+ const elements: BoundElements = {
1909
+ ...(def.header === undefined
1910
+ ? {}
1911
+ : {
1912
+ header: {
1913
+ locator: [
1914
+ { by: "role", role: "heading", name: textFrom(def.header) },
1915
+ ],
1916
+ },
1917
+ }),
1918
+ ...assembled.elements,
1919
+ };
1920
+ const { actions } = assembled;
1921
+
1922
+ const anchor = classifyAnchor(
1923
+ def.name,
1924
+ def.anchor,
1925
+ elements,
1926
+ def.header !== undefined,
1927
+ );
1928
+ const recorder = recorderOf(def.recorder);
1929
+ const ready = readyDescriptorOf(def.ready);
1930
+ const projectedActions = classifyActions(actions);
1931
+
1932
+ const captureRules = [
1933
+ ...assembled.capture,
1934
+ ...captureRulesOf(def.capture?.rules, owner, projectElements(elements)),
1935
+ ];
1936
+ const capture = captureDescriptorOf(captureRules);
1937
+
1938
+ const descriptor: PageDescriptor = {
1939
+ path: def.path,
1940
+ name: def.name,
1941
+ ...(recorder === undefined ? {} : { recorder }),
1942
+ ...(ready === undefined ? {} : { ready }),
1943
+ ...(def.header === undefined ? {} : { header: textFrom(def.header) }),
1944
+ ...(anchor.descriptor === undefined ? {} : { anchor: anchor.descriptor }),
1945
+ elements: projectElements(elements),
1946
+ ...(projectedActions === undefined ? {} : { actions: projectedActions }),
1947
+ ...(capture === undefined ? {} : { capture }),
1948
+ };
1949
+
1950
+ return pageFactoryOf({
1951
+ root: [],
1952
+ ...(anchor.anchor === undefined ? {} : { anchor: anchor.anchor }),
1953
+ ...(def.ready === undefined ? {} : { ready: def.ready }),
1954
+ elements,
1955
+ actions,
1956
+ descriptor,
1957
+ });
1958
+ }
1959
+
1960
+ interface PresetBase {
1961
+ path: string;
1962
+ include: readonly AnySurface[];
1963
+ group: string[];
1964
+ ready?: AppReady;
1965
+ capture: CaptureRule[];
1966
+ }
1967
+
1968
+ function presetBaseOf(
1969
+ outer: PresetBase | undefined,
1970
+ base: PagePresetDefinition<string, readonly AnySurface[]>,
1971
+ ): PresetBase {
1972
+ const ready = base.ready ?? outer?.ready;
1973
+
1974
+ return {
1975
+ path: `${outer?.path ?? ""}${base.path}`,
1976
+ include: [...(outer?.include ?? []), ...(base.include ?? [])],
1977
+ group: [...(outer?.group ?? []), ...(base.recorder?.group ?? [])],
1978
+ capture: [...(base.capture?.rules ?? []), ...(outer?.capture ?? [])],
1979
+ ...(ready === undefined ? {} : { ready }),
1980
+ };
1981
+ }
1982
+
1983
+ function presetOf(base: PresetBase): PagePreset<string, readonly AnySurface[]> {
1984
+ const apply = (def: AnyPageDefinition): AnyPageDefinition => {
1985
+ const group = [...base.group, ...(def.recorder?.group ?? [])];
1986
+ const recorder: PageRecorder = {
1987
+ ...def.recorder,
1988
+ ...(group.length === 0 ? {} : { group }),
1989
+ };
1990
+ const ready = def.ready ?? base.ready;
1991
+ const capture = [...(def.capture?.rules ?? []), ...base.capture];
1992
+
1993
+ return {
1994
+ ...def,
1995
+ path: `${base.path}${def.path}`,
1996
+ include: [...base.include, ...(def.include ?? [])],
1997
+ recorder,
1998
+ ...(capture.length === 0 ? {} : { capture: { rules: capture } }),
1999
+ ...(ready === undefined ? {} : { ready }),
2000
+ };
2001
+ };
2002
+
2003
+ const preset = (
2004
+ definition: AnyPageDefinition | ((...params: never[]) => AnyPageDefinition),
2005
+ ): unknown => definer(definition, (def) => pageFactoryFrom(apply(def)));
2006
+
2007
+ return Object.assign(preset, {
2008
+ path: base.path,
2009
+ include: base.include,
2010
+ preset: (
2011
+ sub: PagePresetDefinition<string, readonly AnySurface[]>,
2012
+ ): PagePreset<string, readonly AnySurface[]> =>
2013
+ presetOf(presetBaseOf(base, sub)),
2014
+ }) as unknown as PagePreset<string, readonly AnySurface[]>;
2015
+ }
2016
+
2017
+ export const definePage = Object.assign(
2018
+ (
2019
+ definition: AnyPageDefinition | ((...params: never[]) => AnyPageDefinition),
2020
+ ): unknown => definer(definition, pageFactoryFrom),
2021
+ {
2022
+ preset: (
2023
+ base: PagePresetDefinition<string, readonly AnySurface[]>,
2024
+ ): PagePreset<string, readonly AnySurface[]> =>
2025
+ presetOf(presetBaseOf(undefined, base)),
2026
+ },
2027
+ ) as unknown as DefinePage;
2028
+
2029
+ export type AnyPages = Record<string, AnyPageFactory>;
2030
+
2031
+ export interface AppDefinition<P extends AnyPages> {
2032
+ pages: P;
2033
+ ready?: AppReady;
2034
+ capture?: { rules: { match: RegExp; text: string }[] };
2035
+ }
2036
+
2037
+ export type BoundPages<P extends AnyPages> = {
2038
+ [K in keyof P]: P[K] extends (page: Page) => infer Instance
2039
+ ? Instance
2040
+ : never;
2041
+ };
2042
+
2043
+ export interface AppFixtures<P extends AnyPages> extends RecorderFixtures {
2044
+ pages: BoundPages<P>;
2045
+ }
2046
+
2047
+ type DefaultTestArgs<P extends AnyPages> = PlaywrightTestArgs &
2048
+ PlaywrightTestOptions &
2049
+ AppFixtures<P>;
2050
+
2051
+ type DefaultWorkerArgs = PlaywrightWorkerArgs & PlaywrightWorkerOptions;
2052
+
2053
+ export interface AppFixtureOptions {
2054
+ base?: TestType<PlaywrightTestArgs, NonNullable<unknown>>;
2055
+ }
2056
+
2057
+ export interface AppFixture<P extends AnyPages> {
2058
+ <
2059
+ Defs extends readonly AnyProvision[],
2060
+ T extends PlaywrightTestArgs,
2061
+ W extends NonNullable<unknown>,
2062
+ >(
2063
+ provisions: Defs,
2064
+ options: AppFixtureOptions & { base: TestType<T, W> },
2065
+ ): TestType<T & AppFixtures<P> & { provision: ProvisionMap<Defs> }, W>;
2066
+ <Defs extends readonly AnyProvision[]>(
2067
+ provisions: Defs,
2068
+ options?: AppFixtureOptions,
2069
+ ): TestType<
2070
+ DefaultTestArgs<P> & { provision: ProvisionMap<Defs> },
2071
+ DefaultWorkerArgs
2072
+ >;
2073
+ <T extends PlaywrightTestArgs, W extends NonNullable<unknown>>(
2074
+ provisions: undefined,
2075
+ options: AppFixtureOptions & { base: TestType<T, W> },
2076
+ ): TestType<T & AppFixtures<P>, W>;
2077
+ (
2078
+ provisions?: undefined,
2079
+ options?: AppFixtureOptions,
2080
+ ): TestType<DefaultTestArgs<P>, DefaultWorkerArgs>;
2081
+ }
2082
+
2083
+ export interface App<P extends AnyPages = AnyPages> {
2084
+ pages: P;
2085
+ fixture: AppFixture<P>;
2086
+ descriptor: AppDescriptor;
2087
+ }
2088
+
2089
+ function boundPagesOf(pages: AnyPages, page: Page): Record<string, unknown> {
2090
+ const bound: Record<string, unknown> = {};
2091
+ const settled = new Map<string, unknown>();
2092
+
2093
+ for (const [key, factory] of Object.entries(pages)) {
2094
+ Object.defineProperty(bound, key, {
2095
+ enumerable: true,
2096
+ get(): unknown {
2097
+ if (!settled.has(key)) {
2098
+ settled.set(key, factory(page));
2099
+ }
2100
+
2101
+ return settled.get(key);
2102
+ },
2103
+ });
2104
+ }
2105
+
2106
+ return bound;
2107
+ }
2108
+
2109
+ function appOf<P extends AnyPages>(
2110
+ def: AppDefinition<P>,
2111
+ descriptor: AppDescriptor,
2112
+ ): App<P> {
2113
+ const byPath = new Map<string, string>();
2114
+ for (const page of descriptor.pages) {
2115
+ const holder = byPath.get(page.path);
2116
+ if (holder !== undefined) {
2117
+ throw new Error(
2118
+ `pages "${holder}" and "${page.name}" both claim the path ${page.path}`,
2119
+ );
2120
+ }
2121
+ byPath.set(page.path, page.name);
2122
+ }
2123
+
2124
+ const bound: Record<string, AnyPageFactory> = {};
2125
+ const binding: AppBinding = {
2126
+ ...(def.ready === undefined ? {} : { ready: def.ready }),
2127
+ pages: (page) => boundPagesOf(bound, page),
2128
+ };
2129
+
2130
+ for (const [key, factory] of Object.entries<AnyPageFactory>(def.pages)) {
2131
+ if (!isBindable(factory)) {
2132
+ throw new Error(`"${key}" is not a page defined with definePage`);
2133
+ }
2134
+ bound[key] = factory[bindKey](binding);
2135
+ }
2136
+
2137
+ const app: App<P> = {
2138
+ pages: bound as P,
2139
+ descriptor,
2140
+ fixture: ((
2141
+ provisions?: readonly AnyProvision[],
2142
+ options?: AppFixtureOptions,
2143
+ ) => {
2144
+ const test = (options?.base ?? playwrightTest).extend<
2145
+ AppFixtures<AnyPages>
2146
+ >({
2147
+ ...createRecorderFixtures(app),
2148
+ pages: async ({ page }, use): Promise<void> => {
2149
+ await use(boundPagesOf(bound, page));
2150
+ },
2151
+ });
2152
+
2153
+ return provisions === undefined
2154
+ ? test
2155
+ : test.extend<{ provision: ProvisionMap<readonly AnyProvision[]> }>({
2156
+ provision: async ({ page }, use): Promise<void> => {
2157
+ await using provisioner = createProvisioner(page, provisions);
2158
+
2159
+ await use(provisioner.provision);
2160
+ },
2161
+ });
2162
+ }) as AppFixture<P>,
2163
+ };
2164
+
2165
+ return app;
2166
+ }
2167
+
2168
+ export function defineApp<P extends AnyPages>(def: AppDefinition<P>): App<P> {
2169
+ const ready = readyDescriptorOf(def.ready);
2170
+ const captureRules = (def.capture?.rules ?? []).map(
2171
+ (rule): CaptureRuleDescriptor => ({
2172
+ match: regexDescriptorOf(rule.match),
2173
+ text: rule.text,
2174
+ }),
2175
+ );
2176
+ const capture = captureDescriptorOf(captureRules);
2177
+
2178
+ return appOf(def, {
2179
+ ...(ready === undefined ? {} : { ready }),
2180
+ pages: Object.values<AnyPageFactory>(def.pages)
2181
+ .map((page) => page.descriptor)
2182
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
2183
+ ...(capture === undefined ? {} : { capture }),
2184
+ });
2185
+ }
2186
+
2187
+ function loadElement(descriptor: ElementDescriptor): BoundElement | undefined {
2188
+ if ("locator" in descriptor) {
2189
+ return descriptor.params === undefined
2190
+ ? { locator: descriptor.locator }
2191
+ : { locator: descriptor.locator, params: descriptor.params };
2192
+ }
2193
+
2194
+ if ("surface" in descriptor) {
2195
+ return { surface: loadSurface(descriptor.surface) };
2196
+ }
2197
+
2198
+ if ("overlay" in descriptor) {
2199
+ return { overlay: loadOverlay(descriptor) };
2200
+ }
2201
+
2202
+ return undefined;
2203
+ }
2204
+
2205
+ function loadElements(
2206
+ elements: Record<string, ElementDescriptor>,
2207
+ ): BoundElements {
2208
+ const bound: BoundElements = {};
2209
+
2210
+ for (const [key, descriptor] of Object.entries(elements)) {
2211
+ const loaded = loadElement(descriptor);
2212
+ if (loaded !== undefined) {
2213
+ bound[key] = loaded;
2214
+ }
2215
+ }
2216
+
2217
+ return bound;
2218
+ }
2219
+
2220
+ function loadSurface(descriptor: NestedSurfaceDescriptor): NestedSurface {
2221
+ return {
2222
+ factory: componentFactoryOf({
2223
+ root: descriptor.root ?? [],
2224
+ elements: loadElements(descriptor.elements),
2225
+ actions: {},
2226
+ descriptor,
2227
+ }),
2228
+ descriptor,
2229
+ };
2230
+ }
2231
+
2232
+ function loadOverlay(
2233
+ descriptor: Extract<ElementDescriptor, { overlay: OverlayDescriptor }>,
2234
+ ): NestedOverlay {
2235
+ const { overlay } = descriptor;
2236
+ const button = overlay.close.button;
2237
+
2238
+ return {
2239
+ factory: overlayFactoryOf({
2240
+ root: overlay.root,
2241
+ elements: loadElements(overlay.elements),
2242
+ actions: {},
2243
+ close: {
2244
+ esc: overlay.close.esc,
2245
+ ...(button === undefined
2246
+ ? {}
2247
+ : {
2248
+ button: (root: Locator): Locator => resolveLocator(root, button),
2249
+ }),
2250
+ },
2251
+ descriptor: overlay,
2252
+ }),
2253
+ descriptor,
2254
+ };
2255
+ }
2256
+
2257
+ export function loadPage(
2258
+ descriptor: PageDescriptor,
2259
+ ): PageFactory<string, Record<string, unknown>, Record<never, never>> {
2260
+ const ready =
2261
+ descriptor.ready !== undefined && "selector" in descriptor.ready
2262
+ ? descriptor.ready
2263
+ : undefined;
2264
+ const anchor: PageAnchor | undefined = Array.isArray(descriptor.anchor)
2265
+ ? { chain: descriptor.anchor }
2266
+ : descriptor.header === undefined
2267
+ ? undefined
2268
+ : { key: "header" };
2269
+
2270
+ return pageFactoryOf({
2271
+ root: [],
2272
+ ...(anchor === undefined ? {} : { anchor }),
2273
+ ...(ready === undefined ? {} : { ready }),
2274
+ elements: loadElements(descriptor.elements),
2275
+ actions: {},
2276
+ descriptor,
2277
+ }) as PageFactory<string, Record<string, unknown>, Record<never, never>>;
2278
+ }
2279
+
2280
+ export function loadApp(raw: unknown): App {
2281
+ const parsed = appDescriptorSchema.parse(raw);
2282
+ const pages = Object.fromEntries(
2283
+ parsed.pages.map((page) => [page.name, loadPage(page)]),
2284
+ );
2285
+
2286
+ const ready =
2287
+ parsed.ready !== undefined && "selector" in parsed.ready
2288
+ ? parsed.ready
2289
+ : undefined;
2290
+
2291
+ return appOf(
2292
+ {
2293
+ pages,
2294
+ ...(ready === undefined ? {} : { ready }),
2295
+ },
2296
+ parsed,
2297
+ );
2298
+ }
2299
+
2300
+ export async function arriveAt<T extends { waitFor: () => Promise<void> }>(
2301
+ destination: Factory<T>,
2302
+ page: Page,
2303
+ ): Promise<T> {
2304
+ const arrived = destination(page);
2305
+ await arrived.waitFor();
2306
+ return arrived;
2307
+ }
2308
+
2309
+ export async function waitForHydration(
2310
+ page: Page,
2311
+ timeoutMs = 60_000,
2312
+ ): Promise<void> {
2313
+ const marker = page.locator('html[data-hydrated="true"]');
2314
+ const attached = await marker
2315
+ .waitFor({ state: "attached", timeout: timeoutMs })
2316
+ .then(
2317
+ () => true,
2318
+ () => false,
2319
+ );
2320
+
2321
+ if (!attached) {
2322
+ await page.reload();
2323
+ await marker.waitFor({ state: "attached", timeout: timeoutMs });
2324
+ }
2325
+ }
2326
+
2327
+ export function pathParams<Path extends string>(
2328
+ path: Path,
2329
+ url: string,
2330
+ ): PathParams<Path> {
2331
+ return matchPath(path, url) as PathParams<Path>;
2332
+ }
2333
+
2334
+ function matchPath(path: string, url: string): PathParams<string> {
2335
+ const pattern = new RegExp(
2336
+ `^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/:[A-Za-z]+/g, "([^/]+)")}(?:/|$)`,
2337
+ );
2338
+ const names = [...path.matchAll(/:([A-Za-z]+)/g)].map(([, name]) => name);
2339
+ const pathname = new URL(url).pathname;
2340
+ const match = pattern.exec(pathname);
2341
+
2342
+ if (match === null) {
2343
+ throw new Error(`${pathname} does not match ${path}`);
2344
+ }
2345
+
2346
+ return Object.fromEntries(
2347
+ names.map((name, index) => [
2348
+ name,
2349
+ decodeURIComponent(match[index + 1] ?? ""),
2350
+ ]),
2351
+ ) as PathParams<string>;
2352
+ }
2353
+
2354
+ function fillPath(path: string, params: Record<string, string>): string {
2355
+ return path.replace(/:([A-Za-z]+)/g, (match, name: string) => {
2356
+ const value = params[name];
2357
+ if (value === undefined) {
2358
+ throw new Error(`missing path param ${match}`);
2359
+ }
2360
+
2361
+ return encodeURIComponent(value);
2362
+ });
2363
+ }