pomwright 1.3.0 → 1.5.0

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 (117) hide show
  1. package/AGENTS.md +37 -0
  2. package/CHANGELOG.md +193 -0
  3. package/README.md +316 -34
  4. package/dist/index.d.mts +1058 -132
  5. package/dist/index.d.ts +1058 -132
  6. package/dist/index.js +2309 -185
  7. package/dist/index.mjs +2304 -185
  8. package/docs/{get-locator-methods-explanation.md → v1/get-locator-methods-explanation.md} +0 -16
  9. package/docs/v1-to-v2-migration/bridge-migration-guide.md +159 -0
  10. package/docs/v1-to-v2-migration/direct-migration-guide.md +238 -0
  11. package/docs/v1-to-v2-migration/v1-to-v2-comparison.md +547 -0
  12. package/docs/v2/PageObject.md +293 -0
  13. package/docs/v2/composing-locator-modules.md +93 -0
  14. package/docs/v2/locator-registry.md +693 -0
  15. package/docs/v2/logging.md +168 -0
  16. package/docs/v2/overview.md +515 -0
  17. package/docs/v2/session-storage.md +160 -0
  18. package/index.ts +61 -9
  19. package/intTestV2/.env +0 -0
  20. package/intTestV2/fixtures/testApp.fixtures.ts +43 -0
  21. package/intTestV2/package.json +22 -0
  22. package/intTestV2/page-object-models/testApp/pages/iframe/iframe.locatorSchema.ts +24 -0
  23. package/intTestV2/page-object-models/testApp/pages/iframe/iframe.page.ts +17 -0
  24. package/intTestV2/page-object-models/testApp/pages/testPage.locatorSchema.ts +32 -0
  25. package/intTestV2/page-object-models/testApp/pages/testPage.page.ts +119 -0
  26. package/intTestV2/page-object-models/testApp/pages/testPath/[color]/color.locatorSchema.ts +29 -0
  27. package/intTestV2/page-object-models/testApp/pages/testPath/[color]/color.page.ts +48 -0
  28. package/intTestV2/page-object-models/testApp/pages/testPath/testPath.locatorSchema.ts +9 -0
  29. package/intTestV2/page-object-models/testApp/pages/testPath/testPath.page.ts +23 -0
  30. package/intTestV2/page-object-models/testApp/pages/testfilters/testfilters.locatorSchema.ts +114 -0
  31. package/intTestV2/page-object-models/testApp/pages/testfilters/testfilters.page.ts +23 -0
  32. package/intTestV2/page-object-models/testApp/testApp.base.ts +20 -0
  33. package/intTestV2/playwright.config.ts +54 -0
  34. package/intTestV2/server.js +216 -0
  35. package/intTestV2/test-data/staticPage/index.html +280 -0
  36. package/intTestV2/test-data/staticPage/w3images/avatar2.png +0 -0
  37. package/intTestV2/test-data/staticPage/w3images/avatar3.png +0 -0
  38. package/intTestV2/test-data/staticPage/w3images/avatar5.png +0 -0
  39. package/intTestV2/test-data/staticPage/w3images/avatar6.png +0 -0
  40. package/intTestV2/test-data/staticPage/w3images/forest.jpg +0 -0
  41. package/intTestV2/test-data/staticPage/w3images/lights.jpg +0 -0
  42. package/intTestV2/test-data/staticPage/w3images/mountains.jpg +0 -0
  43. package/intTestV2/test-data/staticPage/w3images/nature.jpg +0 -0
  44. package/intTestV2/test-data/staticPage/w3images/snow.jpg +0 -0
  45. package/intTestV2/tests/locatorRegistry/add/add.describe.spec.ts +54 -0
  46. package/intTestV2/tests/locatorRegistry/add/add.filter.spec.ts +143 -0
  47. package/intTestV2/tests/locatorRegistry/add/add.frameLocator.spec.ts +23 -0
  48. package/intTestV2/tests/locatorRegistry/add/add.getByAltText.spec.ts +23 -0
  49. package/intTestV2/tests/locatorRegistry/add/add.getById.spec.ts +45 -0
  50. package/intTestV2/tests/locatorRegistry/add/add.getByLabel.spec.ts +23 -0
  51. package/intTestV2/tests/locatorRegistry/add/add.getByPlaceholder.spec.ts +23 -0
  52. package/intTestV2/tests/locatorRegistry/add/add.getByRole.spec.ts +23 -0
  53. package/intTestV2/tests/locatorRegistry/add/add.getByTestId.spec.ts +23 -0
  54. package/intTestV2/tests/locatorRegistry/add/add.getByText.spec.ts +23 -0
  55. package/intTestV2/tests/locatorRegistry/add/add.getByTitle.spec.ts +23 -0
  56. package/intTestV2/tests/locatorRegistry/add/add.locator.spec.ts +23 -0
  57. package/intTestV2/tests/locatorRegistry/add/add.reuseExisting.spec.ts +66 -0
  58. package/intTestV2/tests/locatorRegistry/add/add.reuseReusable.spec.ts +311 -0
  59. package/intTestV2/tests/locatorRegistry/add/add.spec.ts +159 -0
  60. package/intTestV2/tests/locatorRegistry/filter.cycle.spec.ts +39 -0
  61. package/intTestV2/tests/locatorRegistry/getLocator/getLocator.spec.ts +253 -0
  62. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.clearSteps.spec.ts +105 -0
  63. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.describe.spec.ts +23 -0
  64. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.filter.spec.ts +368 -0
  65. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.getLocator.spec.ts +56 -0
  66. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.getNestedLocator.spec.ts +175 -0
  67. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.nth.spec.ts +60 -0
  68. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.remove.spec.ts +32 -0
  69. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.replace.spec.ts +24 -0
  70. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.spec.ts +110 -0
  71. package/intTestV2/tests/locatorRegistry/getLocatorSchema/getLocatorSchema.update.spec.ts +322 -0
  72. package/intTestV2/tests/locatorRegistry/getNestedLocator/getNestedLocator.spec.ts +412 -0
  73. package/intTestV2/tests/locatorRegistry/registry/registry.binding.spec.ts +50 -0
  74. package/intTestV2/tests/locatorRegistry/validation/validation.locatorSchemaPath.spec.ts +115 -0
  75. package/intTestV2/tests/locatorRegistry/validation/validation.sub-path.spec.ts +45 -0
  76. package/intTestV2/tests/step/step.spec.ts +49 -0
  77. package/intTestV2/tests/testApp/color.spec.ts +15 -0
  78. package/intTestV2/tests/testApp/iframe.spec.ts +57 -0
  79. package/intTestV2/tests/testApp/testFilters.spec.ts +24 -0
  80. package/intTestV2/tests/testApp/testPage.spec.ts +161 -0
  81. package/intTestV2/tests/testApp/testPath.spec.ts +18 -0
  82. package/pack-build.sh +11 -0
  83. package/pack-test-v2.sh +36 -0
  84. package/package.json +10 -3
  85. package/playwright.base.ts +42 -0
  86. package/skills/README.md +56 -0
  87. package/skills/pomwright-v1-5-bridge-migration/SKILL.md +40 -0
  88. package/skills/pomwright-v1-5-bridge-migration/references/call-site-migration.md +178 -0
  89. package/skills/pomwright-v1-5-bridge-migration/references/schema-translation.md +183 -0
  90. package/skills/pomwright-v2-migration/SKILL.md +63 -0
  91. package/skills/pomwright-v2-migration/references/call-site-migration.md +265 -0
  92. package/skills/pomwright-v2-migration/references/class-migration.md +266 -0
  93. package/skills/pomwright-v2-migration/references/fixture-and-helpers.md +423 -0
  94. package/skills/pomwright-v2-migration/references/locator-registration.md +344 -0
  95. package/srcV2/fixture/base.fixtures.ts +23 -0
  96. package/srcV2/helpers/navigation.ts +153 -0
  97. package/srcV2/helpers/playwrightReportLogger.ts +196 -0
  98. package/srcV2/helpers/sessionStorage.ts +251 -0
  99. package/srcV2/helpers/stepDecorator.ts +106 -0
  100. package/srcV2/locators/index.ts +15 -0
  101. package/srcV2/locators/locatorQueryBuilder.ts +427 -0
  102. package/srcV2/locators/locatorRegistrationBuilder.ts +558 -0
  103. package/srcV2/locators/locatorRegistry.ts +541 -0
  104. package/srcV2/locators/locatorUpdateBuilder.ts +602 -0
  105. package/srcV2/locators/reusableLocatorBuilder.ts +200 -0
  106. package/srcV2/locators/types.ts +256 -0
  107. package/srcV2/locators/utils.ts +309 -0
  108. package/srcV2/locators/v1SchemaTranslator.ts +178 -0
  109. package/srcV2/pageObject.ts +105 -0
  110. /package/docs/{BaseApi-explanation.md → v1/BaseApi-explanation.md} +0 -0
  111. /package/docs/{BasePage-explanation.md → v1/BasePage-explanation.md} +0 -0
  112. /package/docs/{LocatorSchema-explanation.md → v1/LocatorSchema-explanation.md} +0 -0
  113. /package/docs/{LocatorSchemaPath-explanation.md → v1/LocatorSchemaPath-explanation.md} +0 -0
  114. /package/docs/{PlaywrightReportLogger-explanation.md → v1/PlaywrightReportLogger-explanation.md} +0 -0
  115. /package/docs/{intro-to-using-pomwright.md → v1/intro-to-using-pomwright.md} +0 -0
  116. /package/docs/{sessionStorage-methods-explanation.md → v1/sessionStorage-methods-explanation.md} +0 -0
  117. /package/docs/{tips-folder-structure.md → v1/tips-folder-structure.md} +0 -0
