@itmetsam/samurai-framework 0.21.3 → 0.21.5

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 (112) hide show
  1. package/dist/api.d.ts +3 -1
  2. package/dist/assert/asymmetric.d.ts +29 -0
  3. package/dist/assert/asymmetric.js +166 -0
  4. package/dist/assert/element-probes.d.ts +20 -0
  5. package/dist/assert/element-probes.js +45 -0
  6. package/dist/assert/expect.d.ts +20 -9
  7. package/dist/assert/expect.js +12 -4
  8. package/dist/assert/locator-assertion-base.d.ts +52 -0
  9. package/dist/assert/locator-assertion-base.js +78 -0
  10. package/dist/assert/locator-assertions.d.ts +26 -49
  11. package/dist/assert/locator-assertions.js +35 -78
  12. package/dist/assert/more-locator-assertions.d.ts +30 -0
  13. package/dist/assert/more-locator-assertions.js +86 -0
  14. package/dist/assert/retry-assertions.d.ts +28 -0
  15. package/dist/assert/retry-assertions.js +113 -0
  16. package/dist/assert/text-match.d.ts +2 -0
  17. package/dist/assert/text-match.js +8 -0
  18. package/dist/assert/value-assertions.d.ts +29 -1
  19. package/dist/assert/value-assertions.js +84 -5
  20. package/dist/assert/value-matchers.d.ts +19 -0
  21. package/dist/assert/value-matchers.js +53 -0
  22. package/dist/browser/browser.d.ts +9 -0
  23. package/dist/browser/browser.js +25 -5
  24. package/dist/browser/cdp.d.ts +11 -0
  25. package/dist/browser/cdp.js +28 -0
  26. package/dist/browser/chrome-launch.d.ts +4 -0
  27. package/dist/browser/chrome-launch.js +2 -2
  28. package/dist/browser/index.d.ts +5 -0
  29. package/dist/browser/index.js +4 -0
  30. package/dist/browser/launch-firefox.js +1 -1
  31. package/dist/browser/launch-flags.d.ts +4 -1
  32. package/dist/browser/launch-flags.js +18 -5
  33. package/dist/browser/navigation-error.d.ts +5 -1
  34. package/dist/browser/navigation-error.js +10 -2
  35. package/dist/browser/page-content.d.ts +5 -0
  36. package/dist/browser/page-content.js +43 -0
  37. package/dist/browser/page-evaluate.d.ts +9 -0
  38. package/dist/browser/page-evaluate.js +20 -0
  39. package/dist/browser/page-locators.d.ts +1 -1
  40. package/dist/browser/page-locators.js +1 -1
  41. package/dist/browser/page-navigation.d.ts +18 -0
  42. package/dist/browser/page-navigation.js +52 -2
  43. package/dist/browser/page-settings.d.ts +1 -1
  44. package/dist/browser/page.d.ts +52 -1
  45. package/dist/browser/page.js +66 -2
  46. package/dist/browser/session-capabilities.d.ts +10 -0
  47. package/dist/browser/session-capabilities.js +11 -0
  48. package/dist/browser/session.d.ts +2 -1
  49. package/dist/browser/session.js +3 -3
  50. package/dist/browser/storage-state.d.ts +6 -0
  51. package/dist/browser/storage-state.js +37 -0
  52. package/dist/browser/use-applier.d.ts +27 -0
  53. package/dist/browser/use-applier.js +94 -0
  54. package/dist/browser/viewport.d.ts +3 -2
  55. package/dist/browser/viewport.js +15 -2
  56. package/dist/config/config.js +2 -0
  57. package/dist/config/use-options.d.ts +16 -0
  58. package/dist/config/use-options.js +128 -0
  59. package/dist/locator/element-access.d.ts +2 -0
  60. package/dist/locator/element-reads.d.ts +17 -0
  61. package/dist/locator/element-reads.js +31 -0
  62. package/dist/locator/evaluate.d.ts +12 -0
  63. package/dist/locator/evaluate.js +36 -0
  64. package/dist/locator/locator.d.ts +23 -0
  65. package/dist/locator/locator.js +36 -1
  66. package/dist/logger/warn-once.d.ts +4 -0
  67. package/dist/logger/warn-once.js +13 -0
  68. package/dist/runner/attachments.d.ts +16 -0
  69. package/dist/runner/attachments.js +60 -0
  70. package/dist/runner/fixture-args.d.ts +15 -0
  71. package/dist/runner/fixture-args.js +95 -0
  72. package/dist/runner/fixture-defs.d.ts +22 -0
  73. package/dist/runner/fixture-defs.js +103 -0
  74. package/dist/runner/fixture-scope.d.ts +33 -0
  75. package/dist/runner/fixture-scope.js +156 -0
  76. package/dist/runner/fixtures.d.ts +34 -0
  77. package/dist/runner/fixtures.js +39 -0
  78. package/dist/runner/hooks.d.ts +6 -1
  79. package/dist/runner/hooks.js +15 -3
  80. package/dist/runner/registry.d.ts +3 -39
  81. package/dist/runner/registry.js +93 -19
  82. package/dist/runner/reporter.d.ts +7 -1
  83. package/dist/runner/reporter.js +3 -0
  84. package/dist/runner/run-test.d.ts +1 -3
  85. package/dist/runner/run-test.js +92 -34
  86. package/dist/runner/steps.d.ts +13 -0
  87. package/dist/runner/steps.js +67 -0
  88. package/dist/runner/suite.d.ts +5 -0
  89. package/dist/runner/suite.js +8 -0
  90. package/dist/runner/test-annotations.d.ts +16 -0
  91. package/dist/runner/test-annotations.js +47 -0
  92. package/dist/runner/test-function.d.ts +103 -0
  93. package/dist/runner/test-function.js +1 -0
  94. package/dist/runner/test-info.d.ts +104 -0
  95. package/dist/runner/test-info.js +169 -0
  96. package/dist/runner/test-runner.js +2 -1
  97. package/dist/runner/test-timeout.d.ts +22 -0
  98. package/dist/runner/test-timeout.js +45 -0
  99. package/dist/runner/use-options.d.ts +7 -0
  100. package/dist/runner/use-options.js +18 -0
  101. package/dist/runner/worker-session.js +2 -1
  102. package/dist/script/call-function.d.ts +7 -0
  103. package/dist/script/call-function.js +21 -3
  104. package/dist/script/element-handle.d.ts +1 -1
  105. package/dist/script/element-handle.js +1 -1
  106. package/dist/types/config.d.ts +3 -2
  107. package/dist/types/extend.d.ts +30 -0
  108. package/dist/types/test.d.ts +120 -0
  109. package/dist/types/use.d.ts +47 -0
  110. package/dist/wait/wait-until.d.ts +3 -1
  111. package/dist/wait/wait-until.js +7 -2
  112. package/package.json +1 -1
