@itmetsam/samurai-framework 0.21.2 → 0.21.3

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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/assert/locator-assertions.d.ts +4 -0
  3. package/dist/assert/locator-assertions.js +10 -2
  4. package/dist/assert/text-match.d.ts +2 -2
  5. package/dist/assert/text-match.js +2 -2
  6. package/dist/browser/dialog.js +4 -1
  7. package/dist/browser/init-script.d.ts +11 -0
  8. package/dist/browser/init-script.js +29 -0
  9. package/dist/browser/page-locators.d.ts +18 -0
  10. package/dist/browser/page-locators.js +19 -0
  11. package/dist/browser/page-permissions.d.ts +6 -0
  12. package/dist/browser/page-permissions.js +15 -0
  13. package/dist/browser/page-settings.d.ts +3 -0
  14. package/dist/browser/page-settings.js +4 -0
  15. package/dist/browser/page.d.ts +29 -13
  16. package/dist/browser/page.js +46 -45
  17. package/dist/config/variables.js +2 -1
  18. package/dist/locator/accessible-name.d.ts +22 -0
  19. package/dist/locator/accessible-name.js +128 -0
  20. package/dist/locator/action-timeout-error.js +5 -1
  21. package/dist/locator/actionability.d.ts +3 -2
  22. package/dist/locator/actionability.js +4 -4
  23. package/dist/locator/attribute-locator.d.ts +13 -0
  24. package/dist/locator/attribute-locator.js +27 -0
  25. package/dist/locator/chain-resolver.d.ts +12 -3
  26. package/dist/locator/chain-resolver.js +85 -15
  27. package/dist/locator/check-actions.d.ts +15 -0
  28. package/dist/locator/check-actions.js +44 -0
  29. package/dist/locator/combine-step.d.ts +5 -0
  30. package/dist/locator/combine-step.js +15 -0
  31. package/dist/locator/control-probe.d.ts +10 -0
  32. package/dist/locator/control-probe.js +50 -0
  33. package/dist/locator/element-access.d.ts +24 -0
  34. package/dist/locator/element-access.js +53 -0
  35. package/dist/locator/element-actions.d.ts +17 -0
  36. package/dist/locator/element-actions.js +51 -0
  37. package/dist/locator/element-reads.d.ts +29 -4
  38. package/dist/locator/element-reads.js +83 -2
  39. package/dist/locator/element-state.d.ts +5 -0
  40. package/dist/locator/element-state.js +2 -0
  41. package/dist/locator/file-actions.d.ts +2 -2
  42. package/dist/locator/filter-step.d.ts +24 -0
  43. package/dist/locator/filter-step.js +72 -0
  44. package/dist/locator/label-locator.d.ts +1 -1
  45. package/dist/locator/locator.d.ts +94 -29
  46. package/dist/locator/locator.js +183 -151
  47. package/dist/locator/page-scripts.d.ts +2 -0
  48. package/dist/locator/page-scripts.js +6 -0
  49. package/dist/locator/page-search.d.ts +11 -2
  50. package/dist/locator/page-search.js +15 -3
  51. package/dist/locator/page-step.d.ts +16 -0
  52. package/dist/locator/page-step.js +35 -0
  53. package/dist/locator/role-locator.d.ts +65 -0
  54. package/dist/locator/role-locator.js +125 -0
  55. package/dist/locator/role-table.d.ts +18 -0
  56. package/dist/locator/role-table.js +190 -0
  57. package/dist/locator/select-actions.d.ts +13 -0
  58. package/dist/locator/select-actions.js +73 -0
  59. package/dist/locator/selector-string.d.ts +7 -0
  60. package/dist/locator/selector-string.js +27 -0
  61. package/dist/locator/selector.d.ts +47 -15
  62. package/dist/locator/selector.js +83 -12
  63. package/dist/locator/strict-mode.d.ts +6 -0
  64. package/dist/locator/strict-mode.js +6 -0
  65. package/dist/locator/text-locator.d.ts +1 -1
  66. package/dist/network/url-match.js +3 -1
  67. package/dist/types/config.d.ts +7 -0
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -19,7 +19,7 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
19
19
  ```
20
20
 
21
21
  - **Auto-waiting.** Actions wait until the element is attached, visible, stable, enabled and not covered. Assertions retry until they pass or time out.
22
- - **Semantic locators.** By role, label, text, test id, CSS or XPath, with ordered fallbacks.
22
+ - **Semantic locators.** By role, label, text, test id, CSS or XPath, with `nth`, `filter`, `and` / `or` and ordered fallbacks.
23
23
  - **Environments and secrets.** One test file runs against `dev`, `staging` or `production`; secrets come from the environment or a `.env.<name>` file and are masked in logs and reports.
24
24
  - **Parallel runs.** Tests run side by side in worker processes (`--workers`, `workers` in the config; half the CPUs by default), and a flow's Parallel node runs its branches at the same time (see [Running tests and reports](https://github.com/degrootsam/samurai-framework/wiki/Running-tests-and-reports#parallel-runs)).
25
25
  - **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
@@ -65,4 +65,8 @@ export declare class LocatorAssertions {
65
65
  toHaveClass(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
66
66
  /** The computed value of a CSS property equals `expected` (string) or matches it (RegExp); `name` may be camelCase (`backgroundColor`) or kebab-case, `--custom` is kept */
67
67
  toHaveCSS(name: string, expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
68
+ /** The accessible name equals `expected` (string, whitespace collapsed and trimmed on both sides) or matches it (RegExp, tested as written); a missing element never passes, not even with `.not` */
69
+ toHaveAccessibleName(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
70
+ /** The accessible description (`aria-describedby`, else `title` when it is not the name) equals or matches `expected`, as `toHaveAccessibleName`; "" when there is none */
71
+ toHaveAccessibleDescription(expected: string | RegExp, options?: LocatorAssertionOptions): Promise<void>;
68
72
  }
@@ -3,7 +3,7 @@ import { waitUntil, WaitTimeoutError } from "../wait/wait-until.js";
3
3
  import { AssertionError } from "./assertion-error.js";
4
4
  import { track } from "./pending-assertions.js";
5
5
  import { bindOutput } from "../wait/output-sink.js";
6
- import { captured, classCheck, matchesText, textCheck } from "./text-match.js";
6
+ import { captured, matchesText, normalizedCheck, textCheck, } from "./text-match.js";
7
7
  /**
8
8
  * Re-reads the element until `check` passes or the timeout expires, and resolves with the read that passed. A
9
9
  * timeout fails as an `AssertionError` with the last read, or as "could not read" when no read completed.
@@ -107,10 +107,18 @@ export class LocatorAssertions {
107
107
  }
108
108
  /** The class attribute equals `expected` (string, whitespace collapsed and trimmed on both sides) or matches it (RegExp, tested on the attribute as written) */
109
109
  toHaveClass(expected, options) {
110
- return this.assertExisting("toHaveClass", expected, () => this.locator.classAttribute(), classCheck(expected), options);
110
+ return this.assertExisting("toHaveClass", expected, () => this.locator.classAttribute(), normalizedCheck(expected), options);
111
111
  }
112
112
  /** The computed value of a CSS property equals `expected` (string) or matches it (RegExp); `name` may be camelCase (`backgroundColor`) or kebab-case, `--custom` is kept */
113
113
  toHaveCSS(name, expected, options) {
114
114
  return this.assertExisting("toHaveCSS", expected, () => this.locator.cssValue(name), (actual) => matchesText(actual, expected, "exact"), options);
115
115
  }
116
+ /** The accessible name equals `expected` (string, whitespace collapsed and trimmed on both sides) or matches it (RegExp, tested as written); a missing element never passes, not even with `.not` */
117
+ toHaveAccessibleName(expected, options) {
118
+ return this.assertExisting("toHaveAccessibleName", expected, () => this.locator.accessibleName(), normalizedCheck(expected), options);
119
+ }
120
+ /** The accessible description (`aria-describedby`, else `title` when it is not the name) equals or matches `expected`, as `toHaveAccessibleName`; "" when there is none */
121
+ toHaveAccessibleDescription(expected, options) {
122
+ return this.assertExisting("toHaveAccessibleDescription", expected, () => this.locator.accessibleDescription(), normalizedCheck(expected), options);
123
+ }
116
124
  }
@@ -8,5 +8,5 @@ export declare function matchesText(actual: string, expected: string | RegExp, m
8
8
  export declare function captured(matcher: string | RegExp, actual: string): string;
9
9
  /** A check for a read that may find nothing (`null`): true when the text is there and matches */
10
10
  export declare function textCheck(expected: string | RegExp, mode: "exact" | "contains"): (actual: string | null) => boolean;
11
- /** A check for a class attribute: a string equals it after collapsing whitespace and trimming both sides, a RegExp tests it as written */
12
- export declare function classCheck(expected: string | RegExp): (actual: string) => boolean;
11
+ /** A check for a text compared after normalising whitespace: a string equals it with whitespace collapsed and both sides trimmed, a RegExp tests it as written */
12
+ export declare function normalizedCheck(expected: string | RegExp): (actual: string) => boolean;
@@ -21,8 +21,8 @@ export function captured(matcher, actual) {
21
21
  export function textCheck(expected, mode) {
22
22
  return (actual) => actual !== null && matchesText(actual, expected, mode);
23
23
  }
24
- /** A check for a class attribute: a string equals it after collapsing whitespace and trimming both sides, a RegExp tests it as written */
25
- export function classCheck(expected) {
24
+ /** A check for a text compared after normalising whitespace: a string equals it with whitespace collapsed and both sides trimmed, a RegExp tests it as written */
25
+ export function normalizedCheck(expected) {
26
26
  if (expected instanceof RegExp)
27
27
  return (actual) => matchesText(actual, expected, "exact");
28
28
  const normalize = (value) => value.replace(/\s+/g, " ").trim();
@@ -30,7 +30,10 @@ export class Dialog {
30
30
  }
31
31
  /** Clicks OK; `promptText` is typed into a `prompt` first */
32
32
  accept(promptText) {
33
- return this.answer({ accept: true, ...(promptText !== undefined && { userText: promptText }) });
33
+ return this.answer({
34
+ accept: true,
35
+ ...(promptText !== undefined && { userText: promptText }),
36
+ });
34
37
  }
35
38
  /** Clicks Cancel */
36
39
  dismiss() {
@@ -1,2 +1,13 @@
1
+ import { type PreloadHandle } from "../script/preload.js";
2
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
3
+ import type { BrowsingContext } from "../types/bidi-modules/browsing-context.js";
1
4
  /** Wraps an init script as one function declaration; the argument is embedded as JSON because preload scripts take no arguments */
2
5
  export declare function initScriptSource(script: string | ((arg?: any) => void), arg: unknown): string;
6
+ /** The init scripts a page registered, so they can be released with the page */
7
+ export declare class InitScripts {
8
+ private scripts;
9
+ /** Registers `script` for every document the context loads from now on, and runs it in the one loaded now */
10
+ add(biDiConnector: BiDiConnector, contextId: BrowsingContext, script: string | ((arg?: any) => void), arg?: unknown): Promise<PreloadHandle>;
11
+ /** The scripts still registered */
12
+ all(): PreloadHandle[];
13
+ }
@@ -1,4 +1,5 @@
1
1
  import { toFunctionDeclaration } from "../script/call-function.js";
2
+ import { addPreload } from "../script/preload.js";
2
3
  /** Wraps an init script as one function declaration; the argument is embedded as JSON because preload scripts take no arguments */
3
4
  export function initScriptSource(script, arg) {
4
5
  if (typeof script === "string") {
@@ -14,3 +15,31 @@ export function initScriptSource(script, arg) {
14
15
  }
15
16
  return `() => (${declaration})(${argument})`;
16
17
  }
18
+ /** The init scripts a page registered, so they can be released with the page */
19
+ export class InitScripts {
20
+ constructor() {
21
+ this.scripts = new Set();
22
+ }
23
+ /** Registers `script` for every document the context loads from now on, and runs it in the one loaded now */
24
+ async add(biDiConnector, contextId, script, arg) {
25
+ const source = initScriptSource(script, arg);
26
+ const handle = await addPreload(biDiConnector, {
27
+ source,
28
+ contexts: [contextId],
29
+ onRunError: "log",
30
+ });
31
+ const tracked = {
32
+ id: handle.id,
33
+ dispose: async () => {
34
+ this.scripts.delete(tracked);
35
+ await handle.dispose();
36
+ },
37
+ };
38
+ this.scripts.add(tracked);
39
+ return tracked;
40
+ }
41
+ /** The scripts still registered */
42
+ all() {
43
+ return [...this.scripts];
44
+ }
45
+ }
@@ -0,0 +1,18 @@
1
+ import Locator from "../locator/locator.js";
2
+ import type { HelperRealm } from "../locator/helper-realm.js";
3
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
4
+ import type { BrowsingContext } from "../types/bidi-modules/browsing-context.js";
5
+ import type { PageConfig } from "./page-settings.js";
6
+ /** Makes the page's locators, and holds whether they are strict */
7
+ export declare class PageLocators {
8
+ private biDiConnector;
9
+ private id;
10
+ private helperRealm;
11
+ private pageConfig;
12
+ private assertOpen;
13
+ private strictOverride;
14
+ constructor(biDiConnector: BiDiConnector, id: BrowsingContext, helperRealm: HelperRealm, pageConfig: PageConfig, assertOpen: () => void);
15
+ locate(selector: ConstructorParameters<typeof Locator>[0]): Locator;
16
+ /** `undefined` goes back to config `use.strictSelectors`; applies to locators already made, too */
17
+ setStrictSelectors(enabled: boolean | undefined): void;
18
+ }
@@ -0,0 +1,19 @@
1
+ import Locator from "../locator/locator.js";
2
+ /** Makes the page's locators, and holds whether they are strict */
3
+ export class PageLocators {
4
+ constructor(biDiConnector, id, helperRealm, pageConfig, assertOpen) {
5
+ this.biDiConnector = biDiConnector;
6
+ this.id = id;
7
+ this.helperRealm = helperRealm;
8
+ this.pageConfig = pageConfig;
9
+ this.assertOpen = assertOpen;
10
+ }
11
+ locate(selector) {
12
+ this.assertOpen();
13
+ return new Locator(selector, this.biDiConnector, this.id, this.helperRealm, [], () => this.pageConfig.strictSelectors(this.strictOverride));
14
+ }
15
+ /** `undefined` goes back to config `use.strictSelectors`; applies to locators already made, too */
16
+ setStrictSelectors(enabled) {
17
+ this.strictOverride = enabled;
18
+ }
19
+ }
@@ -0,0 +1,6 @@
1
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
2
+ import type { BrowserContext } from "./browser-context.js";
3
+ /** Grants a browser permission to the origin of the page (`url`), or to `options.origin` */
4
+ export declare function grantPermission(biDiConnector: BiDiConnector, url: () => Promise<string>, context: () => BrowserContext | undefined, name: string, options?: {
5
+ origin?: string;
6
+ }): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /** Grants a browser permission to the origin of the page (`url`), or to `options.origin` */
2
+ export async function grantPermission(biDiConnector, url, context, name, options = {}) {
3
+ const origin = options.origin ?? new URL(await url()).origin;
4
+ // about:blank and data: pages have no origin to grant to
5
+ if (origin === "null")
6
+ throw new Error("grantPermission(): needs a page on a web origin, or an explicit origin option");
7
+ const browserContext = context();
8
+ await biDiConnector.send("permissions.setPermission", {
9
+ descriptor: { name },
10
+ state: "granted",
11
+ origin,
12
+ ...(browserContext &&
13
+ !browserContext.isDefault && { userContext: browserContext.id }),
14
+ });
15
+ }
@@ -25,7 +25,10 @@ export declare class PageConfig {
25
25
  height: number;
26
26
  } | null;
27
27
  headless?: boolean;
28
+ strictSelectors?: boolean;
28
29
  }>;
30
+ /** Whether locators throw when they match several elements: the given override, else config `use.strictSelectors`, else no */
31
+ strictSelectors(override?: boolean): Promise<boolean>;
29
32
  /** The time (ms) a navigation or a wait may take: the call's, else config `navigation.timeout`, else 30 s */
30
33
  navigationTimeout(override?: number): Promise<number>;
31
34
  /** What a navigation waits for: the call's, else config `navigation.waitUntil`, else the load event */
@@ -31,6 +31,10 @@ export class PageConfig {
31
31
  ? Promise.resolve(this.given.use)
32
32
  : readSection("use");
33
33
  }
34
+ /** Whether locators throw when they match several elements: the given override, else config `use.strictSelectors`, else no */
35
+ async strictSelectors(override) {
36
+ return override ?? (await this.use()).strictSelectors ?? false;
37
+ }
34
38
  /** The time (ms) a navigation or a wait may take: the call's, else config `navigation.timeout`, else 30 s */
35
39
  async navigationTimeout(override) {
36
40
  return (override ??
@@ -1,5 +1,7 @@
1
1
  import Locator from "../locator/locator.js";
2
- import type { TextOptions } from "../locator/selector.js";
2
+ import type { FilterOptions } from "../locator/filter-step.js";
3
+ import type { RoleOptions } from "../locator/role-locator.js";
4
+ import type { ExactOption, TextOptions } from "../locator/selector.js";
3
5
  import type { NetworkRequest } from "../network/network-tracker.js";
4
6
  import type { Recorder, RecorderOptions } from "../recorder/recorder.js";
5
7
  import { ContextTree } from "./context-tree.js";
@@ -21,7 +23,7 @@ export type { PageEventName, WaitableEventName, WaitForEventOptions, WaitOptions
21
23
  import { type PageSettings, type ReadinessState } from "./page-settings.js";
22
24
  export type { PageSettings, ReadinessState };
23
25
  import { type Viewport } from "./viewport.js";
24
- import { type PreloadHandle } from "../script/preload.js";
26
+ import type { PreloadHandle } from "../script/preload.js";
25
27
  import type { BiDiConnector } from "../transport/bidi-connection.js";
26
28
  import type { BrowsingContext } from "../types/bidi-modules/browsing-context.js";
27
29
  export type { LoadState, NavigateOptions, NavigationResult };
@@ -32,6 +34,7 @@ export default class Page {
32
34
  private helperRealm;
33
35
  private initScripts;
34
36
  private pageConfig;
37
+ private locators;
35
38
  private pageEvents;
36
39
  private documents;
37
40
  private nav;
@@ -49,21 +52,34 @@ export default class Page {
49
52
  get contextId(): BrowsingContext;
50
53
  /** The context tree, created on first use and shared by everything that needs to attribute frame events to this page */
51
54
  tree(): Promise<ContextTree>;
52
- /** Locates an element on the page and returns an interactable object */
53
- locator(xpath: string): Locator;
54
- private locate;
55
+ /**
56
+ * Locates elements on the page. An xpath (`//x`, `/html/body`, `(//x)[2]`, `./x`, `xpath=…`, or an older
57
+ * relative one such as `a[@id='x']`) or a css selector (`input[name='x']`, `a, button`, `css=…`).
58
+ * `options` filter the matches, as `locator.filter()` does
59
+ */
60
+ locator(selector: string, options?: FilterOptions<Locator>): Locator;
61
+ /**
62
+ * Makes this page's locators strict (or not): an action or read on a locator matching several elements then
63
+ * throws `name(): strict mode violation, N elements match ...` instead of using the first. `undefined` goes back
64
+ * to config `use.strictSelectors` (off unless set). Applies to locators already made, too
65
+ */
66
+ setStrictSelectors(enabled: boolean | undefined): void;
55
67
  /** Locates elements with a CSS selector */