@@ -0,0 +1,344 @@
1
+ # Locator Registration: defineLocators and the v2 add() DSL
2
+
3
+ ## Overview
4
+
5
+ v2 locator registration uses a fluent builder API inside `defineLocators()`. The `add(path)` method returns a builder with Playwright-matching method names. No `GetByMethod` enum or schema objects are needed.
6
+
7
+ ## File structure
8
+
9
+ v2 separates path types and registration into a companion `.locatorSchema.ts` file:
10
+
11
+ ```ts
12
+ // myPage.locatorSchema.ts
13
+ import type { LocatorRegistry } from "pomwright";
14
+
15
+ export type Paths =
16
+ | "main"
17
+ | "main.form"
18
+ | "main.form.input@username"
19
+ | "main.form.button@submit";
20
+
21
+ export function defineLocators(registry: LocatorRegistry<Paths>) {
22
+ registry.add("main").locator("main");
23
+ registry.add("main.form").locator("form.login");
24
+ registry.add("main.form.input@username").getByLabel("Username");
25
+ registry.add("main.form.button@submit").getByRole("button", { name: "Submit" });
26
+ }
27
+ ```
28
+
29
+ ```ts
30
+ // myPage.page.ts
31
+ import { defineLocators, type Paths } from "./myPage.locatorSchema";
32
+
33
+ export default class MyPage extends PageObject<Paths> {
34
+ protected defineLocators(): void {
35
+ defineLocators(this.locatorRegistry);
36
+ }
37
+ }
38
+ ```
39
+
40
+ This pattern separates concerns (types/registration vs class behavior) and allows locator definitions to be shared across page objects.
41
+
42
+ ## GetByMethod mapping
43
+
44
+ | v1 `locatorMethod` | v1 schema field | v1 options field | v2 method | v2 arguments |
45
+ |---|---|---|---|---|
46
+ | `GetByMethod.role` | `role` | `roleOptions` | `getByRole(role, options?)` | role value, roleOptions as second arg |
47
+ | `GetByMethod.text` | `text` | `textOptions` | `getByText(text, options?)` | text value, textOptions as second arg |
48
+ | `GetByMethod.label` | `label` | `labelOptions` | `getByLabel(text, options?)` | label value, labelOptions as second arg |
49
+ | `GetByMethod.placeholder` | `placeholder` | `placeholderOptions` | `getByPlaceholder(text, options?)` | placeholder value, placeholderOptions |
50
+ | `GetByMethod.altText` | `altText` | `altTextOptions` | `getByAltText(text, options?)` | altText value, altTextOptions |
51
+ | `GetByMethod.title` | `title` | `titleOptions` | `getByTitle(text, options?)` | title value, titleOptions |
52
+ | `GetByMethod.locator` | `locator` (string) | `locatorOptions` | `locator(selector, options?)` | locator string, locatorOptions |
53
+ | `GetByMethod.frameLocator` | `frameLocator` | - | `frameLocator(selector)` | frameLocator string |
54
+ | `GetByMethod.testId` | `testId` | - | `getByTestId(testId)` | testId value |
55
+ | `GetByMethod.id` | `id` | - | `getById(id)` | id string or RegExp |
56
+ | `GetByMethod.dataCy` | `dataCy` | - | `locator('[data-cy="value"]')` | CSS attribute selector string |
57
+
58
+ ## Translation examples
59
+
60
+ ### Role
61
+
62
+ ```ts
63
+ // v1
64
+ this.locators.addSchema("main.button@login", {
65
+ role: "button",
66
+ roleOptions: { name: "Login" },
67
+ locatorMethod: GetByMethod.role,
68
+ });
69
+
70
+ // v2
71
+ this.add("main.button@login").getByRole("button", { name: "Login" });
72
+ ```
73
+
74
+ ### Locator (CSS/XPath)
75
+
76
+ ```ts
77
+ // v1
78
+ this.locators.addSchema("main", {
79
+ locator: "main",
80
+ locatorMethod: GetByMethod.locator,
81
+ });
82
+
83
+ // v2
84
+ this.add("main").locator("main");
85
+ ```
86
+
87
+ ### Locator with options
88
+
89
+ ```ts
90
+ // v1
91
+ this.locators.addSchema("body.section@playground", {
92
+ locator: "section",
93
+ locatorOptions: { hasText: /Playground/i },
94
+ locatorMethod: GetByMethod.locator,
95
+ });
96
+
97
+ // v2
98
+ this.add("body.section@playground").locator("section", { hasText: /Playground/i });
99
+ ```
100
+
101
+ ### Label
102
+
103
+ ```ts
104
+ // v1
105
+ this.locators.addSchema("main.form.input@username", {
106
+ label: "Username",
107
+ locatorMethod: GetByMethod.label,
108
+ });
109
+
110
+ // v2
111
+ this.add("main.form.input@username").getByLabel("Username");
112
+ ```
113
+
114
+ ### Text
115
+
116
+ ```ts
117
+ // v1
118
+ this.locators.addSchema("main.heading", {
119
+ text: "Welcome",
120
+ textOptions: { exact: true },
121
+ locatorMethod: GetByMethod.text,
122
+ });
123
+
124
+ // v2
125
+ this.add("main.heading").getByText("Welcome", { exact: true });
126
+ ```
127
+
128
+ ### Placeholder
129
+
130
+ ```ts
131
+ // v1
132
+ this.locators.addSchema("main.form.input@search", {
133
+ placeholder: "Search...",
134
+ locatorMethod: GetByMethod.placeholder,
135
+ });
136
+
137
+ // v2
138
+ this.add("main.form.input@search").getByPlaceholder("Search...");
139
+ ```
140
+
141
+ ### Title
142
+
143
+ ```ts
144
+ // v1
145
+ this.locators.addSchema("topMenu.news", {
146
+ title: "News",
147
+ locatorMethod: GetByMethod.title,
148
+ });
149
+
150
+ // v2
151
+ this.add("topMenu.news").getByTitle("News");
152
+ ```
153
+
154
+ ### TestId
155
+
156
+ ```ts
157
+ // v1
158
+ this.locators.addSchema("main.toggle", {
159
+ testId: "toggle-a",
160
+ locatorMethod: GetByMethod.testId,
161
+ });
162
+
163
+ // v2
164
+ this.add("main.toggle").getByTestId("toggle-a");
165
+ ```
166
+
167
+ ### FrameLocator
168
+
169
+ ```ts
170
+ // v1
171
+ this.locators.addSchema("main.frame@login", {
172
+ frameLocator: "#auth",
173
+ locatorMethod: GetByMethod.frameLocator,
174
+ });
175
+
176
+ // v2
177
+ this.add("main.frame@login").frameLocator("#auth");
178
+ ```
179
+
180
+ Terminal frameLocator paths in v2 resolve to `frameLocator.owner()` (the iframe element), not the frame itself. Non-terminal frames scope into the frame for subsequent segments. This is a behavioral change from v1.
181
+
182
+ ### getById
183
+
184
+ ```ts
185
+ // v1
186
+ this.locators.addSchema("main.modal@close", {
187
+ id: "close-modal",
188
+ locatorMethod: GetByMethod.id,
189
+ });
190
+
191
+ // v2
192
+ this.add("main.modal@close").getById("close-modal");
193
+ ```
194
+
195
+ Note: v1 regex IDs use prefix match `*[id^="pattern"]`; v2 regex IDs use substring match `[id*="pattern"]` with CSS escaping. Results may differ for edge cases.
196
+
197
+ ### dataCy (removed in v2)
198
+
199
+ ```ts
200
+ // v1
201
+ this.locators.addSchema("main.widget", {
202
+ dataCy: "my-widget",
203
+ locatorMethod: GetByMethod.dataCy,
204
+ });
205
+
206
+ // v2 (translate to CSS attribute selector)
207
+ this.add("main.widget").locator('[data-cy="my-widget"]');
208
+ ```
209
+
210
+ v2 does not include a built-in `data-cy` selector engine. Use `locator()` with a CSS attribute selector, or register a custom Playwright selector engine in your test fixtures.
211
+
212
+ ## Filter translation
213
+
214
+ v1 `filter` property on schema -> v2 chained `.filter()` call:
215
+
216
+ ```ts
217
+ // v1
218
+ this.locators.addSchema("body.section@playground", {
219
+ locator: "section",
220
+ locatorMethod: GetByMethod.locator,
221
+ filter: { hasText: /Playground/i },
222
+ });
223
+
224
+ // v2
225
+ this.add("body.section@playground").locator("section").filter({ hasText: /Playground/i });
226
+
227
+ // OR, if the filter only has hasText/hasNotText, use locator options instead:
228
+ this.add("body.section@playground").locator("section", { hasText: /Playground/i });
229
+ ```
230
+
231
+ v2 `filter` accepts `has`/`hasNot` as **registry path strings or Playwright Locator instances**. Path strings are often preferred for registry-native references:
232
+
233
+ ```ts
234
+ // v2 filter with has/hasNot as path references
235
+ this.add("main.card").locator(".card").filter({
236
+ has: "main.card.badge", // path string, not Locator
237
+ hasText: "Active",
238
+ });
239
+ ```
240
+
241
+ ## Registration with filter + nth at definition time
242
+
243
+ ```ts
244
+ // v2 supports chaining filter and nth on add():
245
+ this.add("one.two").locator("div.two").filter({ hasText: "two" }).nth(0);
246
+ ```
247
+
248
+ This registers the locator with a filter step and an nth step baked into the schema.
249
+
250
+ ## Reusable locator patterns
251
+
252
+ ### v1: LocatorSchemaWithoutPath + spread
253
+
254
+ ```ts
255
+ const buttonSchema: LocatorSchemaWithoutPath = {
256
+ role: "button",
257
+ locatorMethod: GetByMethod.role,
258
+ };
259
+
260
+ this.locators.addSchema("main.button@submit", { ...buttonSchema, roleOptions: { name: "Submit" } });
261
+ this.locators.addSchema("main.button@cancel", { ...buttonSchema, roleOptions: { name: "Cancel" } });
262
+ ```
263
+
264
+ ### v2: createReusable
265
+
266
+ ```ts
267
+ // createReusable is available on the LocatorRegistry
268
+ const button = this.locatorRegistry.createReusable.getByRole("button");
269
+
270
+ // Reuse with PATCH-style override (only provide options to merge)
271
+ this.add("main.button@submit", { reuse: button }).getByRole({ name: "Submit" });
272
+ this.add("main.button@cancel", { reuse: button }).getByRole({ name: "Cancel" });
273
+ ```
274
+
275
+ ### v2: reuse by path reference
276
+
277
+ ```ts
278
+ // Register the first path normally
279
+ this.add("main.button@submit").getByRole("button", { name: "Submit" });
280
+
281
+ // Clone its definition to another path (exact clone, no override chaining)
282
+ this.add("main.button@cancel", { reuse: "main.button@submit" });
283
+ ```
284
+
285
+ `add(path, { reuse: "existing.path" })` clones the existing schema and returns `void`, so it cannot be chained with additional overrides.
286
+
287
+ ### v2: createReusable with steps
288
+
289
+ ```ts
290
+ const activeItem = this.locatorRegistry.createReusable
291
+ .locator(".item")
292
+ .filter({ hasText: "Active" });
293
+
294
+ this.add("main.list.item@active", { reuse: activeItem });
295
+ ```
296
+
297
+ ## Programmatic registration with loops
298
+
299
+ v2 supports template literal types and programmatic registration for DRY code:
300
+
301
+ ```ts
302
+ const tableVariants = ["body.table", "body.table@hexCode"] as const;
303
+
304
+ type TableVariants = (typeof tableVariants)[number];
305
+ type TableChildren = "row" | "row.rowheader" | "row.cell";
306
+
307
+ export type Paths = "body" | "body.heading" | TableVariants | `${TableVariants}.${TableChildren}`;
308
+
309
+ export function defineLocators(registry: LocatorRegistry<Paths>) {
310
+ registry.add("body").locator("body");
311
+ registry.add("body.heading").getByRole("heading", { name: "Your Random Color is:" });
312
+
313
+ for (const variant of tableVariants) {
314
+ registry.add(variant).getByRole("table");
315
+ registry.add(`${variant}.row`).getByRole("row");
316
+ registry.add(`${variant}.row.rowheader`).getByRole("rowheader");
317
+ registry.add(`${variant}.row.cell`).getByRole("cell");
318
+ }
319
+ }
320
+ ```
321
+
322
+ ## Path validation rules
323
+
324
+ v2 enforces stricter path validation than v1:
325
+
326
+ | Rule | v1 | v2 |
327
+ |---|---|---|
328
+ | Leading/trailing dots | Allowed | Rejected |
329
+ | Whitespace in paths | Allowed | Rejected |
330
+ | Empty path segments (`a..b`) | Allowed | Rejected |
331
+ | Duplicate registration | Overwrites silently | Throws error |
332
+ | Missing parent path | Allowed | Registration does not require parent-first order; chain semantics depend on registered segments |
333
+
334
+ Parent-first registration is recommended for readability and predictable chain behavior: `"main"` before `"main.form"` before `"main.form.input"`.
335
+
336
+ ## describe() at registration time
337
+
338
+ ```ts
339
+ this.add("main.button@submit")
340
+ .getByRole("button", { name: "Submit" })
341
+ .describe("The main submit button in the login form");
342
+ ```
343
+
344
+ Adds a descriptive label to the locator (uses Playwright's `locator.describe()`).
@@ -0,0 +1,23 @@
1
+ import { test as base } from "@playwright/test";
2
+ import { type LogEntry, type LogLevel, PlaywrightReportLogger } from "../helpers/playwrightReportLogger";
3
+
4
+ type BaseFixtures = {
5
+ log: PlaywrightReportLogger;
6
+ };
7
+
8
+ export const test = base.extend<BaseFixtures>({
9
+ // biome-ignore lint/correctness/noEmptyPattern: Playwright does not support the use of _
10
+ log: async ({}, use, testInfo) => {
11
+ const contextName = "TestCase";
12
+ const sharedLogEntry: LogEntry[] = [];
13
+
14
+ const sharedLogLevel: { current: LogLevel; initial: LogLevel } =
15
+ testInfo.retry === 0 ? { current: "warn", initial: "warn" } : { current: "debug", initial: "debug" };
16
+
17
+ const log = new PlaywrightReportLogger(sharedLogLevel, sharedLogEntry, contextName);
18
+
19
+ await use(log);
20
+
21
+ log.attachLogsToTest(testInfo);
22
+ },
23
+ });
@@ -0,0 +1,153 @@
1
+ import { expect, type Page, test } from "@playwright/test";
2
+
3
+ type WaitUntil = NonNullable<Parameters<Page["goto"]>[1]>["waitUntil"];
4
+ type State = NonNullable<Parameters<Page["waitForLoadState"]>[0]>;
5
+
6
+ const DEFAULT_WAIT_UNTIL: WaitUntil = "load";
7
+ const DEFAULT_LOAD_STATE: State = "load";
8
+
9
+ export type NavigationOptions = {
10
+ waitUntil?: WaitUntil;
11
+ waitForLoadState?: State;
12
+ };
13
+
14
+ export interface NavigationString {
15
+ goto(urlPathOrUrl: string, options?: NavigationOptions): Promise<void>;
16
+ gotoThisPage(options?: NavigationOptions): Promise<void>;
17
+ expectThisPage(options?: NavigationOptions): Promise<void>;
18
+ expectAnotherPage(options?: NavigationOptions): Promise<void>;
19
+ }
20
+
21
+ export interface NavigationRegExp {
22
+ expectThisPage(options?: NavigationOptions): Promise<void>;
23
+ expectAnotherPage(options?: NavigationOptions): Promise<void>;
24
+ }
25
+
26
+ export type ExtractNavigationType<FullUrlType> = FullUrlType extends RegExp ? NavigationRegExp : NavigationString;
27
+
28
+ class Navigation {
29
+ private pageActionsToPerform: (() => Promise<void>)[];
30
+ private defaultOptions?: NavigationOptions;
31
+
32
+ constructor(
33
+ private page: Page,
34
+ private baseUrl: string | RegExp,
35
+ private urlPath: string | RegExp,
36
+ private fullUrl: string | RegExp,
37
+ private label: string,
38
+ actions: (() => Promise<void>)[] | null = null,
39
+ defaultOptions?: NavigationOptions,
40
+ ) {
41
+ this.pageActionsToPerform = actions ?? [];
42
+ this.defaultOptions = defaultOptions;
43
+ }
44
+
45
+ private resolveWaitUntil(options?: NavigationOptions) {
46
+ return options?.waitUntil ?? this.defaultOptions?.waitUntil ?? DEFAULT_WAIT_UNTIL;
47
+ }
48
+
49
+ private resolveWaitForLoadState(options?: NavigationOptions) {
50
+ return options?.waitForLoadState ?? this.defaultOptions?.waitForLoadState ?? DEFAULT_LOAD_STATE;
51
+ }
52
+
53
+ private async executeActions() {
54
+ for (const action of this.pageActionsToPerform) {
55
+ await action();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Navigate to a provided URL or URL path. If the input starts with "/", the POC's baseUrl is used as a prefix.
61
+ * Available only when baseUrl and urlPath are strings.
62
+ */
63
+ public async goto(urlPathOrUrl: string, options?: NavigationOptions) {
64
+ const waitUntil = this.resolveWaitUntil(options);
65
+ if (typeof this.baseUrl !== "string" || typeof this.urlPath !== "string") {
66
+ throw new Error("goto() is not supported when baseUrl or urlPath is a RegExp.");
67
+ }
68
+
69
+ await test.step(`${this.label}: Navigate to the provided URL or URL Path`, async () => {
70
+ const targetUrl = urlPathOrUrl.startsWith("/") ? `${this.baseUrl}${urlPathOrUrl}` : urlPathOrUrl;
71
+ await this.page.goto(targetUrl, { waitUntil });
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Navigate to this page's fullUrl and run any post-navigation actions.
77
+ * Available only when fullUrl is a string.
78
+ */
79
+ public async gotoThisPage(options?: NavigationOptions) {
80
+ if (typeof this.fullUrl !== "string") {
81
+ throw new Error("gotoThisPage() is not supported when fullUrl is a RegExp.");
82
+ }
83
+ const waitUntil = this.resolveWaitUntil(options);
84
+ const fullUrl = this.fullUrl;
85
+
86
+ await test.step(`${this.label}: Navigate to this Page`, async () => {
87
+ await this.page.goto(fullUrl, { waitUntil });
88
+ await this.executeActions();
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Expect to be on this page. Works with both string and RegExp fullUrl values.
94
+ * Uses waitUntil from navigation options when waiting for URL.
95
+ */
96
+ public async expectThisPage(options?: NavigationOptions) {
97
+ const waitUntil = this.resolveWaitUntil(options);
98
+ await test.step(`${this.label}: Expect this Page`, async () => {
99
+ await this.page.waitForURL(this.fullUrl, { waitUntil });
100
+
101
+ await expect(async () => {
102
+ if (this.fullUrl instanceof RegExp) {
103
+ expect(this.page.url(), `expected '${this.fullUrl}', found '${this.page.url()}'`).toMatch(this.fullUrl);
104
+ } else {
105
+ expect(this.page.url(), `expected '${this.fullUrl}', found '${this.page.url()}'`).toBe(this.fullUrl);
106
+ }
107
+ }).toPass();
108
+
109
+ await this.executeActions();
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Expect to be on any other page (i.e. not this page).
115
+ * Uses waitForLoadState from navigation options before validating URL.
116
+ */
117
+ public async expectAnotherPage(options?: NavigationOptions) {
118
+ const waitForLoadState = this.resolveWaitForLoadState(options);
119
+ await test.step(`${this.label}: Expect any other Page`, async () => {
120
+ await this.page.waitForLoadState(waitForLoadState);
121
+
122
+ if (this.fullUrl instanceof RegExp) {
123
+ await expect
124
+ .poll(async () => this.page.url(), {
125
+ message: `expected url to not match '${this.fullUrl}'`,
126
+ })
127
+ .not.toMatch(this.fullUrl);
128
+ } else {
129
+ await expect
130
+ .poll(async () => this.page.url(), {
131
+ message: `expected url to not be '${this.fullUrl}', found '${this.page.url()}'`,
132
+ })
133
+ .not.toBe(this.fullUrl);
134
+ }
135
+ });
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Factory to create a navigation helper. The returned type is narrowed based on the fullUrl type.
141
+ */
142
+ export function createNavigation<FullUrlType extends string | RegExp>(
143
+ page: Page,
144
+ baseUrl: string | RegExp,
145
+ urlPath: string | RegExp,
146
+ fullUrl: FullUrlType,
147
+ label: string,
148
+ actions: (() => Promise<void>)[] | null = null,
149
+ defaultOptions?: NavigationOptions,
150
+ ): ExtractNavigationType<FullUrlType> {
151
+ const navigation = new Navigation(page, baseUrl, urlPath, fullUrl, label, actions, defaultOptions);
152
+ return navigation as ExtractNavigationType<FullUrlType>;
153
+ }