@@ -25,6 +25,7 @@ export type { PageSettings, ReadinessState };
25
25
  import { type Viewport } from "./viewport.js";
26
26
  import type { PreloadHandle } from "../script/preload.js";
27
27
  import type { BiDiConnector } from "../transport/bidi-connection.js";
28
+ import { type PageFunction } from "../script/call-function.js";
28
29
  import type { BrowsingContext } from "../types/bidi-modules/browsing-context.js";
29
30
  export type { LoadState, NavigateOptions, NavigationResult };
30
31
  export default class Page {
@@ -46,6 +47,9 @@ export default class Page {
46
47
  private browserContext;
47
48
  private transfers;
48
49
  private evidence;
50
+ private knownViewport;
51
+ /** Chrome's phone layout is on (`emulateMobile`): viewport changes keep it */
52
+ private mobile;
49
53
  /** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
50
54
  constructor(biDiConnector: BiDiConnector, contextId: string, settings?: PageSettings);
51
55
  /** The BiDi browsing context id of this page's top-level context */
@@ -61,7 +65,8 @@ export default class Page {
61
65
  /**
62
66
  * Makes this page's locators strict (or not): an action or read on a locator matching several elements then
63
67
  * 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
68
+ * to config `use.strictSelectors` (off unless set). Applies to locators made afterwards and to earlier ones that have not
69
+ * run an action or read yet: a locator keeps the answer it got at its first one
65
70
  */
66
71
  setStrictSelectors(enabled: boolean | undefined): void;
67
72
  /** Locates elements with a CSS selector */
@@ -133,14 +138,60 @@ export default class Page {
133
138
  goForward(): Promise<boolean>;
134
139
  /** The URL of the page now */
135
140
  url(): Promise<string>;
141
+ /**
142
+ * Waits until the URL matches `url` (a string equals it, or with `*` / `**` is a glob; a RegExp is tested on it;
143
+ * a function gets it as a `URL`), then until the page reached `waitUntil` (default "load"). Also sees `pushState`.
144
+ * @example
145
+ * await page.getByRole("button", { name: "Sign in" }).click();
146
+ * await page.waitForURL("**\/dashboard");
147
+ */
148
+ waitForURL(url: UrlPattern | ((url: URL) => boolean), options?: {
149
+ timeout?: number;
150
+ waitUntil?: LoadState;
151
+ }): Promise<void>;
152
+ /** The markup of the document now, doctype included */
153
+ content(): Promise<string>;
154
+ /**
155
+ * Replaces the document with `html` and waits for `waitUntil` (default "load"). Scripts and styles in it run and apply;
156
+ * the page keeps its URL and origin (it is not a `data:` page).
157
+ * @example
158
+ * await page.setContent("<button>Go</button><script>document.title = 'x'</script>");
159
+ */
160
+ setContent(html: string, options?: {
161
+ timeout?: number;
162
+ waitUntil?: LoadState;
163
+ }): Promise<void>;
136
164
  /** The title of the document now */
137
165
  title(): Promise<string>;
166
+ /**
167
+ * Runs `fn` in the page, with `arg` as its only argument, and returns the result (a promise is awaited). `fn` is a function,
168
+ * the source of one (`"async () => ..."`), or any other string, which is an expression (`"document.title"`; it takes no argument).
169
+ * Values come back as plain JavaScript: Date, Map, Set, RegExp, BigInt, NaN, undefined, nested objects and arrays;
170
+ * an element as an `ElementHandle`; what cannot be copied (a function, window, error, promise) as a `RemoteObject` with its `type`.
171
+ * The page's own error message is kept (`ScriptError`). A function written under tsx must not contain named inner functions
172
+ * (the transpiler adds a `__name` helper the page lacks); a string always works.
173
+ * @example
174
+ * const count = await page.evaluate((min) => document.querySelectorAll("li").length > min, 3);
175
+ */
176
+ evaluate<R = unknown, A = unknown>(fn: PageFunction<[A], R>, arg?: A): Promise<R>;
138
177
  /**
139
178
  * Sets the size of the viewport (and, optionally, the device pixel ratio); `null` puts the browser's own size back.
140
179
  * @example
141
180
  * await page.setViewport({ width: 390, height: 844, devicePixelRatio: 3 });
142
181
  */
143
182
  setViewport(viewport: Viewport | null): Promise<void>;
183
+ /** @internal Chrome: lays this page out like a phone from now on (`use.isMobile`), including the size it has now */
184
+ emulateMobile(): Promise<void>;
185
+ /** Playwright's name for `setViewport` */
186
+ setViewportSize(size: Viewport): Promise<void>;
187
+ /**
188
+ * The size this page was last given (`setViewport`, `setViewportSize`, or the default from config `use.viewport` when the
189
+ * browser opened it), read at once; null before one is known, after `setViewport(null)` and when config turns the viewport off.
190
+ */
191
+ viewportSize(): {
192
+ width: number;
193
+ height: number;
194
+ } | null;
144
195
  /** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
145
196
  applyDefaultViewport(): Promise<void>;
146
197
  /**
@@ -28,6 +28,8 @@ import { applyViewport } from "./viewport.js";
28
28
  import { HelperRealm } from "../locator/helper-realm.js";
29
29
  import { BiDiError } from "../transport/bidi-error.js";
30
30
  import { callFunction } from "../script/call-function.js";
31
+ import { readContent } from "./page-content.js";
32
+ import { evaluateInPage } from "./page-evaluate.js";
31
33
  export default class Page {
32
34
  /** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
33
35
  constructor(biDiConnector, contextId, settings) {
@@ -37,6 +39,9 @@ export default class Page {
37
39
  this.lazyLogs = new Lazy();
38
40
  this.errorsAllowed = false;
39
41
  this.isClosed = false;
42
+ this.knownViewport = null;
43
+ /** Chrome's phone layout is on (`emulateMobile`): viewport changes keep it */
44
+ this.mobile = false;
40
45
  this.biDiConnector = biDiConnector;
41
46
  this.id = contextId;
42
47
  this.pageConfig = new PageConfig(settings);
@@ -84,7 +89,8 @@ export default class Page {
84
89
  /**
85
90
  * Makes this page's locators strict (or not): an action or read on a locator matching several elements then
86
91
  * 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
92
+ * to config `use.strictSelectors` (off unless set). Applies to locators made afterwards and to earlier ones that have not
93
+ * run an action or read yet: a locator keeps the answer it got at its first one
88
94
  */
89
95
  setStrictSelectors(enabled) {
90
96
  this.locators.setStrictSelectors(enabled);
@@ -220,11 +226,49 @@ export default class Page {
220
226
  this.assertOpen();
221
227
  return callFunction(this.biDiConnector, this.id, "() => location.href", [], { awaitPromise: false });
222
228
  }
229
+ /**
230
+ * Waits until the URL matches `url` (a string equals it, or with `*` / `**` is a glob; a RegExp is tested on it;
231
+ * a function gets it as a `URL`), then until the page reached `waitUntil` (default "load"). Also sees `pushState`.
232
+ * @example
233
+ * await page.getByRole("button", { name: "Sign in" }).click();
234
+ * await page.waitForURL("**\/dashboard");
235
+ */
236
+ waitForURL(url, options) {
237
+ return this.nav.waitForURL(url, options);
238
+ }
239
+ /** The markup of the document now, doctype included */
240
+ async content() {
241
+ this.assertOpen();
242
+ return readContent(this.biDiConnector, this.id);
243
+ }
244
+ /**
245
+ * Replaces the document with `html` and waits for `waitUntil` (default "load"). Scripts and styles in it run and apply;
246
+ * the page keeps its URL and origin (it is not a `data:` page).
247
+ * @example
248
+ * await page.setContent("<button>Go</button><script>document.title = 'x'</script>");
249
+ */
250
+ async setContent(html, options) {
251
+ return this.nav.setContent(html, options);
252
+ }
223
253
  /** The title of the document now */
224
254
  async title() {
225
255
  this.assertOpen();
226
256
  return callFunction(this.biDiConnector, this.id, "() => document.title", [], { awaitPromise: false });
227
257
  }
258
+ /**
259
+ * Runs `fn` in the page, with `arg` as its only argument, and returns the result (a promise is awaited). `fn` is a function,
260
+ * the source of one (`"async () => ..."`), or any other string, which is an expression (`"document.title"`; it takes no argument).
261
+ * Values come back as plain JavaScript: Date, Map, Set, RegExp, BigInt, NaN, undefined, nested objects and arrays;
262
+ * an element as an `ElementHandle`; what cannot be copied (a function, window, error, promise) as a `RemoteObject` with its `type`.
263
+ * The page's own error message is kept (`ScriptError`). A function written under tsx must not contain named inner functions
264
+ * (the transpiler adds a `__name` helper the page lacks); a string always works.
265
+ * @example
266
+ * const count = await page.evaluate((min) => document.querySelectorAll("li").length > min, 3);
267
+ */
268
+ async evaluate(fn, arg) {
269
+ this.assertOpen();
270
+ return evaluateInPage(this.biDiConnector, this.id, fn, arg);
271
+ }
228
272
  /**
229
273
  * Sets the size of the viewport (and, optionally, the device pixel ratio); `null` puts the browser's own size back.
230
274
  * @example
@@ -232,7 +276,27 @@ export default class Page {
232
276
  */
233
277
  async setViewport(viewport) {
234
278
  this.assertOpen();
235
- await applyViewport(this.biDiConnector, this.id, viewport);
279
+ await applyViewport(this.biDiConnector, this.id, viewport, this.mobile);
280
+ this.knownViewport = viewport && {
281
+ width: viewport.width,
282
+ height: viewport.height,
283
+ };
284
+ }
285
+ /** @internal Chrome: lays this page out like a phone from now on (`use.isMobile`), including the size it has now */
286
+ async emulateMobile() {
287
+ this.mobile = true;
288
+ await this.setViewport(this.knownViewport);
289
+ }
290
+ /** Playwright's name for `setViewport` */
291
+ setViewportSize(size) {
292
+ return this.setViewport(size);
293
+ }
294
+ /**
295
+ * The size this page was last given (`setViewport`, `setViewportSize`, or the default from config `use.viewport` when the
296
+ * browser opened it), read at once; null before one is known, after `setViewport(null)` and when config turns the viewport off.
297
+ */
298
+ viewportSize() {
299
+ return this.knownViewport && { ...this.knownViewport };
236
300
  }
237
301
  /** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
238
302
  async applyDefaultViewport() {
@@ -1,3 +1,4 @@
1
+ import type { UseOptions } from "../types/use.js";
1
2
  /**
2
3
  * Sent with session.new (Firefox) or as capabilities of the chromedriver session (Chrome). With "ignore" the browser
3
4
  * leaves a JavaScript dialog open and reports it (`browsingContext.userPromptOpened`), so the framework decides how
@@ -10,3 +11,12 @@ export declare const SESSION_CAPABILITIES: {
10
11
  };
11
12
  };
12
13
  };
14
+ /** The session capabilities for a launch: the ones above, plus the `use` option that has to be set when the browser starts */
15
+ export declare function sessionCapabilities(use?: Pick<UseOptions, "acceptInsecureCerts">): {
16
+ alwaysMatch: {
17
+ acceptInsecureCerts?: boolean | undefined;
18
+ unhandledPromptBehavior: {
19
+ default: string;
20
+ };
21
+ };
22
+ };
@@ -6,3 +6,14 @@
6
6
  export const SESSION_CAPABILITIES = {
7
7
  alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } },
8
8
  };
9
+ /** The session capabilities for a launch: the ones above, plus the `use` option that has to be set when the browser starts */
10
+ export function sessionCapabilities(use) {
11
+ return {
12
+ alwaysMatch: {
13
+ ...SESSION_CAPABILITIES.alwaysMatch,
14
+ ...(use?.acceptInsecureCerts !== undefined && {
15
+ acceptInsecureCerts: use.acceptInsecureCerts,
16
+ }),
17
+ },
18
+ };
19
+ }
@@ -1,10 +1,11 @@
1
1
  import type { BiDiConnector } from "../transport/bidi-connection.js";
2
+ import type { UseOptions } from "../types/use.js";
2
3
  /** session.new got no reply in time */
3
4
  export declare class BrowserStartTimeoutError extends Error {
4
5
  constructor(label: string, timeout: number);
5
6
  }
6
7
  /** Sends session.new with its own, longer timeout; `label` names the browser in the timeout error */
7
- export declare function startSession(connector: Pick<BiDiConnector, "send">, label: string, commandTimeout: number | undefined): Promise<void>;
8
+ export declare function startSession(connector: Pick<BiDiConnector, "send">, label: string, commandTimeout: number | undefined, use?: UseOptions): Promise<void>;
8
9
  /**
9
10
  * Stops what a failed launch started. A browser left running holds the profile (the next launch
10
11
  * fails with "already open") and keeps the test process alive.
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_COMMAND_TIMEOUT } from "../config/defaults.js";
2
2
  import { BiDiError } from "../transport/bidi-error.js";
3
- import { SESSION_CAPABILITIES } from "./session-capabilities.js";
3
+ import { sessionCapabilities } from "./session-capabilities.js";
4
4
  /** session.new waits at least this long (ms): the first start after an install can take a while (macOS checks the new app) */
5
5
  const SESSION_START_TIMEOUT = 120_000;
6
6
  /** session.new got no reply in time */
@@ -11,10 +11,10 @@ export class BrowserStartTimeoutError extends Error {
11
11
  }
12
12
  }
13
13
  /** Sends session.new with its own, longer timeout; `label` names the browser in the timeout error */
14
- export async function startSession(connector, label, commandTimeout) {
14
+ export async function startSession(connector, label, commandTimeout, use) {
15
15
  const timeout = Math.max(commandTimeout ?? DEFAULT_COMMAND_TIMEOUT, SESSION_START_TIMEOUT);
16
16
  try {
17
- await connector.send("session.new", { capabilities: SESSION_CAPABILITIES }, { timeout });
17
+ await connector.send("session.new", { capabilities: sessionCapabilities(use) }, { timeout });
18
18
  }
19
19
  catch (err) {
20
20
  if (err instanceof BiDiError && err.code === "timeout")
@@ -0,0 +1,6 @@
1
+ import type { StorageState } from "../types/use.js";
2
+ import type { CookieInput } from "./browser-context.js";
3
+ /** The state itself, or the JSON file (relative to `baseDir`) that holds it. Throws `use(): what is wrong`. */
4
+ export declare function loadStorageState(source: string | StorageState, baseDir: string): StorageState;
5
+ /** The state's cookies as `context.addCookies()` takes them; `expires` of -1 (or none) is a session cookie */
6
+ export declare function cookiesOf(state: StorageState): CookieInput[];
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { checkStorageStateObject } from "../config/use-options.js";
4
+ /** The state itself, or the JSON file (relative to `baseDir`) that holds it. Throws `use(): what is wrong`. */
5
+ export function loadStorageState(source, baseDir) {
6
+ if (typeof source !== "string")
7
+ return source;
8
+ const file = path.resolve(baseDir, source);
9
+ let state;
10
+ try {
11
+ state = JSON.parse(readFileSync(file, "utf8"));
12
+ }
13
+ catch (err) {
14
+ throw new Error(`use(): storageState file ${JSON.stringify(source)} could not be read: ${err instanceof Error ? err.message : String(err)}`);
15
+ }
16
+ if (typeof state !== "object" || state === null || Array.isArray(state))
17
+ throw new Error(`use(): storageState file ${JSON.stringify(source)} must hold an object`);
18
+ checkStorageStateObject(state);
19
+ return state;
20
+ }
21
+ const SAME_SITE = { Strict: "strict", Lax: "lax", None: "none" };
22
+ /** The state's cookies as `context.addCookies()` takes them; `expires` of -1 (or none) is a session cookie */
23
+ export function cookiesOf(state) {
24
+ return (state.cookies ?? []).map((cookie) => ({
25
+ name: cookie.name,
26
+ value: cookie.value,
27
+ domain: cookie.domain,
28
+ ...(cookie.path !== undefined && { path: cookie.path }),
29
+ ...(cookie.expires !== undefined &&
30
+ cookie.expires > 0 && { expiry: cookie.expires }),
31
+ ...(cookie.httpOnly !== undefined && { httpOnly: cookie.httpOnly }),
32
+ ...(cookie.secure !== undefined && { secure: cookie.secure }),
33
+ ...(cookie.sameSite !== undefined && {
34
+ sameSite: SAME_SITE[cookie.sameSite],
35
+ }),
36
+ }));
37
+ }
@@ -0,0 +1,27 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
2
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
3
+ import type { UseOptions } from "../types/use.js";
4
+ import type { BrowserContext } from "./browser-context.js";
5
+ import type Page from "./page.js";
6
+ /** The `use` options a browser applies to its contexts and pages, by what each browser can do (see the wiki's support matrix) */
7
+ export declare class UseApplier {
8
+ private readonly connector;
9
+ private readonly options;
10
+ private readonly state;
11
+ private readonly chrome;
12
+ constructor(connector: BiDiConnector, browserName: SupportedBrowser, options: UseOptions, projectDir: string);
13
+ /** What new contexts accept unless they are told otherwise */
14
+ get acceptInsecureCerts(): boolean | undefined;
15
+ /** Whether pages are laid out like a phone: Chrome only */
16
+ get isMobile(): boolean;
17
+ /** Whether pages have touch input: Chrome only */
18
+ get hasTouch(): boolean;
19
+ /**
20
+ * Sets up a context: locale, timezone, user agent, touch and JavaScript through BiDi emulation, the extra headers,
21
+ * then the storage state. Firefox gets JavaScript and the colour scheme when it starts (a profile setting).
22
+ */
23
+ context(context: BrowserContext): Promise<void>;
24
+ /** Sets up a page of a context this applier set up: Chrome's phone layout and colour scheme (CDP, so per page) */
25
+ page(page: Page): Promise<void>;
26
+ private restore;
27
+ }
@@ -0,0 +1,94 @@
1
+ import { warnOnce } from "../logger/warn-once.js";
2
+ import { cdpSend, sendRaw } from "./cdp.js";
3
+ import { cookiesOf, loadStorageState } from "./storage-state.js";
4
+ /** The `use` options a browser applies to its contexts and pages, by what each browser can do (see the wiki's support matrix) */
5
+ export class UseApplier {
6
+ constructor(connector, browserName, options, projectDir) {
7
+ this.connector = connector;
8
+ this.options = options;
9
+ this.chrome = browserName === "chrome";
10
+ // Read now: a wrong path fails the test before it starts
11
+ if (options.storageState !== undefined)
12
+ this.state = loadStorageState(options.storageState, projectDir);
13
+ if (!this.chrome) {
14
+ if (options.isMobile)
15
+ warnOnce("use.isMobile", "use.isMobile only works on Chrome: it is ignored on Firefox");
16
+ if (options.hasTouch)
17
+ warnOnce("use.hasTouch", "use.hasTouch only works on Chrome: it is ignored on Firefox");
18
+ }
19
+ }
20
+ /** What new contexts accept unless they are told otherwise */
21
+ get acceptInsecureCerts() {
22
+ return this.options.acceptInsecureCerts;
23
+ }
24
+ /** Whether pages are laid out like a phone: Chrome only */
25
+ get isMobile() {
26
+ return this.chrome && this.options.isMobile === true;
27
+ }
28
+ /** Whether pages have touch input: Chrome only */
29
+ get hasTouch() {
30
+ return this.chrome && this.options.hasTouch === true;
31
+ }
32
+ /**
33
+ * Sets up a context: locale, timezone, user agent, touch and JavaScript through BiDi emulation, the extra headers,
34
+ * then the storage state. Firefox gets JavaScript and the colour scheme when it starts (a profile setting).
35
+ */
36
+ async context(context) {
37
+ const { options } = this;
38
+ const emulation = {
39
+ ...(options.locale !== undefined && { locale: options.locale }),
40
+ ...(options.timezoneId !== undefined && { timezone: options.timezoneId }),
41
+ ...(options.userAgent !== undefined && { userAgent: options.userAgent }),
42
+ ...(this.hasTouch && { touch: 1 }),
43
+ ...(this.chrome &&
44
+ options.javaScriptEnabled === false && { javaScriptEnabled: false }),
45
+ };
46
+ if (Object.keys(emulation).length > 0)
47
+ await context.emulate(emulation);
48
+ if (options.extraHTTPHeaders)
49
+ await sendRaw(this.connector, "network.setExtraHeaders", {
50
+ headers: Object.entries(options.extraHTTPHeaders).map(([name, value]) => ({ name, value: { type: "string", value } })),
51
+ userContexts: [context.id],
52
+ });
53
+ if (this.state)
54
+ await this.restore(context, this.state);
55
+ }
56
+ /** Sets up a page of a context this applier set up: Chrome's phone layout and colour scheme (CDP, so per page) */
57
+ async page(page) {
58
+ if (!this.chrome)
59
+ return;
60
+ if (this.isMobile)
61
+ await page.emulateMobile();
62
+ if (this.options.colorScheme !== undefined)
63
+ await cdpSend(this.connector, page.contextId, "Emulation.setEmulatedMedia", {
64
+ features: [
65
+ { name: "prefers-color-scheme", value: this.options.colorScheme },
66
+ ],
67
+ });
68
+ }
69
+ async restore(context, state) {
70
+ const cookies = cookiesOf(state);
71
+ if (cookies.length > 0)
72
+ await context.addCookies(cookies);
73
+ for (const { origin, localStorage } of state.origins ?? []) {
74
+ if (localStorage.length === 0)
75
+ continue;
76
+ await writeLocalStorage(context, origin, localStorage);
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Local storage belongs to an origin, so the browser has to be on it: a scratch tab opens the origin (the request is
82
+ * answered here, it never reaches the network), writes the entries and closes again.
83
+ */
84
+ async function writeLocalStorage(context, origin, entries) {
85
+ const tab = await context.newPage();
86
+ try {
87
+ await tab.route(`${origin}/**`, (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html>" }));
88
+ await tab.navigateTo(`${origin}/`);
89
+ await tab.evaluate((items) => items.forEach(({ name, value }) => localStorage.setItem(name, value)), entries);
90
+ }
91
+ finally {
92
+ await tab.close();
93
+ }
94
+ }
@@ -9,6 +9,7 @@ export interface Viewport {
9
9
  export declare function validateViewport({ width, height, devicePixelRatio, }: Viewport): void;
10
10
  /**
11
11
  * Gives the context this size (and optionally this device pixel ratio); `null` puts the browser's own size back
12
- * and waits until the page has really got it.
12
+ * and waits until the page has really got it. A `mobile` page (Chrome's phone layout, `use.isMobile`) is sized
13
+ * through CDP, as BiDi's `setViewport` would turn the phone layout off again.
13
14
  */
14
- export declare function applyViewport(connector: BiDiConnector, context: string, viewport: Viewport | null): Promise<void>;
15
+ export declare function applyViewport(connector: BiDiConnector, context: string, viewport: Viewport | null, mobile?: boolean): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { callFunction } from "../script/call-function.js";
2
+ import { cdpSend } from "./cdp.js";
2
3
  /** Time (ms) without a `resize` event after which a viewport reset counts as done */
3
4
  const RESIZE_QUIET_TIME = 50;
4
5
  /** Time (ms) a viewport reset waits for a `resize` event that may never come (the size did not change) */
@@ -20,9 +21,21 @@ export function validateViewport({ width, height, devicePixelRatio, }) {
20
21
  }
21
22
  /**
22
23
  * Gives the context this size (and optionally this device pixel ratio); `null` puts the browser's own size back
23
- * and waits until the page has really got it.
24
+ * and waits until the page has really got it. A `mobile` page (Chrome's phone layout, `use.isMobile`) is sized
25
+ * through CDP, as BiDi's `setViewport` would turn the phone layout off again.
24
26
  */
25
- export async function applyViewport(connector, context, viewport) {
27
+ export async function applyViewport(connector, context, viewport, mobile = false) {
28
+ if (mobile) {
29
+ if (viewport)
30
+ validateViewport(viewport);
31
+ await cdpSend(connector, context, "Emulation.setDeviceMetricsOverride", {
32
+ width: viewport?.width ?? 0,
33
+ height: viewport?.height ?? 0,
34
+ deviceScaleFactor: viewport ? (viewport.devicePixelRatio ?? 1) : 0,
35
+ mobile: true,
36
+ });
37
+ return;
38
+ }
26
39
  if (viewport === null) {
27
40
  await untilResized(connector, context, () => connector.send("browsingContext.setViewport", {
28
41
  context,
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import logger from "../logger/index.js";
3
3
  import { pathToFileURL } from "url";
4
4
  import { validateBrowserConfig } from "./browsers.js";
5
+ import { validateUseConfig } from "./use-options.js";
5
6
  import { validateForbidOnlyConfig } from "./forbid-only.js";
6
7
  import { validateRetriesConfig } from "./retries.js";
7
8
  import { validateWorkersConfig } from "./workers.js";
@@ -30,6 +31,7 @@ export function validateConfig(config) {
30
31
  validateWorkersConfig(config);
31
32
  validateForbidOnlyConfig(config);
32
33
  validateRetriesConfig(config);
34
+ validateUseConfig(config);
33
35
  }
34
36
  /** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
35
37
  export async function loadRawConfig() {
@@ -0,0 +1,16 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ import type { UseOptions } from "../types/use.js";
3
+ /**
4
+ * Checks the options of `test.use()` or of the config's `use` and returns them. The config's `use` also holds
5
+ * options that are not these (`viewport`, `headless`, ...), so it passes `allowOthers`; `test.use()` refuses
6
+ * a key it does not know. Throws `use(): what is wrong`.
7
+ */
8
+ export declare function validateUseOptions(options: unknown, allowOthers?: boolean): UseOptions;
9
+ /** Checks a storage state object, whether it came from the options or from a file */
10
+ export declare function checkStorageStateObject(state: Record<string, unknown>): void;
11
+ /** The options after `layers` are applied in order: a later layer wins, key by key */
12
+ export declare function mergeUse(layers: readonly UseOptions[]): UseOptions;
13
+ /** Checks the new options of a config's `use` */
14
+ export declare function validateUseConfig(config: SamuraiTestConfig): void;
15
+ /** The options of `test.use()` that a config's `use` holds, without the rest (`viewport`, `headless`, ...) */
16
+ export declare function useOptionsOf(configUse: object | undefined): UseOptions;
@@ -0,0 +1,128 @@
1
+ const NAMES = [
2
+ "locale",
3
+ "timezoneId",
4
+ "userAgent",
5
+ "javaScriptEnabled",
6
+ "acceptInsecureCerts",
7
+ "hasTouch",
8
+ "isMobile",
9
+ "storageState",
10
+ "extraHTTPHeaders",
11
+ "colorScheme",
12
+ ];
13
+ const BOOLEANS = [
14
+ "javaScriptEnabled",
15
+ "acceptInsecureCerts",
16
+ "hasTouch",
17
+ "isMobile",
18
+ ];
19
+ const COLOR_SCHEMES = ["light", "dark", "no-preference"];
20
+ const HEADER_NAME = /^[\w!#$%&'*+.^`|~-]+$/;
21
+ const fail = (problem) => {
22
+ throw new TypeError(`use(): ${problem}`);
23
+ };
24
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+ /**
26
+ * Checks the options of `test.use()` or of the config's `use` and returns them. The config's `use` also holds
27
+ * options that are not these (`viewport`, `headless`, ...), so it passes `allowOthers`; `test.use()` refuses
28
+ * a key it does not know. Throws `use(): what is wrong`.
29
+ */
30
+ export function validateUseOptions(options, allowOthers = false) {
31
+ if (!isObject(options))
32
+ return fail("the options must be an object");
33
+ for (const key of Object.keys(options))
34
+ if (!allowOthers && !NAMES.includes(key))
35
+ fail(`unknown option "${key}"`);
36
+ const use = options;
37
+ for (const name of BOOLEANS)
38
+ if (use[name] !== undefined && typeof use[name] !== "boolean")
39
+ fail(`${name} must be true or false, got ${JSON.stringify(use[name])}`);
40
+ checkLocale(use.locale);
41
+ checkTimezone(use.timezoneId);
42
+ if (use.userAgent !== undefined && !nonEmpty(use.userAgent))
43
+ fail("userAgent must be a non-empty string");
44
+ if (use.colorScheme !== undefined && !COLOR_SCHEMES.includes(use.colorScheme))
45
+ fail(`colorScheme must be "light", "dark" or "no-preference", got ${JSON.stringify(use.colorScheme)}`);
46
+ checkHeaders(use.extraHTTPHeaders);
47
+ checkStorageState(use.storageState);
48
+ return use;
49
+ }
50
+ const nonEmpty = (value) => typeof value === "string" && value !== "";
51
+ function checkLocale(locale) {
52
+ if (locale === undefined)
53
+ return;
54
+ try {
55
+ if (!nonEmpty(locale) || Intl.getCanonicalLocales(locale).length !== 1)
56
+ throw new RangeError();
57
+ }
58
+ catch {
59
+ fail(`invalid locale ${JSON.stringify(locale)}`);
60
+ }
61
+ }
62
+ function checkTimezone(timezone) {
63
+ if (timezone === undefined)
64
+ return;
65
+ try {
66
+ if (!nonEmpty(timezone))
67
+ throw new RangeError();
68
+ new Intl.DateTimeFormat(undefined, { timeZone: timezone });
69
+ }
70
+ catch {
71
+ fail(`unknown timezoneId ${JSON.stringify(timezone)}`);
72
+ }
73
+ }
74
+ function checkHeaders(headers) {
75
+ if (headers === undefined)
76
+ return;
77
+ if (!isObject(headers))
78
+ fail("extraHTTPHeaders must be an object of header names and values");
79
+ for (const [name, value] of Object.entries(headers))
80
+ if (!HEADER_NAME.test(name))
81
+ fail(`extraHTTPHeaders has an invalid header name "${name}"`);
82
+ else if (typeof value !== "string")
83
+ fail(`extraHTTPHeaders "${name}" must be a string`);
84
+ }
85
+ function checkStorageState(state) {
86
+ if (state === undefined)
87
+ return;
88
+ if (typeof state === "string") {
89
+ if (state === "")
90
+ fail("storageState must not be an empty path");
91
+ return;
92
+ }
93
+ if (!isObject(state))
94
+ return fail("storageState must be a file path or an object");
95
+ checkStorageStateObject(state);
96
+ }
97
+ /** Checks a storage state object, whether it came from the options or from a file */
98
+ export function checkStorageStateObject(state) {
99
+ for (const key of ["cookies", "origins"])
100
+ if (state[key] !== undefined && !Array.isArray(state[key]))
101
+ fail(`storageState.${key} must be an array`);
102
+ for (const cookie of (state.cookies ?? [])) {
103
+ if (!isObject(cookie) ||
104
+ !nonEmpty(cookie.name) ||
105
+ typeof cookie.value !== "string" ||
106
+ !nonEmpty(cookie.domain))
107
+ fail("every storageState cookie needs a name, a value and a domain");
108
+ }
109
+ for (const origin of (state.origins ?? [])) {
110
+ if (!isObject(origin) ||
111
+ !nonEmpty(origin.origin) ||
112
+ !Array.isArray(origin.localStorage ?? []))
113
+ fail("every storageState origin needs an origin and a localStorage list");
114
+ }
115
+ }
116
+ /** The options after `layers` are applied in order: a later layer wins, key by key */
117
+ export function mergeUse(layers) {
118
+ return Object.assign({}, ...layers);
119
+ }
120
+ /** Checks the new options of a config's `use` */
121
+ export function validateUseConfig(config) {
122
+ if (config.use !== undefined)
123
+ validateUseOptions(config.use, true);
124
+ }
125
+ /** The options of `test.use()` that a config's `use` holds, without the rest (`viewport`, `headless`, ...) */
126
+ export function useOptionsOf(configUse) {
127
+ return Object.fromEntries(Object.entries(configUse ?? {}).filter(([key]) => NAMES.includes(key)));
128
+ }
@@ -8,6 +8,8 @@ export interface ElementAccess extends ElementContext {
8
8
  * throw instead (`method` is the public method that asked, for the message)
9
9
  */
10
10
  resolveOne(method?: string): Promise<ElementHandle | null>;
11
+ /** Every element the locator matches now, in document order; never strict, never waits */
12
+ resolveAll(): Promise<ElementHandle[]>;
11
13
  }
12
14
  /** Calls `fn` in the page; a throwing function becomes a "Failed to locate element" error */
13
15
  export declare function callInPage<T>(access: Pick<ElementAccess, "connector" | "contextId" | "selector">, fn: string, args: Arg[], sandbox?: string): Promise<T>;