56
68
  getByCss(css: string): Locator;
57
- /** Locates elements by their text: an exact match unless `match: "partial"` */
58
- getByText(text: string, options?: TextOptions): Locator;
59
- /** Locates form controls by their label text: an exact match unless `match: "partial"` */
60
- getByLabel(text: string, options?: TextOptions): Locator;
69
+ /** Locates elements by their text: an exact match unless `match: "partial"` or `exact: false`; a RegExp is tested as written (see `TextOptions`) */
70
+ getByText(text: string | RegExp, options?: TextOptions): Locator;
71
+ /** Locates form controls by their label text: an exact match unless `match: "partial"` or `exact: false`; a RegExp is tested as written */
72
+ getByLabel(text: string | RegExp, options?: TextOptions): Locator;
73
+ /** Locates elements by their `placeholder` (see `TextOptions` for `exact` and RegExp) */
74
+ getByPlaceholder(text: string | RegExp, options?: ExactOption): Locator;
75
+ /** Locates elements by their `alt` text (images, areas, image inputs) */
76
+ getByAltText(text: string | RegExp, options?: ExactOption): Locator;
77
+ /** Locates elements by their `title` attribute */
78
+ getByTitle(text: string | RegExp, options?: ExactOption): Locator;
61
79
  /** Locates elements by their `data-testid` attribute */
