pw-core 0.0.5 → 1.2.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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ A developer-first framework layer built on top of Playwright for creating readab
9
9
  ## Documentation
10
10
 
11
11
  - **Online Documentation**: [qecore.github.io/pw-core](https://qecore.github.io/pw-core)
12
- - **Release Guide**: [releases/v1.0.0.md](./releases/v1.0.0.md)
12
+ - **Release Guide**: [releases/v1.1.md](./releases/v1.1.md)
13
13
 
14
14
  ---
15
15
 
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Table = exports.TableRows = void 0;
4
4
  const test_1 = require("@playwright/test");
5
+ const caller_location_1 = require("../page/utils/caller-location");
5
6
  /**
6
7
  * A custom Array subclass representing a list of table rows.
7
8
  * Inherits all standard Array methods while adding type-safe get and getAll capabilities.
@@ -43,7 +44,7 @@ class Table {
43
44
  return test_1.test.step(`Get headers of table`, async () => {
44
45
  const headers = await this.root.locator('th').evaluateAll(ths => ths.map(th => th.textContent?.trim().toLowerCase() || ''));
45
46
  return headers;
46
- });
47
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
47
48
  }
48
49
  /**
49
50
  * Retrieves all the rows of the table as typed objects.
@@ -67,7 +68,7 @@ class Table {
67
68
  });
68
69
  }, headers);
69
70
  return rowsData.filter((r) => r !== null);
70
- });
71
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
71
72
  }
72
73
  /**
73
74
  * Retrieves all rows from the table, returned as a custom TableRows collection
@@ -85,7 +86,7 @@ class Table {
85
86
  return await this.root.locator('tbody tr, tr[data-testid="transaction-row"]').evaluateAll(trs => {
86
87
  return trs.filter(tr => tr.querySelectorAll('td').length > 0).length;
87
88
  });
88
- });
89
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
89
90
  }
90
91
  /**
91
92
  * Retrieves the value of a specific cell by row index and column key.
@@ -106,7 +107,7 @@ class Table {
106
107
  const cell = dataRows[rowIndex].querySelectorAll('td')[columnIndex];
107
108
  return cell?.textContent?.trim() ?? '';
108
109
  }, { rowIndex, columnIndex });
109
- });
110
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
110
111
  }
111
112
  }
112
113
  exports.Table = Table;
package/dist/helpers.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Page } from '@playwright/test';
1
+ import { Page } from '@playwright/test';
2
2
  /** Read a value from the browser's localStorage. Returns null if the key is absent. */
3
3
  export declare function getLocalStorage(page: Page, key: string): Promise<string | null>;
4
4
  /** Write a value to the browser's localStorage. */
package/dist/helpers.js CHANGED
@@ -5,21 +5,31 @@ exports.setLocalStorage = setLocalStorage;
5
5
  exports.getSessionStorage = getSessionStorage;
6
6
  exports.setSessionStorage = setSessionStorage;
7
7
  exports.seedSessionStorage = seedSessionStorage;
8
+ const test_1 = require("@playwright/test");
9
+ const caller_location_1 = require("./page/utils/caller-location");
8
10
  /** Read a value from the browser's localStorage. Returns null if the key is absent. */
9
11
  async function getLocalStorage(page, key) {
10
- return page.evaluate((k) => localStorage.getItem(k), key);
12
+ return test_1.test.step(`Get localStorage "${key}"`, async () => {
13
+ return page.evaluate((k) => localStorage.getItem(k), key);
14
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
11
15
  }
12
16
  /** Write a value to the browser's localStorage. */
13
17
  async function setLocalStorage(page, key, value) {
14
- await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
18
+ await test_1.test.step(`Set localStorage "${key}"`, async () => {
19
+ await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
20
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
15
21
  }
16
22
  /** Read a value from the browser's sessionStorage. Returns null if the key is absent. */
17
23
  async function getSessionStorage(page, key) {
18
- return page.evaluate((k) => sessionStorage.getItem(k), key);
24
+ return test_1.test.step(`Get sessionStorage "${key}"`, async () => {
25
+ return page.evaluate((k) => sessionStorage.getItem(k), key);
26
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
19
27
  }
20
28
  /** Write a value to the browser's sessionStorage. */
21
29
  async function setSessionStorage(page, key, value) {
22
- await page.evaluate(([k, v]) => sessionStorage.setItem(k, v), [key, value]);
30
+ await test_1.test.step(`Set sessionStorage "${key}"`, async () => {
31
+ await page.evaluate(([k, v]) => sessionStorage.setItem(k, v), [key, value]);
32
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
23
33
  }
24
34
  /**
25
35
  * Seeds sessionStorage entries via addInitScript before any navigation.
@@ -30,10 +40,12 @@ async function seedSessionStorage(page, entries) {
30
40
  const cookies = await page.context().cookies();
31
41
  if (cookies.length > 0) {
32
42
  const pairs = Object.entries(entries);
33
- await page.addInitScript((items) => {
34
- for (const [key, value] of items) {
35
- sessionStorage.setItem(key, value);
36
- }
37
- }, pairs);
43
+ await test_1.test.step('Seed sessionStorage', async () => {
44
+ await page.addInitScript((items) => {
45
+ for (const [key, value] of items) {
46
+ sessionStorage.setItem(key, value);
47
+ }
48
+ }, pairs);
49
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
38
50
  }
39
51
  }
@@ -5,6 +5,7 @@ exports.defineActionMethods = defineActionMethods;
5
5
  const test_1 = require("@playwright/test");
6
6
  const config_1 = require("../config");
7
7
  const formatter_1 = require("../utils/formatter");
8
+ const caller_location_1 = require("../utils/caller-location");
8
9
  function executeAction(prop, resolveLocatorFn, timeout, args) {
9
10
  const [locatorKey, ...methodArgs] = args;
10
11
  let optNth = undefined;
@@ -47,9 +48,41 @@ function executeAction(prop, resolveLocatorFn, timeout, args) {
47
48
  methodArgs[1] = playwrightOpts;
48
49
  }
49
50
  }
51
+ let shouldMask = false;
52
+ if (prop === 'fill' && methodArgs.length > 0) {
53
+ const opts = methodArgs[1];
54
+ if (opts && typeof opts === 'object' && opts.mask !== undefined) {
55
+ shouldMask = opts.mask === true;
56
+ }
57
+ else {
58
+ const targetStr = (0, formatter_1.formatTarget)(locatorKey);
59
+ const targetStrLower = targetStr.toLowerCase();
60
+ shouldMask = targetStrLower.includes('pass') || targetStrLower.includes('pw');
61
+ }
62
+ }
63
+ if (prop === 'fill' && shouldMask) {
64
+ return test_1.test.step(stepName, async () => {
65
+ await locator.focus();
66
+ await locator.evaluate((el, val) => {
67
+ const inputEl = el;
68
+ const prototype = el.tagName === 'TEXTAREA'
69
+ ? window.HTMLTextAreaElement.prototype
70
+ : window.HTMLInputElement.prototype;
71
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
72
+ if (descriptor && descriptor.set) {
73
+ descriptor.set.call(inputEl, val);
74
+ }
75
+ else {
76
+ inputEl.value = val;
77
+ }
78
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
79
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
80
+ }, methodArgs[0]);
81
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
82
+ }
50
83
  return test_1.test.step(stepName, () => {
51
84
  return method.apply(locator, methodArgs);
52
- });
85
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
53
86
  }
54
87
  function defineActionMethods(instance, resolveLocatorFn, timeout) {
55
88
  const locatorMethods = [...config_1.zeroArgMethodsList, ...config_1.oneArgMethodsList];
@@ -6,34 +6,39 @@ type GotoOptions = {
6
6
  };
7
7
  type ToHaveURLOptions = Parameters<ReturnType<typeof playwrightExpect<Page>>['toHaveURL']>[1];
8
8
  type ToHaveTitleOptions = Parameters<ReturnType<typeof playwrightExpect<Page>>['toHaveTitle']>[1];
9
+ type StepLocation = {
10
+ file: string;
11
+ line: number;
12
+ column: number;
13
+ };
9
14
  /**
10
15
  * Navigate to the page URL defined in the page config.
11
16
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-goto Page.goto} in a test step.
12
17
  */
13
- export declare function goto(page: Page, url: string | undefined, constructorName: string, options?: GotoOptions): Promise<void>;
18
+ export declare function goto(page: Page, url: string | undefined, constructorName: string, options?: GotoOptions, location?: StepLocation): Promise<void>;
14
19
  /**
15
20
  * Assert the current URL matches the page config URL or a custom pattern.
16
21
  * Uses {@link https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url expect(page).toHaveURL}.
17
22
  */
18
- export declare function verifyURL(page: Page, url: string | undefined, constructorName: string, urlOrOptions?: string | RegExp | ToHaveURLOptions, options?: ToHaveURLOptions): Promise<void>;
23
+ export declare function verifyURL(page: Page, url: string | undefined, constructorName: string, urlOrOptions?: string | RegExp | ToHaveURLOptions, options?: ToHaveURLOptions, location?: StepLocation): Promise<void>;
19
24
  /**
20
25
  * Assert the page title matches the expected value.
21
26
  * Uses {@link https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-title expect(page).toHaveTitle}.
22
27
  */
23
- export declare function verifyTitle(page: Page, title: string | RegExp, options?: ToHaveTitleOptions): Promise<void>;
28
+ export declare function verifyTitle(page: Page, title: string | RegExp, options?: ToHaveTitleOptions, location?: StepLocation): Promise<void>;
24
29
  /**
25
30
  * Reload the page.
26
31
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-reload Page.reload} in a test step.
27
32
  */
28
- export declare function reload(page: Page, options?: Parameters<Page['reload']>[0]): Promise<void>;
33
+ export declare function reload(page: Page, options?: Parameters<Page['reload']>[0], location?: StepLocation): Promise<void>;
29
34
  /**
30
35
  * Wait for the page to reach a load state.
31
36
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-wait-for-load-state Page.waitForLoadState} in a test step.
32
37
  */
33
- export declare function waitForLoadState(page: Page, state?: Parameters<Page['waitForLoadState']>[0], options?: Parameters<Page['waitForLoadState']>[1]): Promise<void>;
38
+ export declare function waitForLoadState(page: Page, state?: Parameters<Page['waitForLoadState']>[0], options?: Parameters<Page['waitForLoadState']>[1], location?: StepLocation): Promise<void>;
34
39
  /**
35
40
  * Wait for navigation to a URL. Defaults to the page config URL when no pattern is given.
36
41
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-wait-for-url Page.waitForURL} in a test step.
37
42
  */
38
- export declare function waitForURL(page: Page, url: string | undefined, constructorName: string, urlOrOptions?: string | RegExp | Parameters<Page['waitForURL']>[1], options?: Parameters<Page['waitForURL']>[1]): Promise<void>;
43
+ export declare function waitForURL(page: Page, url: string | undefined, constructorName: string, urlOrOptions?: string | RegExp | Parameters<Page['waitForURL']>[1], options?: Parameters<Page['waitForURL']>[1], location?: StepLocation): Promise<void>;
39
44
  export {};
@@ -33,59 +33,59 @@ function resolveUrlPattern(url, constructorName, urlOrOptions, options) {
33
33
  * Navigate to the page URL defined in the page config.
34
34
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-goto Page.goto} in a test step.
35
35
  */
36
- async function goto(page, url, constructorName, options) {
36
+ async function goto(page, url, constructorName, options, location) {
37
37
  await test_1.test.step(`Goto "${url || ''}"`, async () => {
38
38
  if (!url) {
39
39
  throw new Error(`URL is not defined on ${constructorName}`);
40
40
  }
41
41
  await page.goto(url, options);
42
- });
42
+ }, { box: true, location });
43
43
  }
44
44
  /**
45
45
  * Assert the current URL matches the page config URL or a custom pattern.
46
46
  * Uses {@link https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url expect(page).toHaveURL}.
47
47
  */
48
- async function verifyURL(page, url, constructorName, urlOrOptions, options) {
48
+ async function verifyURL(page, url, constructorName, urlOrOptions, options, location) {
49
49
  const { targetUrl, actualOptions } = resolveUrlPattern(url, constructorName, urlOrOptions, options);
50
50
  const stepName = `Verify URL matches "${targetUrl.toString()}"`;
51
51
  await test_1.test.step(stepName, async () => {
52
52
  await (0, test_1.expect)(page).toHaveURL(targetUrl, actualOptions);
53
- });
53
+ }, { box: true, location });
54
54
  }
55
55
  /**
56
56
  * Assert the page title matches the expected value.
57
57
  * Uses {@link https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-title expect(page).toHaveTitle}.
58
58
  */
59
- async function verifyTitle(page, title, options) {
59
+ async function verifyTitle(page, title, options, location) {
60
60
  const stepName = `Verify title matches "${title}"`;
61
61
  await test_1.test.step(stepName, async () => {
62
62
  await (0, test_1.expect)(page).toHaveTitle(title, options);
63
- });
63
+ }, { box: true, location });
64
64
  }
65
65
  /**
66
66
  * Reload the page.
67
67
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-reload Page.reload} in a test step.
68
68
  */
69
- async function reload(page, options) {
69
+ async function reload(page, options, location) {
70
70
  await test_1.test.step('Reload page', async () => {
71
71
  await page.reload(options);
72
- });
72
+ }, { box: true, location });
73
73
  }
74
74
  /**
75
75
  * Wait for the page to reach a load state.
76
76
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-wait-for-load-state Page.waitForLoadState} in a test step.
77
77
  */
78
- async function waitForLoadState(page, state, options) {
78
+ async function waitForLoadState(page, state, options, location) {
79
79
  const stepName = `Wait for load state "${state ?? 'load'}"`;
80
80
  await test_1.test.step(stepName, async () => {
81
81
  await page.waitForLoadState(state, options);
82
- });
82
+ }, { box: true, location });
83
83
  }
84
84
  /**
85
85
  * Wait for navigation to a URL. Defaults to the page config URL when no pattern is given.
86
86
  * Wraps {@link https://playwright.dev/docs/api/class-page#page-wait-for-url Page.waitForURL} in a test step.
87
87
  */
88
- async function waitForURL(page, url, constructorName, urlOrOptions, options) {
88
+ async function waitForURL(page, url, constructorName, urlOrOptions, options, location) {
89
89
  let targetUrl;
90
90
  let actualOptions = options;
91
91
  if (urlOrOptions !== undefined && (typeof urlOrOptions === 'string' || urlOrOptions instanceof RegExp)) {
@@ -103,5 +103,5 @@ async function waitForURL(page, url, constructorName, urlOrOptions, options) {
103
103
  const stepName = `Wait for URL "${targetUrl.toString()}"`;
104
104
  await test_1.test.step(stepName, async () => {
105
105
  await page.waitForURL(targetUrl, actualOptions);
106
- });
106
+ }, { box: true, location });
107
107
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createVerifyChain = createVerifyChain;
4
4
  const test_1 = require("@playwright/test");
5
5
  const formatter_1 = require("../utils/formatter");
6
+ const caller_location_1 = require("../utils/caller-location");
6
7
  function createVerifyChain(resolveLocator, target, verifyOptions, isSoft) {
7
8
  const defaultNth = verifyOptions?.nth;
8
9
  const defaultHasText = verifyOptions?.hasText;
@@ -18,7 +19,7 @@ function createVerifyChain(resolveLocator, target, verifyOptions, isSoft) {
18
19
  const expectation = expectFn(locator, stepName);
19
20
  const match = isNegated ? expectation.not : expectation;
20
21
  await match.toBeVisible(options);
21
- });
22
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
22
23
  };
23
24
  return new Proxy(baseFn, {
24
25
  get(targetObj, prop) {
@@ -53,7 +54,7 @@ function createVerifyChain(resolveLocator, target, verifyOptions, isSoft) {
53
54
  const expectation = expectFn(locator, stepName);
54
55
  const match = isNegated ? expectation.not : expectation;
55
56
  await match[prop](...args);
56
- });
57
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
57
58
  };
58
59
  }
59
60
  });
@@ -1,4 +1,9 @@
1
1
  import { Locator, expect as playwrightExpect } from '@playwright/test';
2
+ type StepLocation = {
3
+ file: string;
4
+ line: number;
5
+ column: number;
6
+ };
2
7
  export declare function verifyHidden(resolveLocator: (target: any, options?: {
3
8
  nth?: number;
4
9
  hasText?: string | RegExp;
@@ -7,7 +12,7 @@ export declare function verifyHidden(resolveLocator: (target: any, options?: {
7
12
  nth?: number;
8
13
  hasText?: string | RegExp;
9
14
  message?: string;
10
- }): Promise<void>;
15
+ }, location?: StepLocation): Promise<void>;
11
16
  export declare function verifyEnabled(resolveLocator: (target: any, options?: {
12
17
  nth?: number;
13
18
  hasText?: string | RegExp;
@@ -16,7 +21,7 @@ export declare function verifyEnabled(resolveLocator: (target: any, options?: {
16
21
  nth?: number;
17
22
  hasText?: string | RegExp;
18
23
  message?: string;
19
- }): Promise<void>;
24
+ }, location?: StepLocation): Promise<void>;
20
25
  export declare function verifyDisabled(resolveLocator: (target: any, options?: {
21
26
  nth?: number;
22
27
  hasText?: string | RegExp;
@@ -25,4 +30,5 @@ export declare function verifyDisabled(resolveLocator: (target: any, options?: {
25
30
  nth?: number;
26
31
  hasText?: string | RegExp;
27
32
  message?: string;
28
- }): Promise<void>;
33
+ }, location?: StepLocation): Promise<void>;
34
+ export {};
@@ -5,24 +5,24 @@ exports.verifyEnabled = verifyEnabled;
5
5
  exports.verifyDisabled = verifyDisabled;
6
6
  const test_1 = require("@playwright/test");
7
7
  const formatter_1 = require("../utils/formatter");
8
- async function verifyHidden(resolveLocator, target, options) {
8
+ async function verifyHidden(resolveLocator, target, options, location) {
9
9
  const stepName = options?.message ?? (0, formatter_1.formatAssertionDescription)(target, 'toBeHidden', false, [options]);
10
10
  await test_1.test.step(stepName, async () => {
11
11
  const locator = resolveLocator(target, { nth: options?.nth, hasText: options?.hasText });
12
12
  await (0, test_1.expect)(locator, stepName).toBeHidden(options);
13
- });
13
+ }, { box: true, location });
14
14
  }
15
- async function verifyEnabled(resolveLocator, target, options) {
15
+ async function verifyEnabled(resolveLocator, target, options, location) {
16
16
  const stepName = options?.message ?? (0, formatter_1.formatAssertionDescription)(target, 'toBeEnabled', false, [options]);
17
17
  await test_1.test.step(stepName, async () => {
18
18
  const locator = resolveLocator(target, { nth: options?.nth, hasText: options?.hasText });
19
19
  await (0, test_1.expect)(locator, stepName).toBeEnabled(options);
20
- });
20
+ }, { box: true, location });
21
21
  }
22
- async function verifyDisabled(resolveLocator, target, options) {
22
+ async function verifyDisabled(resolveLocator, target, options, location) {
23
23
  const stepName = options?.message ?? (0, formatter_1.formatAssertionDescription)(target, 'toBeDisabled', false, [options]);
24
24
  await test_1.test.step(stepName, async () => {
25
25
  const locator = resolveLocator(target, { nth: options?.nth, hasText: options?.hasText });
26
26
  await (0, test_1.expect)(locator, stepName).toBeDisabled(options);
27
- });
27
+ }, { box: true, location });
28
28
  }
@@ -1,15 +1,52 @@
1
1
  import { Locator, Page } from '@playwright/test';
2
- export { ProxyLocatorMethods } from './types/proxy-methods';
2
+ import type { DynamicTestIdEntry, DynamicSelectorEntry } from './locators/dynamic-locator-resolver.js';
3
+ export { ProxyLocatorMethods } from './types/proxy-methods.js';
4
+ export type { DynamicLocatorEntry, DynamicTestIdEntry, DynamicSelectorEntry } from './locators/dynamic-locator-resolver.js';
3
5
  export declare const zeroArgMethodsList: readonly ["click", "dblclick", "hover", "focus", "blur", "check", "uncheck", "clear", "waitFor", "isChecked", "isDisabled", "isVisible", "textContent", "innerText", "allInnerTexts", "allTextContents", "count", "scrollIntoViewIfNeeded", "boundingBox"];
4
6
  export declare const oneArgMethodsList: readonly ["fill", "press", "pressSequentially", "selectOption", "setInputFiles", "getAttribute", "dragTo"];
5
7
  export type AllowedZeroArgMethods = typeof zeroArgMethodsList[number];
6
8
  export type AllowedOneArgMethods = typeof oneArgMethodsList[number];
7
9
  export type AllowedMethodKeys = AllowedZeroArgMethods | AllowedOneArgMethods;
10
+ import { ValidateDynamicEntryProperties, HasDuplicatePlaceholders } from './types/validation.js';
11
+ /**
12
+ * Replaces every `{Name}` placeholder in `Pattern` with `Capitalize<Value>`
13
+ * where `Value` is drawn from the corresponding array in `Entry`.
14
+ * Template-literal unions distribute automatically, producing the full cartesian product.
15
+ */
16
+ type ReplacePattern<Pattern extends string, Entry> = Pattern extends `${infer Before}{${infer Name}}${infer After}` ? Name extends keyof Entry ? Entry[Name] extends readonly string[] ? `${Before}${Capitalize<Entry[Name][number]>}${ReplacePattern<After, Entry> & string}` : never : never : Pattern;
17
+ /**
18
+ * Expands a dynamic key pattern into the union of all concrete (camelCased) keys.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * type Keys = ExpandDynamicKey<
23
+ * "{status}{id}Chart",
24
+ * { status: readonly ['active', 'inactive']; id: readonly ['line', 'bar']; testId: "status-id-chart" }
25
+ * >;
26
+ * // => "activeLineChart" | "activeBarChart" | "inactiveLineChart" | "inactiveBarChart"
27
+ * ```
28
+ */
29
+ type ExpandDynamicKey<Pattern extends string, Entry> = Uncapitalize<ReplacePattern<Pattern, Entry>>;
30
+ /**
31
+ * Resolves every key in a testIds record:
32
+ * - string values → the key itself (static locator)
33
+ * - object values → the expanded dynamic key union
34
+ */
35
+ type ResolvedTestIdKeys<I> = {
36
+ [K in keyof I & string]: I[K] extends string ? K : ExpandDynamicKey<K, I[K]>;
37
+ }[keyof I & string];
38
+ /**
39
+ * Validates all entries in a testIds or selectors record.
40
+ * Static (string) entries pass through. Dynamic entries are checked via ValidateDynamicEntryProperties.
41
+ */
42
+ type ValidateTestIds<I, TargetKey extends 'testId' | 'selector'> = {
43
+ [K in keyof I & string]: I[K] extends string ? string : K extends `${string}{${string}}${string}` ? HasDuplicatePlaceholders<K> extends true ? "Error: Pattern contains duplicate placeholders" : ValidateDynamicEntryProperties<K, I[K], TargetKey> : I[K];
44
+ };
8
45
  export type PageKeys<T> = (T extends {
9
46
  testIds?: infer I;
10
- } ? keyof I & string : never) | (T extends {
47
+ } ? I extends Record<string, any> ? ResolvedTestIdKeys<I> : never : never) | (T extends {
11
48
  selectors?: infer S;
12
- } ? keyof S & string : never);
49
+ } ? S extends Record<string, any> ? ResolvedTestIdKeys<S> : never : never);
13
50
  export type TypedLocators<T> = {
14
51
  [K in PageKeys<T>]: Locator;
15
52
  };
@@ -17,16 +54,29 @@ export type ChainedKeys<T> = `${PageKeys<T>}.${PageKeys<T>}`;
17
54
  export type TargetKey<T> = PageKeys<T> | ChainedKeys<T> | Locator;
18
55
  export type ValidateTarget<K, T> = K extends Locator ? Locator : K extends (PageKeys<T> | ChainedKeys<T>) ? K : never;
19
56
  export declare function getOptionsArgumentIndex(methodName: AllowedMethodKeys): number;
20
- export type PageConfig<T = any> = {
57
+ /** Map type accepted by the `testIds` property in a page config. */
58
+ export type TestIdMap = Record<string, string | DynamicTestIdEntry>;
59
+ export type SelectorMap = Record<string, string | DynamicSelectorEntry>;
60
+ export type PageConfig = {
21
61
  url?: string;
22
- testIds?: Record<string, string>;
62
+ testIds?: TestIdMap;
63
+ selectors?: SelectorMap;
64
+ Class?: new (page: Page, config?: any) => any;
65
+ };
66
+ export type ValidatePageConfig<T> = {
67
+ url?: string;
68
+ testIds?: T extends {
69
+ testIds: infer I;
70
+ } ? I extends Record<string, any> ? ValidateTestIds<I, 'testId'> : TestIdMap : TestIdMap;
23
71
  selectors?: T extends {
24
72
  testIds: infer I;
25
73
  selectors: infer S;
26
- } ? {
27
- [K in keyof S]: K extends keyof I ? `Duplicate key: ${K & string} already exists in testIds` : S[K];
28
- } : Record<string, string>;
74
+ } ? S extends Record<string, any> ? {
75
+ [K in keyof S]: K extends keyof I ? `Duplicate key: ${K & string} already exists in testIds` : ValidateTestIds<S, 'selector'>[K & keyof S & string];
76
+ } : SelectorMap : SelectorMap;
29
77
  Class?: new (page: Page, config?: any) => any;
78
+ } & {
79
+ [K in Exclude<keyof T, keyof PageConfig>]: never;
30
80
  };
31
81
  /**
32
82
  * Creates a strongly-typed page configuration object containing URLs, testIds, and CSS selectors.
@@ -48,4 +98,4 @@ export type PageConfig<T = any> = {
48
98
  * });
49
99
  * ```
50
100
  */
51
- export declare function createPageConfig<T extends PageConfig<T>>(config: T): T;
101
+ export declare function createPageConfig<const T extends PageConfig>(config: [T] extends [ValidatePageConfig<T>] ? T : ValidatePageConfig<T>): T;
@@ -5,6 +5,7 @@ export * from './assertions/verify-helpers';
5
5
  export * from './assertions/expect';
6
6
  export * from './actions/page-actions';
7
7
  export * from './actions/locator-actions';
8
+ export * from './locators/dynamic-locator-resolver';
8
9
  export * from './locators/resolver';
9
10
  export * from './typed-page';
10
11
  export * from './registry';
@@ -21,6 +21,7 @@ __exportStar(require("./assertions/verify-helpers"), exports);
21
21
  __exportStar(require("./assertions/expect"), exports);
22
22
  __exportStar(require("./actions/page-actions"), exports);
23
23
  __exportStar(require("./actions/locator-actions"), exports);
24
+ __exportStar(require("./locators/dynamic-locator-resolver"), exports);
24
25
  __exportStar(require("./locators/resolver"), exports);
25
26
  __exportStar(require("./typed-page"), exports);
26
27
  __exportStar(require("./registry"), exports);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Dynamic locator resolver — expands `{placeholder}` patterns in testId keys
3
+ * into all value combinations at runtime.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const testIds = {
8
+ * staticKey: 'static-test-id',
9
+ * "{status}{id}Chart": {
10
+ * id: ['line', 'bar'],
11
+ * status: ['active', 'inactive'],
12
+ * testId: "status-id-chart"
13
+ * }
14
+ * };
15
+ *
16
+ * expandDynamicLocators(testIds);
17
+ * // => {
18
+ * // staticKey: 'static-test-id',
19
+ * // activeLineChart: 'active-line-chart',
20
+ * // activeBarChart: 'active-bar-chart',
21
+ * // inactiveLineChart: 'inactive-line-chart',
22
+ * // inactiveBarChart: 'inactive-bar-chart'
23
+ * // }
24
+ * ```
25
+ */
26
+ /**
27
+ * Shape of a dynamic testId entry.
28
+ *
29
+ * `testId` — the test-id pattern with placeholder *names* as literal substrings.
30
+ * Every other property is a placeholder name mapped to its list of allowed values.
31
+ */
32
+ export type DynamicTestIdEntry = {
33
+ testId?: string;
34
+ [placeholder: string]: string | readonly string[] | undefined;
35
+ };
36
+ export type DynamicSelectorEntry = {
37
+ selector?: string;
38
+ [placeholder: string]: string | readonly string[] | undefined;
39
+ };
40
+ export type DynamicLocatorEntry = DynamicTestIdEntry | DynamicSelectorEntry;
41
+ /** Returns true when `key` contains at least one `{…}` placeholder. */
42
+ export declare function isDynamicKey(key: string): boolean;
43
+ /**
44
+ * Expands all dynamic entries in a `testIds` map into a flat `key → testId` map.
45
+ * Static (string-valued) entries pass through unchanged.
46
+ */
47
+ export declare function expandDynamicLocators(testIds: Record<string, string | DynamicLocatorEntry>): Record<string, string>;
48
+ /** Return the expanded (flat) testIds map, caching the result per config object. */
49
+ export declare function getExpandedTestIds(testIds: Record<string, string | DynamicLocatorEntry>): Record<string, string>;
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ /**
3
+ * Dynamic locator resolver — expands `{placeholder}` patterns in testId keys
4
+ * into all value combinations at runtime.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * const testIds = {
9
+ * staticKey: 'static-test-id',
10
+ * "{status}{id}Chart": {
11
+ * id: ['line', 'bar'],
12
+ * status: ['active', 'inactive'],
13
+ * testId: "status-id-chart"
14
+ * }
15
+ * };
16
+ *
17
+ * expandDynamicLocators(testIds);
18
+ * // => {
19
+ * // staticKey: 'static-test-id',
20
+ * // activeLineChart: 'active-line-chart',
21
+ * // activeBarChart: 'active-bar-chart',
22
+ * // inactiveLineChart: 'inactive-line-chart',
23
+ * // inactiveBarChart: 'inactive-bar-chart'
24
+ * // }
25
+ * ```
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.isDynamicKey = isDynamicKey;
29
+ exports.expandDynamicLocators = expandDynamicLocators;
30
+ exports.getExpandedTestIds = getExpandedTestIds;
31
+ /** Returns true when `key` contains at least one `{…}` placeholder. */
32
+ function isDynamicKey(key) {
33
+ return /\{[^}]+\}/.test(key);
34
+ }
35
+ /**
36
+ * Capitalize the first character of a string.
37
+ * @internal
38
+ */
39
+ function capitalize(value) {
40
+ return value.charAt(0).toUpperCase() + value.slice(1);
41
+ }
42
+ /**
43
+ * Compute the cartesian product of an array-of-arrays.
44
+ *
45
+ * @example cartesianProduct([['a','b'],['1','2']]) // [['a','1'],['a','2'],['b','1'],['b','2']]
46
+ * @internal
47
+ */
48
+ function cartesianProduct(arrays) {
49
+ if (arrays.length === 0)
50
+ return [[]];
51
+ return arrays.reduce((acc, arr) => acc.flatMap(combo => arr.map(val => [...combo, val])), [[]]);
52
+ }
53
+ /**
54
+ * Extract placeholder names from a pattern like `"{status}{id}Chart"`.
55
+ * @internal
56
+ */
57
+ function extractPlaceholders(pattern) {
58
+ const names = [];
59
+ const re = /\{([^}]+)\}/g;
60
+ let m;
61
+ while ((m = re.exec(pattern)) !== null) {
62
+ names.push(m[1]);
63
+ }
64
+ return names;
65
+ }
66
+ /**
67
+ * Expand a single dynamic key pattern + entry into flat key → locator-value pairs.
68
+ * Validates the entry at runtime before expansion.
69
+ * @internal
70
+ */
71
+ function expandSingleDynamic(pattern, entry) {
72
+ const testIdPattern = entry.testId;
73
+ const selectorPattern = entry.selector;
74
+ const targetPattern = (testIdPattern !== undefined ? testIdPattern : selectorPattern);
75
+ const targetKey = testIdPattern !== undefined ? 'testId' : 'selector';
76
+ if (targetPattern === undefined) {
77
+ throw new Error(`Dynamic locator "${pattern}": entry must contain either 'testId' or 'selector' property.`);
78
+ }
79
+ const placeholders = { ...entry };
80
+ delete placeholders.testId;
81
+ delete placeholders.selector;
82
+ // ─── Validation ──────────────────────────────────────────────────────
83
+ const expectedNames = extractPlaceholders(pattern);
84
+ // 0.5. No duplicate placeholders in the pattern
85
+ const uniqueNames = Array.from(new Set(expectedNames));
86
+ if (uniqueNames.length !== expectedNames.length) {
87
+ throw new Error(`Dynamic locator "${pattern}": pattern has duplicate placeholders.`);
88
+ }
89
+ // 1. No unknown keys (only placeholder names + targetKey)
90
+ const actualKeys = Object.keys(placeholders);
91
+ const unknownKeys = actualKeys.filter(k => !expectedNames.includes(k));
92
+ if (unknownKeys.length > 0) {
93
+ throw new Error(`Dynamic locator "${pattern}": unknown key(s) [${unknownKeys.join(', ')}]. ` +
94
+ `Only '${targetKey}' and placeholder keys [${expectedNames.join(', ')}] are allowed.`);
95
+ }
96
+ // 2. All placeholders from the pattern must have a key in the entry
97
+ const missingKeys = expectedNames.filter(name => !(name in placeholders));
98
+ if (missingKeys.length > 0) {
99
+ throw new Error(`Dynamic locator "${pattern}": missing placeholder key(s) [${missingKeys.join(', ')}]. ` +
100
+ `You must provide values for all placeholders defined in the pattern.`);
101
+ }
102
+ // 2.5. Placeholder values must be arrays
103
+ const nonArrayKeys = expectedNames.filter(name => !Array.isArray(placeholders[name]));
104
+ if (nonArrayKeys.length > 0) {
105
+ throw new Error(`Dynamic locator "${pattern}": placeholder key(s) [${nonArrayKeys.join(', ')}] must be arrays.`);
106
+ }
107
+ // 3. All placeholder names must appear as substrings in the target pattern value
108
+ const missingInPattern = expectedNames.filter(name => !targetPattern.includes(name));
109
+ if (missingInPattern.length > 0) {
110
+ throw new Error(`Dynamic locator "${pattern}": ${targetKey} "${targetPattern}" does not contain ` +
111
+ `placeholder name(s) [${missingInPattern.join(', ')}]. ` +
112
+ `All placeholder names must appear in the ${targetKey} so they can be replaced at runtime.`);
113
+ }
114
+ // ─── Expansion ───────────────────────────────────────────────────────
115
+ // Sort placeholder names longest-first to avoid substring replacement issues
116
+ const names = Object.keys(placeholders).sort((a, b) => b.length - a.length);
117
+ const values = names.map(name => {
118
+ const v = placeholders[name];
119
+ return v;
120
+ });
121
+ const result = {};
122
+ for (const combo of cartesianProduct(values)) {
123
+ // Build the *key* — replace {name} with Capitalize(value), then uncapitalize the first char
124
+ let expandedKey = pattern;
125
+ let expandedValue = targetPattern;
126
+ for (let i = 0; i < names.length; i++) {
127
+ const name = names[i];
128
+ const val = combo[i];
129
+ expandedKey = expandedKey.replace(`{${name}}`, capitalize(val));
130
+ expandedValue = expandedValue.replaceAll(name, targetKey === 'selector' ? val : val.toLowerCase());
131
+ }
132
+ // camelCase: uncapitalize the first character
133
+ expandedKey = expandedKey.charAt(0).toLowerCase() + expandedKey.slice(1);
134
+ result[expandedKey] = expandedValue;
135
+ }
136
+ return result;
137
+ }
138
+ /**
139
+ * Expands all dynamic entries in a `testIds` map into a flat `key → testId` map.
140
+ * Static (string-valued) entries pass through unchanged.
141
+ */
142
+ function expandDynamicLocators(testIds) {
143
+ const result = {};
144
+ for (const [key, value] of Object.entries(testIds)) {
145
+ if (typeof value === 'string') {
146
+ result[key] = value;
147
+ }
148
+ else {
149
+ Object.assign(result, expandSingleDynamic(key, value));
150
+ }
151
+ }
152
+ return result;
153
+ }
154
+ /**
155
+ * Cached expansion — avoids re-expanding the same config on every action call.
156
+ * @internal
157
+ */
158
+ const expandedCache = new WeakMap();
159
+ /** Return the expanded (flat) testIds map, caching the result per config object. */
160
+ function getExpandedTestIds(testIds) {
161
+ let expanded = expandedCache.get(testIds);
162
+ if (!expanded) {
163
+ expanded = expandDynamicLocators(testIds);
164
+ expandedCache.set(testIds, expanded);
165
+ }
166
+ return expanded;
167
+ }
@@ -1,12 +1,13 @@
1
1
  import { Page, Locator } from '@playwright/test';
2
- import { ChainedKeys, PageKeys } from '../config';
2
+ import { ChainedKeys, PageKeys, DynamicSelectorEntry } from '../config';
3
+ import type { DynamicLocatorEntry } from './dynamic-locator-resolver';
3
4
  export declare function defineLocators<T extends {
4
- testIds?: Record<string, string>;
5
- selectors?: Record<string, string>;
5
+ testIds?: Record<string, string | DynamicLocatorEntry>;
6
+ selectors?: Record<string, string | DynamicSelectorEntry>;
6
7
  }>(instance: any, context: Page | Locator, config: T): void;
7
8
  export declare function resolveLocator<T extends {
8
- testIds?: Record<string, string>;
9
- selectors?: Record<string, string>;
9
+ testIds?: Record<string, string | DynamicLocatorEntry>;
10
+ selectors?: Record<string, string | DynamicSelectorEntry>;
10
11
  }>(context: Page | Locator, config: T, target: PageKeys<T> | ChainedKeys<T> | Locator, options?: {
11
12
  nth?: number;
12
13
  raw?: boolean;
@@ -16,8 +17,8 @@ export declare function resolveLocator<T extends {
16
17
  * Resolve a config key to a proxied Locator with step-wrapped Playwright methods.
17
18
  */
18
19
  export declare function locator<T extends {
19
- testIds?: Record<string, string>;
20
- selectors?: Record<string, string>;
20
+ testIds?: Record<string, string | DynamicLocatorEntry>;
21
+ selectors?: Record<string, string | DynamicSelectorEntry>;
21
22
  }>(context: Page | Locator, config: T, target: PageKeys<T> | ChainedKeys<T> | Locator, options?: Parameters<Locator['filter']>[0] & {
22
23
  nth?: number;
23
24
  }): Locator;
@@ -7,12 +7,15 @@ exports.wrapLocatorWithProxy = wrapLocatorWithProxy;
7
7
  const test_1 = require("@playwright/test");
8
8
  const config_1 = require("../config");
9
9
  const formatter_1 = require("../utils/formatter");
10
+ const dynamic_locator_resolver_1 = require("./dynamic-locator-resolver");
11
+ const caller_location_1 = require("../utils/caller-location");
10
12
  function defineLocators(instance, context, config) {
11
13
  if (config.testIds) {
12
- for (const key of Object.keys(config.testIds)) {
14
+ const expanded = (0, dynamic_locator_resolver_1.getExpandedTestIds)(config.testIds);
15
+ for (const key of Object.keys(expanded)) {
13
16
  Object.defineProperty(instance, key, {
14
17
  get: () => {
15
- return context.getByTestId(config.testIds[key]);
18
+ return context.getByTestId(expanded[key]);
16
19
  },
17
20
  enumerable: true,
18
21
  configurable: true,
@@ -20,10 +23,11 @@ function defineLocators(instance, context, config) {
20
23
  }
21
24
  }
22
25
  if (config.selectors) {
23
- for (const key of Object.keys(config.selectors)) {
26
+ const expanded = (0, dynamic_locator_resolver_1.getExpandedTestIds)(config.selectors);
27
+ for (const key of Object.keys(expanded)) {
24
28
  Object.defineProperty(instance, key, {
25
29
  get: () => {
26
- return context.locator(config.selectors[key]);
30
+ return context.locator(expanded[key]);
27
31
  },
28
32
  enumerable: true,
29
33
  configurable: true,
@@ -36,12 +40,14 @@ function resolveLocator(context, config, target, options) {
36
40
  return target;
37
41
  const targetStr = target;
38
42
  const parts = targetStr.split('.');
43
+ const expandedTestIds = config.testIds ? (0, dynamic_locator_resolver_1.getExpandedTestIds)(config.testIds) : undefined;
44
+ const expandedSelectors = config.selectors ? (0, dynamic_locator_resolver_1.getExpandedTestIds)(config.selectors) : undefined;
39
45
  const resolveSingle = (key, ctx) => {
40
- if (config.testIds && key in config.testIds) {
41
- return ctx.getByTestId(config.testIds[key]);
46
+ if (expandedTestIds && key in expandedTestIds) {
47
+ return ctx.getByTestId(expandedTestIds[key]);
42
48
  }
43
- if (config.selectors && key in config.selectors) {
44
- return ctx.locator(config.selectors[key]);
49
+ if (expandedSelectors && key in expandedSelectors) {
50
+ return ctx.locator(expandedSelectors[key]);
45
51
  }
46
52
  throw new Error(`Locator key '${key}' is not defined in testIds or selectors.`);
47
53
  };
@@ -78,7 +84,39 @@ function wrapLocatorWithProxy(loc) {
78
84
  if (typeof val === 'function' && actionMethods.includes(propKey)) {
79
85
  return (...args) => {
80
86
  const stepName = (0, formatter_1.formatStepDescription)(propKey, targetLoc, args);
81
- return test_1.test.step(stepName, () => val.apply(targetLoc, args));
87
+ if (propKey === 'fill') {
88
+ const options = args[1];
89
+ let shouldMask = false;
90
+ if (options && typeof options === 'object' && options.mask !== undefined) {
91
+ shouldMask = options.mask === true;
92
+ }
93
+ else {
94
+ const targetStr = (0, formatter_1.formatTarget)(targetLoc);
95
+ const targetStrLower = targetStr.toLowerCase();
96
+ shouldMask = targetStrLower.includes('pass') || targetStrLower.includes('pw');
97
+ }
98
+ if (shouldMask) {
99
+ return test_1.test.step(stepName, async () => {
100
+ await targetLoc.focus();
101
+ await targetLoc.evaluate((el, val) => {
102
+ const inputEl = el;
103
+ const prototype = el.tagName === 'TEXTAREA'
104
+ ? window.HTMLTextAreaElement.prototype
105
+ : window.HTMLInputElement.prototype;
106
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
107
+ if (descriptor && descriptor.set) {
108
+ descriptor.set.call(inputEl, val);
109
+ }
110
+ else {
111
+ inputEl.value = val;
112
+ }
113
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
114
+ inputEl.dispatchEvent(new Event('change', { bubbles: true }));
115
+ }, args[0]);
116
+ }, { box: true, location: (0, caller_location_1.getCallerLocation)() });
117
+ }
118
+ }
119
+ return test_1.test.step(stepName, () => val.apply(targetLoc, args), { box: true, location: (0, caller_location_1.getCallerLocation)() });
82
120
  };
83
121
  }
84
122
  return val;
@@ -1,11 +1,11 @@
1
1
  import test, { Page, TestType } from '@playwright/test';
2
- import { PageConfig } from './config';
2
+ import { PageConfig, ValidatePageConfig } from './config';
3
3
  import { TypedPage } from './typed-page';
4
4
  export type PageRegistry = Record<string, PageConfig>;
5
- export interface PageConstructor<C extends PageConfig<any>> {
5
+ export interface PageConstructor<C extends PageConfig> {
6
6
  new (page: Page): TypedPage<C>;
7
7
  }
8
- export type PageRegistryTest<T extends Record<string, PageConfig<any>>, P, W, O = {}> = TestType<P & {
8
+ export type PageRegistryTest<T extends Record<string, PageConfig>, P, W, O = {}> = TestType<P & {
9
9
  [K in keyof T]: K extends keyof O ? O[K] extends new (page: Page, ...args: any[]) => infer R ? R : NonNullable<T[K]['Class']> extends new (page: Page, ...args: any[]) => infer R2 ? R2 : TypedPage<T[K]> : NonNullable<T[K]['Class']> extends new (page: Page, ...args: any[]) => infer R ? R : TypedPage<T[K]>;
10
10
  }, W & {
11
11
  [K in keyof T as `worker${Capitalize<K & string>}`]: K extends keyof O ? O[K] extends new (page: Page, ...args: any[]) => infer R ? R : NonNullable<T[K]['Class']> extends new (page: Page, ...args: any[]) => infer R2 ? R2 : TypedPage<T[K]> : NonNullable<T[K]['Class']> extends new (page: Page, ...args: any[]) => infer R ? R : TypedPage<T[K]>;
@@ -51,6 +51,8 @@ export type PageRegistryTest<T extends Record<string, PageConfig<any>>, P, W, O
51
51
  * });
52
52
  * ```
53
53
  */
54
- export declare function createPageRegistry<T extends {
55
- [K in keyof T]: PageConfig<T[K]>;
56
- } & Record<string, PageConfig<any>>>(registry: T): typeof test extends TestType<infer P, infer W> ? PageRegistryTest<T, P, W> : never;
54
+ export declare function createPageRegistry<const T extends Record<string, PageConfig>>(registry: [T] extends [{
55
+ [K in keyof T]: ValidatePageConfig<T[K]>;
56
+ }] ? T : {
57
+ [K in keyof T]: ValidatePageConfig<T[K]>;
58
+ }): typeof test extends TestType<infer P, infer W> ? PageRegistryTest<T, P, W> : never;
@@ -1,9 +1,10 @@
1
1
  import { Page, Locator, expect as playwrightExpect } from '@playwright/test';
2
- import { TypedLocators, ProxyLocatorMethods, PageKeys, ChainedKeys } from './config';
2
+ import { TypedLocators, ProxyLocatorMethods, PageKeys, ChainedKeys, DynamicSelectorEntry } from './config';
3
+ import type { DynamicLocatorEntry } from './locators/dynamic-locator-resolver';
3
4
  import { VerifyFn, AssertionsMethod } from './assertions/verify-chain';
4
5
  declare class TypedPageClass<T extends {
5
- testIds?: Record<string, string>;
6
- selectors?: Record<string, string>;
6
+ testIds?: Record<string, string | DynamicLocatorEntry>;
7
+ selectors?: Record<string, string | DynamicSelectorEntry>;
7
8
  url?: string;
8
9
  }> {
9
10
  readonly page: Page;
@@ -58,22 +59,22 @@ declare class TypedPageClass<T extends {
58
59
  expect(target: PageKeys<T> | ChainedKeys<T> | Locator, message?: string): ReturnType<typeof playwrightExpect<Locator>>;
59
60
  }
60
61
  export type TypedPageType<T extends {
61
- testIds?: Record<string, string>;
62
- selectors?: Record<string, string>;
62
+ testIds?: Record<string, string | DynamicLocatorEntry>;
63
+ selectors?: Record<string, string | DynamicSelectorEntry>;
63
64
  url?: string;
64
65
  }> = TypedPageClass<T> & TypedLocators<T> & ProxyLocatorMethods<T> & AssertionsMethod<T>;
65
66
  export declare const TypedPage: {
66
67
  new <T extends {
67
- testIds?: Record<string, string>;
68
- selectors?: Record<string, string>;
68
+ testIds?: Record<string, string | DynamicLocatorEntry>;
69
+ selectors?: Record<string, string | DynamicSelectorEntry>;
69
70
  url?: string;
70
71
  }>(context: Page | Locator, config: T, options?: {
71
72
  timeout?: number;
72
73
  }): TypedPageType<T>;
73
74
  };
74
75
  export type TypedPage<T extends {
75
- testIds?: Record<string, string>;
76
- selectors?: Record<string, string>;
76
+ testIds?: Record<string, string | DynamicLocatorEntry>;
77
+ selectors?: Record<string, string | DynamicSelectorEntry>;
77
78
  url?: string;
78
79
  }> = TypedPageType<T>;
79
80
  export {};
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TypedPage = void 0;
4
+ const test_1 = require("@playwright/test");
4
5
  const verify_chain_1 = require("./assertions/verify-chain");
5
6
  const verify_helpers_1 = require("./assertions/verify-helpers");
6
7
  const expect_1 = require("./assertions/expect");
7
8
  const page_actions_1 = require("./actions/page-actions");
8
9
  const locator_actions_1 = require("./actions/locator-actions");
9
10
  const resolver_1 = require("./locators/resolver");
11
+ const caller_location_1 = require("./utils/caller-location");
10
12
  class TypedPageClass {
11
13
  page;
12
14
  context;
@@ -23,30 +25,76 @@ class TypedPageClass {
23
25
  this.timeout = options?.timeout;
24
26
  (0, resolver_1.defineLocators)(this, this.context, this.config);
25
27
  (0, locator_actions_1.defineActionMethods)(this, this.resolveLocator.bind(this), this.timeout);
28
+ // Auto-wrap subclass methods with Playwright test.step
29
+ const builtInMethods = new Set([
30
+ 'constructor', 'goto', 'verifyURL', 'verifyTitle', 'reload',
31
+ 'waitForLoadState', 'waitForURL', 'resolveLocator', 'locator',
32
+ 'verify', 'verifyHidden', 'verifyEnabled', 'verifyDisabled', 'expect'
33
+ ]);
34
+ let proto = Object.getPrototypeOf(this);
35
+ while (proto && proto !== Object.prototype && proto !== TypedPageClass.prototype) {
36
+ const propNames = Object.getOwnPropertyNames(proto);
37
+ for (const prop of propNames) {
38
+ const desc = Object.getOwnPropertyDescriptor(proto, prop);
39
+ if (desc && typeof desc.value === 'function' && !builtInMethods.has(prop) && !prop.startsWith('_')) {
40
+ const originalFn = desc.value;
41
+ Object.defineProperty(this, prop, {
42
+ value: function (...args) {
43
+ const words = prop.split(/(?=[A-Z])/).map(w => w.toLowerCase());
44
+ if (words.length > 0) {
45
+ words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
46
+ }
47
+ const sentence = words.join(' ');
48
+ const formattedArgs = args.map(arg => {
49
+ if (typeof arg === 'object' && arg !== null) {
50
+ try {
51
+ return JSON.stringify(arg);
52
+ }
53
+ catch {
54
+ return String(arg);
55
+ }
56
+ }
57
+ return String(arg);
58
+ });
59
+ const stepName = formattedArgs.length > 0
60
+ ? `${sentence} - ${formattedArgs.join(' - ')}`
61
+ : sentence;
62
+ return test_1.test.step(stepName, () => {
63
+ return originalFn.apply(this, args);
64
+ });
65
+ },
66
+ writable: true,
67
+ configurable: true,
68
+ enumerable: true
69
+ });
70
+ }
71
+ }
72
+ proto = Object.getPrototypeOf(proto);
73
+ }
26
74
  }
27
75
  /** Navigate to the URL from page config. */
28
76
  async goto(options) {
29
- return (0, page_actions_1.goto)(this.page, this.url, this.constructor.name, options);
77
+ return (0, page_actions_1.goto)(this.page, this.url, this.constructor.name, options, (0, caller_location_1.getCallerLocation)());
30
78
  }
31
79
  /** Assert the current URL matches the page config URL or a custom pattern. */
32
80
  async verifyURL(urlOrOptions, options) {
33
- return (0, page_actions_1.verifyURL)(this.page, this.url, this.constructor.name, urlOrOptions, options);
81
+ return (0, page_actions_1.verifyURL)(this.page, this.url, this.constructor.name, urlOrOptions, options, (0, caller_location_1.getCallerLocation)());
34
82
  }
35
83
  /** Assert the page title matches the expected value. */
36
84
  async verifyTitle(title, options) {
37
- return (0, page_actions_1.verifyTitle)(this.page, title, options);
85
+ return (0, page_actions_1.verifyTitle)(this.page, title, options, (0, caller_location_1.getCallerLocation)());
38
86
  }
39
87
  /** Reload the current page. */
40
88
  async reload(options) {
41
- return (0, page_actions_1.reload)(this.page, options);
89
+ return (0, page_actions_1.reload)(this.page, options, (0, caller_location_1.getCallerLocation)());
42
90
  }
43
91
  /** Wait for the page to reach a load state. */
44
92
  async waitForLoadState(state, options) {
45
- return (0, page_actions_1.waitForLoadState)(this.page, state, options);
93
+ return (0, page_actions_1.waitForLoadState)(this.page, state, options, (0, caller_location_1.getCallerLocation)());
46
94
  }
47
95
  /** Wait for navigation to a URL. Defaults to the page config URL. */
48
96
  async waitForURL(urlOrOptions, options) {
49
- return (0, page_actions_1.waitForURL)(this.page, this.url, this.constructor.name, urlOrOptions, options);
97
+ return (0, page_actions_1.waitForURL)(this.page, this.url, this.constructor.name, urlOrOptions, options, (0, caller_location_1.getCallerLocation)());
50
98
  }
51
99
  resolveLocator(target, options) {
52
100
  return (0, resolver_1.resolveLocator)(this.context, this.config, target, options);
@@ -62,13 +110,13 @@ class TypedPageClass {
62
110
  return fn;
63
111
  }
64
112
  async verifyHidden(target, options) {
65
- return (0, verify_helpers_1.verifyHidden)(this.resolveLocator.bind(this), target, options);
113
+ return (0, verify_helpers_1.verifyHidden)(this.resolveLocator.bind(this), target, options, (0, caller_location_1.getCallerLocation)());
66
114
  }
67
115
  async verifyEnabled(target, options) {
68
- return (0, verify_helpers_1.verifyEnabled)(this.resolveLocator.bind(this), target, options);
116
+ return (0, verify_helpers_1.verifyEnabled)(this.resolveLocator.bind(this), target, options, (0, caller_location_1.getCallerLocation)());
69
117
  }
70
118
  async verifyDisabled(target, options) {
71
- return (0, verify_helpers_1.verifyDisabled)(this.resolveLocator.bind(this), target, options);
119
+ return (0, verify_helpers_1.verifyDisabled)(this.resolveLocator.bind(this), target, options, (0, caller_location_1.getCallerLocation)());
72
120
  }
73
121
  expect(target, message) {
74
122
  const resolved = typeof target !== 'string' ? target : this.resolveLocator(target);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Extracts placeholder names from a `{name}` pattern string.
3
+ */
4
+ export type ExtractPlaceholders<S extends string> = S extends `${string}{${infer Name}}${infer Rest}` ? Name | ExtractPlaceholders<Rest> : never;
5
+ /**
6
+ * Checks recursively if a pattern string contains duplicate placeholders.
7
+ */
8
+ export type HasDuplicatePlaceholders<S extends string, Seen extends string = never> = S extends `${string}{${infer Name}}${infer Rest}` ? Name extends Seen ? true : HasDuplicatePlaceholders<Rest, Seen | Name> : false;
9
+ /**
10
+ * Returns the subset of `Placeholders` whose names are NOT found as
11
+ * substrings of `TestId`. Evaluates to `never` when all are present.
12
+ */
13
+ export type MissingInTestId<TestId extends string, Placeholders extends string> = Placeholders extends any ? TestId extends `${string}${Placeholders}${string}` ? never : Placeholders : never;
14
+ /**
15
+ * Validates a dynamic entry by mapping each of its properties to their expected types.
16
+ *
17
+ * By checking properties individually, TypeScript reports the error exactly on the offending line
18
+ * (e.g. on the wrong value or unknown key) instead of marking the entire object as invalid.
19
+ */
20
+ export type ValidateDynamicEntryProperties<Pattern extends string, Entry, TargetKey extends 'testId' | 'selector'> = {
21
+ [Key in (keyof Entry | ExtractPlaceholders<Pattern> | TargetKey)]: Key extends TargetKey ? Entry extends Record<TargetKey, infer TPat> ? TPat extends string ? [MissingInTestId<TPat, ExtractPlaceholders<Pattern>>] extends [never] ? string : `Error: ${TargetKey} must contain all placeholder names` : `Error: ${TargetKey} must be a string` : `Error: Dynamic entry must include a ${TargetKey} property` : Key extends ExtractPlaceholders<Pattern> ? Key extends keyof Entry ? Entry[Key] extends readonly string[] ? readonly string[] : `Error: Placeholder key must be an array: ${Key & string}` : `Error: Missing placeholder key: ${Key & string}` : `Error: Only placeholder keys and '${TargetKey}' are allowed`;
22
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ export declare function getCallerLocation(): {
2
+ file: string;
3
+ line: number;
4
+ column: number;
5
+ } | undefined;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCallerLocation = getCallerLocation;
4
+ // Global stack trace filter to remove pw-core internal frames from all error stacks.
5
+ // This ensures that Playwright's auto-generated steps and expectations are attributed
6
+ // directly to the user's project files instead of showing internal pw-core library paths.
7
+ const originalPrepare = Error.prepareStackTrace;
8
+ Error.prepareStackTrace = (err, stack) => {
9
+ const filteredStack = stack.filter(frame => {
10
+ const file = frame.getFileName();
11
+ if (!file)
12
+ return true;
13
+ const normalized = file.replace(/\\/g, '/');
14
+ if (normalized.includes('pw-core/src') ||
15
+ normalized.includes('pw-core/dist') ||
16
+ normalized.includes('node_modules/pw-core')) {
17
+ return false;
18
+ }
19
+ return true;
20
+ });
21
+ if (originalPrepare) {
22
+ try {
23
+ return originalPrepare(err, filteredStack);
24
+ }
25
+ catch {
26
+ // Fallback if originalPrepare fails on filtered stack
27
+ }
28
+ }
29
+ const errString = err.toString ? err.toString() : String(err);
30
+ return errString + '\n' + filteredStack.map(frame => {
31
+ try {
32
+ return ' at ' + frame.toString();
33
+ }
34
+ catch {
35
+ return ' at <unknown>';
36
+ }
37
+ }).join('\n');
38
+ };
39
+ function getCallerLocation() {
40
+ const originalPrepareStackTrace = Error.prepareStackTrace;
41
+ try {
42
+ const err = {};
43
+ Error.prepareStackTrace = (_err, stack) => stack;
44
+ Error.captureStackTrace(err);
45
+ const stack = err.stack;
46
+ if (!stack)
47
+ return undefined;
48
+ for (let i = 1; i < stack.length; i++) {
49
+ const frame = stack[i];
50
+ const file = frame.getFileName();
51
+ if (!file)
52
+ continue;
53
+ const normalizedFile = file.replace(/\\/g, '/');
54
+ // Skip internal node_modules, node built-ins, and pw-core source/dist files
55
+ if (normalizedFile.includes('node_modules') ||
56
+ normalizedFile.includes('pw-core/src') ||
57
+ normalizedFile.includes('pw-core/dist') ||
58
+ normalizedFile.includes('playwright/lib') ||
59
+ normalizedFile.includes('@playwright') ||
60
+ normalizedFile.startsWith('node:') ||
61
+ !normalizedFile.includes('/')) {
62
+ continue;
63
+ }
64
+ const line = frame.getLineNumber();
65
+ const column = frame.getColumnNumber();
66
+ if (!line)
67
+ continue;
68
+ return { file, line, column: column ?? 1 };
69
+ }
70
+ return undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ finally {
76
+ Error.prepareStackTrace = originalPrepareStackTrace;
77
+ }
78
+ }
@@ -15,7 +15,14 @@ function formatStepDescription(methodName, target, args) {
15
15
  const targetStr = formatTarget(target);
16
16
  if (methodName === 'fill' && args[0] !== undefined) {
17
17
  const options = args[1];
18
- const shouldMask = !(options && typeof options === 'object' && options.mask === false);
18
+ let shouldMask = false;
19
+ if (options && typeof options === 'object' && options.mask !== undefined) {
20
+ shouldMask = options.mask === true;
21
+ }
22
+ else {
23
+ const targetStrLower = targetStr.toLowerCase();
24
+ shouldMask = targetStrLower.includes('pass') || targetStrLower.includes('pw');
25
+ }
19
26
  const displayValue = shouldMask ? '*'.repeat(String(args[0]).length) : args[0];
20
27
  return `Fill "${targetStr}" with "${displayValue}"`;
21
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-core",
3
- "version": "0.0.5",
3
+ "version": "1.2.0",
4
4
  "workspaces": [
5
5
  "examples",
6
6
  "create-pw-core"