62
80
  getByTestId(testId: string): Locator;
63
- /** Locates elements by ARIA role and, optionally, accessible name */
64
- getByRole(role: string, options?: {
65
- name?: string;
66
- }): Locator;
81
+ /** Locates elements by ARIA role and, optionally, accessible name and state (see `RoleOptions`) */
82
+ getByRole(role: string, options?: RoleOptions): Locator;
67
83
  /**
68
84
  * Runs `script` before any page script in every document this page loads from now on, and once in
69
85
  * the document that is loaded now. It runs in the page's own realm, so the page sees what it sets.
@@ -1,5 +1,5 @@
1
1
  import Locator from "../locator/locator.js";
2
- import { cssSelector, labelSelector, roleSelector, testIdSelector, textSelector, } from "../locator/selector.js";
2
+ import { attributeSelector, cssSelector, labelSelector, roleSelector, testIdSelector, textSelector, } from "../locator/selector.js";
3
3
  import EventEmitter from "node:events";
4
4
  import path from "node:path";
5
5
  import { projectDir } from "../config/config.js";
@@ -19,19 +19,20 @@ import { DocumentWatcher } from "./document-watcher.js";
19
19
  import { PageNavigation } from "./page-navigation.js";
20
20
  import { PageNetwork } from "./page-network.js";
21
21
  import { PageTransfers } from "./page-transfers.js";
22
- import { initScriptSource } from "./init-script.js";
22
+ import { InitScripts } from "./init-script.js";
23
+ import { grantPermission } from "./page-permissions.js";
24
+ import { PageLocators } from "./page-locators.js";
23
25
  import { sourcesFor, waitForPageEvent, } from "./page-events.js";
24
26
  import { PageConfig, } from "./page-settings.js";
25
27
  import { applyViewport } from "./viewport.js";
26
28
  import { HelperRealm } from "../locator/helper-realm.js";
27
- import { addPreload } from "../script/preload.js";
28
29
  import { BiDiError } from "../transport/bidi-error.js";
29
30
  import { callFunction } from "../script/call-function.js";
30
31
  export default class Page {
31
32
  /** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
32
33
  constructor(biDiConnector, contextId, settings) {
33
34
  this.lazyTree = new Lazy();
34
- this.initScripts = new Set();
35
+ this.initScripts = new InitScripts();
35
36
  this.pageEvents = new EventEmitter();
36
37
  this.lazyLogs = new Lazy();
37
38
  this.errorsAllowed = false;
@@ -40,6 +41,7 @@ export default class Page {
40
41
  this.id = contextId;
41
42
  this.pageConfig = new PageConfig(settings);
42
43
  this.helperRealm = new HelperRealm(biDiConnector, contextId);
44
+ this.locators = new PageLocators(biDiConnector, contextId, this.helperRealm, this.pageConfig, () => this.assertOpen());
43
45
  this.documents = new DocumentWatcher(biDiConnector, contextId);
44
46
  this.net = new PageNetwork(biDiConnector, contextId, {
45
47
  config: this.pageConfig,
@@ -70,33 +72,56 @@ export default class Page {
70
72
  tree() {
71
73
  return this.lazyTree.get(() => ContextTree.create(this.biDiConnector));
72
74
  }
73
- /** Locates an element on the page and returns an interactable object */
74
- locator(xpath) {
75
- return this.locate(xpath);
75
+ /**
76
+ * Locates elements on the page. An xpath (`//x`, `/html/body`, `(//x)[2]`, `./x`, `xpath=…`, or an older
77
+ * relative one such as `a[@id='x']`) or a css selector (`input[name='x']`, `a, button`, `css=…`).
78
+ * `options` filter the matches, as `locator.filter()` does
79
+ */
80
+ locator(selector, options) {
81
+ const found = this.locators.locate(selector);
82
+ return options === undefined ? found : found.filter(options);
76
83
  }
77
- locate(selector) {
78
- this.assertOpen();
79
- return new Locator(selector, this.biDiConnector, this.id, this.helperRealm);
84
+ /**
85
+ * Makes this page's locators strict (or not): an action or read on a locator matching several elements then
86
+ * throws `name(): strict mode violation, N elements match ...` instead of using the first. `undefined` goes back
87
+ * to config `use.strictSelectors` (off unless set). Applies to locators already made, too
88
+ */
89
+ setStrictSelectors(enabled) {
90
+ this.locators.setStrictSelectors(enabled);
80
91
  }
81
92
  /** Locates elements with a CSS selector */
82
93
  getByCss(css) {
83
- return this.locate([cssSelector(css)]);
94
+ return this.locators.locate([cssSelector(css)]);
84
95
  }
85
- /** Locates elements by their text: an exact match unless `match: "partial"` */
96
+ /** Locates elements by their text: an exact match unless `match: "partial"` or `exact: false`; a RegExp is tested as written (see `TextOptions`) */
86
97
  getByText(text, options) {
87
- return this.locate([textSelector(text, options)]);
98
+ return this.locators.locate([textSelector(text, options)]);
88
99
  }
89
- /** Locates form controls by their label text: an exact match unless `match: "partial"` */
100
+ /** Locates form controls by their label text: an exact match unless `match: "partial"` or `exact: false`; a RegExp is tested as written */
90
101
  getByLabel(text, options) {
91
- return this.locate([labelSelector(text, options)]);
102
+ return this.locators.locate([labelSelector(text, options)]);
103
+ }
104
+ /** Locates elements by their `placeholder` (see `TextOptions` for `exact` and RegExp) */
105
+ getByPlaceholder(text, options) {
106
+ return this.locators.locate([
107
+ attributeSelector("placeholder", text, options),
108
+ ]);
109
+ }
110
+ /** Locates elements by their `alt` text (images, areas, image inputs) */
111
+ getByAltText(text, options) {
112
+ return this.locators.locate([attributeSelector("alt", text, options)]);
113
+ }
114
+ /** Locates elements by their `title` attribute */
115
+ getByTitle(text, options) {
116
+ return this.locators.locate([attributeSelector("title", text, options)]);
92
117
  }
93
118
  /** Locates elements by their `data-testid` attribute */
94
119
  getByTestId(testId) {
95
- return this.locate([testIdSelector(testId)]);
120
+ return this.locators.locate([testIdSelector(testId)]);
96
121
  }
97
- /** Locates elements by ARIA role and, optionally, accessible name */
122
+ /** Locates elements by ARIA role and, optionally, accessible name and state (see `RoleOptions`) */
98
123
  getByRole(role, options) {
99
- return this.locate([roleSelector(role, options)]);
124
+ return this.locators.locate([roleSelector(role, options)]);
100
125
  }
101
126
  /**
102
127
  * Runs `script` before any page script in every document this page loads from now on, and once in
@@ -109,21 +134,7 @@ export default class Page {
109
134
  */
110
135
  async addInitScript(script, arg) {
111
136
  this.assertOpen();
112
- const source = initScriptSource(script, arg);
113
- const handle = await addPreload(this.biDiConnector, {
114
- source,
115
- contexts: [this.id],
116
- onRunError: "log",
117
- });
118
- const tracked = {
119
- id: handle.id,
120
- dispose: async () => {
121
- this.initScripts.delete(tracked);
122
- await handle.dispose();
123
- },
124
- };
125
- this.initScripts.add(tracked);
126
- return tracked;
137
+ return this.initScripts.add(this.biDiConnector, this.id, script, arg);
127
138
  }
128
139
  /**
129
140
  * Starts recording what a person does in the page as steps (see `Recorder`). The page must be driven by
@@ -137,7 +148,7 @@ export default class Page {
137
148
  }
138
149
  /** Removes everything the page registered in the browser: init scripts, framework helpers and event subscriptions */
139
150
  async dispose() {
140
- const scripts = [...this.initScripts];
151
+ const scripts = this.initScripts.all();
141
152
  const results = await Promise.allSettled([
142
153
  ...scripts.map((handle) => handle.dispose()),
143
154
  this.helperRealm.dispose(),
@@ -302,17 +313,7 @@ export default class Page {
302
313
  */
303
314
  async grantPermission(name, options = {}) {
304
315
  this.assertOpen();
305
- const origin = options.origin ?? new URL(await this.url()).origin;
306
- // about:blank and data: pages have no origin to grant to
307
- if (origin === "null")
308
- throw new Error("grantPermission(): needs a page on a web origin, or an explicit origin option");
309
- const context = this.browserContext;
310
- await this.biDiConnector.send("permissions.setPermission", {
311
- descriptor: { name },
312
- state: "granted",
313
- origin,
314
- ...(context && !context.isDefault && { userContext: context.id }),
315
- });
316
+ await grantPermission(this.biDiConnector, () => this.url(), () => this.browserContext, name, options);
316
317
  }
317
318
  /** True once `close()` has run */
318
319
  get closed() {
@@ -18,7 +18,8 @@ export function isPassthroughKey(target, key) {
18
18
  export function createEnvFixture(environment, variables) {
19
19
  return new Proxy(Object.freeze({ ...variables }), {
20
20
  get(target, key, receiver) {
21
- if (isPassthroughKey(target, key) || Object.prototype.hasOwnProperty.call(target, key))
21
+ if (isPassthroughKey(target, key) ||
22
+ Object.prototype.hasOwnProperty.call(target, key))
22
23
  return Reflect.get(target, key, receiver);
23
24
  throw new UnknownVariableError(String(key), environment);
24
25
  },
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Page-side source that defines, in the scope it is pasted into:
3
+ *
4
+ * - `isHidden(el)`: not rendered or hidden from assistive technology: `display: none`, `visibility: hidden` or
5
+ * `collapse`, or `aria-hidden="true"` on the element or an ancestor.
6
+ * - `accessibleName(el)` and `accessibleDescription(el)`: strings, whitespace collapsed and trimmed.
7
+ * - `roleOf(el)` (from `ROLE_OF_JS`).
8
+ *
9
+ * The name follows the accname algorithm in a simplified form, the order being: `aria-labelledby` (hidden content is
10
+ * read when referenced directly), `aria-label`, the native text alternative (`alt` of img / area / input[type=image],
11
+ * `value` of button-like inputs, the `<label>`s of a form control, `<legend>`, `<caption>`, `<figcaption>`, svg
12
+ * `<title>`), the content for roles that take their name from it (and always for what an embedding walks through),
13
+ * the `title` attribute, and for a text field its `placeholder`. Embedded form controls count by their value, and
14
+ * `::before` / `::after` text by its CSS content. Hidden descendants are skipped. A reference loop stops at the
15
+ * element already visited. The description is `aria-describedby`, else `aria-description`, else the `title` when it was not
16
+ * used as the name.
17
+ *
18
+ * It deliberately differs from the browser's own accessibility tree: no CSS `display` quirks for table parts, no
19
+ * `<summary>` or `<details>` rules, no shadow DOM or slot flattening, no `aria-owns`, and `placeholder` counts as a name
20
+ * for inputs in both browsers' way (Firefox and Chrome disagree there).
21
+ */
22
+ export declare const ACCESSIBLE_NAME_JS: string;
@@ -0,0 +1,128 @@
1
+ import { NAME_FROM_CONTENT_ROLES, ROLE_OF_JS } from "./role-table.js";
2
+ /**
3
+ * Page-side source that defines, in the scope it is pasted into:
4
+ *
5
+ * - `isHidden(el)`: not rendered or hidden from assistive technology: `display: none`, `visibility: hidden` or
6
+ * `collapse`, or `aria-hidden="true"` on the element or an ancestor.
7
+ * - `accessibleName(el)` and `accessibleDescription(el)`: strings, whitespace collapsed and trimmed.
8
+ * - `roleOf(el)` (from `ROLE_OF_JS`).
9
+ *
10
+ * The name follows the accname algorithm in a simplified form, the order being: `aria-labelledby` (hidden content is
11
+ * read when referenced directly), `aria-label`, the native text alternative (`alt` of img / area / input[type=image],
12
+ * `value` of button-like inputs, the `<label>`s of a form control, `<legend>`, `<caption>`, `<figcaption>`, svg
13
+ * `<title>`), the content for roles that take their name from it (and always for what an embedding walks through),
14
+ * the `title` attribute, and for a text field its `placeholder`. Embedded form controls count by their value, and
15
+ * `::before` / `::after` text by its CSS content. Hidden descendants are skipped. A reference loop stops at the
16
+ * element already visited. The description is `aria-describedby`, else `aria-description`, else the `title` when it was not
17
+ * used as the name.
18
+ *
19
+ * It deliberately differs from the browser's own accessibility tree: no CSS `display` quirks for table parts, no
20
+ * `<summary>` or `<details>` rules, no shadow DOM or slot flattening, no `aria-owns`, and `placeholder` counts as a name
21
+ * for inputs in both browsers' way (Firefox and Chrome disagree there).
22
+ */
23
+ export const ACCESSIBLE_NAME_JS = `
24
+ ${ROLE_OF_JS}
25
+ const NAME_FROM_CONTENT = ${JSON.stringify(NAME_FROM_CONTENT_ROLES)};
26
+ const squash = (text) => String(text).replace(/\\s+/g, " ").trim();
27
+ const hiddenSelf = (n) => {
28
+ if (n.getAttribute("aria-hidden") === "true") return true;
29
+ const style = getComputedStyle(n);
30
+ return style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse";
31
+ };
32
+ const isHidden = (el) => {
33
+ if (hiddenSelf(el)) return true;
34
+ for (let n = el.parentElement; n; n = n.parentElement) {
35
+ if (n.getAttribute("aria-hidden") === "true" || getComputedStyle(n).display === "none") return true;
36
+ }
37
+ return false;
38
+ };
39
+ const byIds = (el, attribute) => (el.getAttribute(attribute) || "").split(/\\s+/)
40
+ .map((id) => (id ? el.ownerDocument.getElementById(id) : null)).filter(Boolean);
41
+ const pseudoText = (el, which) => {
42
+ const content = getComputedStyle(el, which).content;
43
+ return content && content !== "none" && content !== "normal" && /^["']/.test(content) ? content.slice(1, -1) : "";
44
+ };
45
+ const embeddedValue = (el, role) => {
46
+ if (role === "textbox" || role === "searchbox" || role === "spinbutton" || role === "slider") return el.value ?? el.getAttribute("aria-valuenow") ?? "";
47
+ if (role === "combobox" || role === "listbox") {
48
+ const chosen = el.localName === "select" ? Array.from(el.selectedOptions) : [];
49
+ return chosen.map((option) => option.label || option.textContent).join(" ");
50
+ }
51
+ return null;
52
+ };
53
+ /** The text of a node and what it embeds. \`seen\` guards reference loops; \`top\` is the node whose name is asked for */
54
+ const walk = (node, seen, top) => {
55
+ if (node.nodeType === 3) return node.nodeValue;
56
+ if (node.nodeType !== 1 || seen.has(node)) return "";
57
+ if (node !== top && hiddenSelf(node)) return "";
58
+ if (["script", "style", "noscript", "template"].includes(node.localName)) return "";
59
+ seen.add(node);
60
+ const label = node !== top && (node.getAttribute("aria-label") || "").trim();
61
+ if (label) return label;
62
+ const role = roleOf(node);
63
+ if (node !== top) {
64
+ const value = embeddedValue(node, role);
65
+ if (value !== null) return value;
66
+ }
67
+ let text = pseudoText(node, "::before");
68
+ for (const child of node.childNodes) {
69
+ const part = walk(child, seen, top);
70
+ const block = child.nodeType === 1 && getComputedStyle(child).display !== "inline";
71
+ text += block ? " " + part + " " : part;
72
+ }
73
+ return text + pseudoText(node, "::after");
74
+ };
75
+ const fromReferences = (el, attribute, seen) => squash(byIds(el, attribute).map((ref) => {
76
+ const own = (ref.getAttribute("aria-label") || "").trim();
77
+ return own || walk(ref, new Set(seen), ref);
78
+ }).join(" "));
79
+ const labelText = (el, seen) => squash(Array.from(el.labels || []).map((label) => walk(label, new Set(seen), label)).join(" "));
80
+ const nativeName = (el, seen) => {
81
+ const tag = el.localName;
82
+ const type = (el.getAttribute("type") || "").toLowerCase();
83
+ const first = (selector) => el.querySelector(":scope > " + selector);
84
+ if (tag === "img" || tag === "area" || (tag === "input" && type === "image")) {
85
+ const alt = el.getAttribute("alt");
86
+ if (alt !== null && squash(alt)) return squash(alt);
87
+ if (tag === "input") return squash(el.getAttribute("value") || "") || labelText(el, seen);
88
+ return "";
89
+ }
90
+ if (tag === "input" && (type === "button" || type === "submit" || type === "reset")) {
91
+ const value = squash(el.getAttribute("value") || "");
92
+ return value || labelText(el, seen) || (type === "submit" ? "Submit" : type === "reset" ? "Reset" : "");
93
+ }
94
+ if (tag === "input" || tag === "textarea" || tag === "select" || tag === "meter" || tag === "progress" || tag === "output") return labelText(el, seen);
95
+ if (tag === "fieldset") { const legend = first("legend"); return legend ? squash(walk(legend, new Set(seen), legend)) : ""; }
96
+ if (tag === "table") { const caption = first("caption"); return caption ? squash(walk(caption, new Set(seen), caption)) : ""; }
97
+ if (tag === "figure") { const caption = first("figcaption"); return caption ? squash(walk(caption, new Set(seen), caption)) : ""; }
98
+ if (tag === "svg") { const title = first("title"); return title ? squash(title.textContent) : ""; }
99
+ return "";
100
+ };
101
+ /** The name and where it came from (\`title\` or \`placeholder\` matter to the description) */
102
+ const nameSource = (el) => {
103
+ const seen = new Set([el]);
104
+ const referenced = fromReferences(el, "aria-labelledby", seen);
105
+ if (referenced) return { name: referenced, source: "labelledby" };
106
+ const own = squash(el.getAttribute("aria-label") || "");
107
+ if (own) return { name: own, source: "label" };
108
+ const native = nativeName(el, seen);
109
+ if (native) return { name: native, source: "native" };
110
+ if (NAME_FROM_CONTENT.includes(roleOf(el)) || el.localName === "label" || el.localName === "legend" || el.localName === "caption") {
111
+ const content = squash(walk(el, new Set(), el));
112
+ if (content) return { name: content, source: "content" };
113
+ }
114
+ const title = squash(el.getAttribute("title") || "");
115
+ if (title) return { name: title, source: "title" };
116
+ const placeholder = squash(el.getAttribute("placeholder") || "");
117
+ if ((el.localName === "input" || el.localName === "textarea") && placeholder) return { name: placeholder, source: "placeholder" };
118
+ return { name: "", source: "" };
119
+ };
120
+ const accessibleName = (el) => nameSource(el).name;
121
+ const accessibleDescription = (el) => {
122
+ const referenced = fromReferences(el, "aria-describedby", new Set([el]));
123
+ if (referenced) return referenced;
124
+ const attribute = squash(el.getAttribute("aria-description") || "");
125
+ if (attribute) return attribute;
126
+ const title = squash(el.getAttribute("title") || "");
127
+ return title && nameSource(el).source !== "title" ? title : "";
128
+ };`;
@@ -1,4 +1,8 @@
1
- const SYMBOLS = { pass: "✓", fail: "✗", pending: "—" };
1
+ const SYMBOLS = {
2
+ pass: "✓",
3
+ fail: "✗",
4
+ pending: "—",
5
+ };
2
6
  function formatMessage({ action, selector, timeout, reason, checks, coveredBy, state, }) {
3
7
  switch (reason) {
4
8
  case "unreadable":