@itmetsam/samurai-framework 0.21.5 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/api.d.ts +6 -1
  2. package/dist/api.js +1 -0
  3. package/dist/browser/browser-context.d.ts +18 -6
  4. package/dist/browser/browser-context.js +25 -7
  5. package/dist/browser/browser.d.ts +4 -4
  6. package/dist/browser/browser.js +11 -8
  7. package/dist/browser/chrome-launch.js +9 -1
  8. package/dist/browser/context-cookie-store.d.ts +17 -0
  9. package/dist/browser/context-cookie-store.js +90 -0
  10. package/dist/browser/index.d.ts +3 -1
  11. package/dist/browser/index.js +1 -1
  12. package/dist/browser/keyboard.d.ts +35 -0
  13. package/dist/browser/keyboard.js +83 -0
  14. package/dist/browser/mouse.d.ts +42 -0
  15. package/dist/browser/mouse.js +66 -0
  16. package/dist/browser/page-navigation.d.ts +0 -2
  17. package/dist/browser/page-navigation.js +1 -6
  18. package/dist/browser/page.d.ts +18 -8
  19. package/dist/browser/page.js +18 -9
  20. package/dist/browser/storage-state.d.ts +0 -2
  21. package/dist/browser/storage-state.js +0 -20
  22. package/dist/browser/use-applier.d.ts +16 -5
  23. package/dist/browser/use-applier.js +44 -17
  24. package/dist/browser/use-commands.d.ts +36 -0
  25. package/dist/browser/use-commands.js +120 -0
  26. package/dist/cli/args.d.ts +8 -4
  27. package/dist/cli/args.js +8 -5
  28. package/dist/cli/console-reporter.js +5 -1
  29. package/dist/cli/main.js +1 -0
  30. package/dist/cli/usage.d.ts +1 -1
  31. package/dist/cli/usage.js +4 -1
  32. package/dist/config/base-url.d.ts +4 -0
  33. package/dist/config/base-url.js +6 -0
  34. package/dist/config/config.js +6 -0
  35. package/dist/config/fully-parallel.d.ts +3 -0
  36. package/dist/config/fully-parallel.js +6 -0
  37. package/dist/config/group-use.d.ts +8 -0
  38. package/dist/config/group-use.js +41 -0
  39. package/dist/config/resolved-config.d.ts +2 -0
  40. package/dist/config/resolved-config.js +3 -0
  41. package/dist/config/storage-state-file.d.ts +3 -0
  42. package/dist/config/storage-state-file.js +20 -0
  43. package/dist/config/use-options.d.ts +3 -1
  44. package/dist/config/use-options.js +70 -14
  45. package/dist/config/web-server-options.d.ts +7 -0
  46. package/dist/config/web-server-options.js +111 -0
  47. package/dist/locator/check-actions.js +1 -1
  48. package/dist/locator/drag-actions.d.ts +17 -0
  49. package/dist/locator/drag-actions.js +80 -0
  50. package/dist/locator/element-actions.d.ts +10 -4
  51. package/dist/locator/element-actions.js +23 -16
  52. package/dist/locator/element-state.d.ts +5 -0
  53. package/dist/locator/element-state.js +2 -1
  54. package/dist/locator/event-actions.d.ts +10 -0
  55. package/dist/locator/event-actions.js +81 -0
  56. package/dist/locator/input-actions.d.ts +2 -11
  57. package/dist/locator/input-actions.js +3 -56
  58. package/dist/locator/key-actions.d.ts +11 -0
  59. package/dist/locator/key-actions.js +52 -0
  60. package/dist/locator/key-map.d.ts +16 -0
  61. package/dist/locator/key-map.js +76 -0
  62. package/dist/locator/locator.d.ts +35 -5
  63. package/dist/locator/locator.js +70 -16
  64. package/dist/locator/page-scripts.d.ts +2 -0
  65. package/dist/locator/page-scripts.js +7 -0
  66. package/dist/locator/pointer-actions.d.ts +44 -0
  67. package/dist/locator/pointer-actions.js +117 -0
  68. package/dist/locator/pointer-element-actions.d.ts +21 -0
  69. package/dist/locator/pointer-element-actions.js +42 -0
  70. package/dist/request/api-request-context.d.ts +65 -0
  71. package/dist/request/api-request-context.js +198 -0
  72. package/dist/request/api-response.d.ts +34 -0
  73. package/dist/request/api-response.js +57 -0
  74. package/dist/request/cookie-jar.d.ts +48 -0
  75. package/dist/request/cookie-jar.js +164 -0
  76. package/dist/request/http-transport.d.ts +31 -0
  77. package/dist/request/http-transport.js +113 -0
  78. package/dist/request/redirect.d.ts +10 -0
  79. package/dist/request/redirect.js +21 -0
  80. package/dist/request/request-body.d.ts +27 -0
  81. package/dist/request/request-body.js +71 -0
  82. package/dist/request/request.d.ts +16 -0
  83. package/dist/request/request.js +20 -0
  84. package/dist/runner/all-hooks.d.ts +7 -1
  85. package/dist/runner/all-hooks.js +17 -1
  86. package/dist/runner/fixture-defs.js +1 -0
  87. package/dist/runner/fixtures.d.ts +4 -1
  88. package/dist/runner/fixtures.js +3 -2
  89. package/dist/runner/global-hooks.d.ts +13 -0
  90. package/dist/runner/global-hooks.js +66 -0
  91. package/dist/runner/options.d.ts +13 -2
  92. package/dist/runner/output-tail.d.ts +14 -0
  93. package/dist/runner/output-tail.js +30 -0
  94. package/dist/runner/pool-schedule.d.ts +22 -0
  95. package/dist/runner/pool-schedule.js +55 -0
  96. package/dist/runner/pool-units.d.ts +34 -0
  97. package/dist/runner/pool-units.js +85 -0
  98. package/dist/runner/pooled-run.d.ts +7 -5
  99. package/dist/runner/pooled-run.js +19 -9
  100. package/dist/runner/registry.js +6 -2
  101. package/dist/runner/reporter.d.ts +10 -0
  102. package/dist/runner/reporter.js +12 -2
  103. package/dist/runner/request-fixture.d.ts +14 -0
  104. package/dist/runner/request-fixture.js +25 -0
  105. package/dist/runner/run-hooks.d.ts +16 -0
  106. package/dist/runner/run-hooks.js +52 -0
  107. package/dist/runner/run-test.js +7 -2
  108. package/dist/runner/run.d.ts +1 -0
  109. package/dist/runner/run.js +1 -0
  110. package/dist/runner/select.d.ts +15 -4
  111. package/dist/runner/select.js +38 -12
  112. package/dist/runner/serial-run.d.ts +23 -0
  113. package/dist/runner/serial-run.js +88 -0
  114. package/dist/runner/serial.d.ts +34 -0
  115. package/dist/runner/serial.js +63 -0
  116. package/dist/runner/shard.d.ts +22 -0
  117. package/dist/runner/shard.js +73 -0
  118. package/dist/runner/suite.d.ts +3 -3
  119. package/dist/runner/suite.js +4 -3
  120. package/dist/runner/test-function.d.ts +13 -1
  121. package/dist/runner/test-info.js +2 -1
  122. package/dist/runner/test-runner.d.ts +1 -0
  123. package/dist/runner/test-runner.js +62 -18
  124. package/dist/runner/use-options.d.ts +4 -4
  125. package/dist/runner/use-options.js +10 -3
  126. package/dist/runner/wait-for-url.d.ts +20 -0
  127. package/dist/runner/wait-for-url.js +65 -0
  128. package/dist/runner/web-server.d.ts +16 -0
  129. package/dist/runner/web-server.js +165 -0
  130. package/dist/runner/worker-pool.d.ts +13 -5
  131. package/dist/runner/worker-pool.js +85 -51
  132. package/dist/runner/worker-protocol.d.ts +8 -0
  133. package/dist/runner/worker-session.js +3 -1
  134. package/dist/runner/worker.d.ts +2 -0
  135. package/dist/runner/worker.js +7 -1
  136. package/dist/types/config.d.ts +83 -0
  137. package/dist/types/test.d.ts +18 -0
  138. package/dist/types/use.d.ts +45 -7
  139. package/package.json +1 -1
  140. package/dist/browser/emulation.d.ts +0 -61
  141. package/dist/browser/emulation.js +0 -180
@@ -1,3 +1,4 @@
1
+ import type { APIRequestContext } from "../request/api-request-context.js";
1
2
  import Locator from "../locator/locator.js";
2
3
  import type { FilterOptions } from "../locator/filter-step.js";
3
4
  import type { RoleOptions } from "../locator/role-locator.js";
@@ -7,7 +8,7 @@ import type { Recorder, RecorderOptions } from "../recorder/recorder.js";
7
8
  import { ContextTree } from "./context-tree.js";
8
9
  import type { BrowserContext } from "./browser-context.js";
9
10
  import { type PdfOptions } from "./pdf.js";
10
- import { type EmulationOptions } from "./emulation.js";
11
+ import type { RuntimeUseOptions } from "../types/use.js";
11
12
  import type { Download } from "./download.js";
12
13
  import type { FileChooser } from "./file-chooser.js";
13
14
  import { type ScreenshotOptions } from "./screenshot.js";
@@ -18,6 +19,8 @@ import { Response } from "../network/response.js";
18
19
  import type { NetworkMatch } from "../network/network-wait.js";
19
20
  import type { UrlPattern } from "../network/url-match.js";
20
21
  import type { LoadState, NavigateOptions, NavigationResult } from "./page-navigation.js";
22
+ import Keyboard from "./keyboard.js";
23
+ import Mouse from "./mouse.js";
21
24
  import { type PageEventName, type PageEvents, type WaitableEventName, type WaitForEventOptions, type WaitOptions } from "./page-events.js";
22
25
  export type { PageEventName, WaitableEventName, WaitForEventOptions, WaitOptions, };
23
26
  import { type PageSettings, type ReadinessState } from "./page-settings.js";
@@ -36,6 +39,10 @@ export default class Page {
36
39
  private initScripts;
37
40
  private pageConfig;
38
41
  private locators;
42
+ /** Real key input to whatever has focus: `press` (also chords such as `Control+a`), `down`, `up`, `type`, `insertText` */
43
+ readonly keyboard: Keyboard;
44
+ /** Real pointer input at viewport coordinates: `move`, `down`, `up`, `click`, `dblclick`, `wheel` */
45
+ readonly mouse: Mouse;
39
46
  private pageEvents;
40
47
  private documents;
41
48
  private nav;
@@ -48,7 +55,7 @@ export default class Page {
48
55
  private transfers;
49
56
  private evidence;
50
57
  private knownViewport;
51
- /** Chrome's phone layout is on (`emulateMobile`): viewport changes keep it */
58
+ /** Chrome's phone layout is on (`setMobileLayout`): viewport changes keep it */
52
59
  private mobile;
53
60
  /** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
54
61
  constructor(biDiConnector: BiDiConnector, contextId: string, settings?: PageSettings);
@@ -181,7 +188,7 @@ export default class Page {
181
188
  */
182
189
  setViewport(viewport: Viewport | null): Promise<void>;
183
190
  /** @internal Chrome: lays this page out like a phone from now on (`use.isMobile`), including the size it has now */
184
- emulateMobile(): Promise<void>;
191
+ setMobileLayout(): Promise<void>;
185
192
  /** Playwright's name for `setViewport` */
186
193
  setViewportSize(size: Viewport): Promise<void>;
187
194
  /**
@@ -213,20 +220,23 @@ export default class Page {
213
220
  pdf(options?: PdfOptions): Promise<Buffer>;
214
221
  /** The browser context (cookies, storage) this page belongs to */
215
222
  context(): BrowserContext;
223
+ /** Makes HTTP requests from the test with this page's cookies; the same as `page.context().request` */
224
+ get request(): APIRequestContext;
216
225
  /** @internal Called when the page is opened in a context */
217
226
  setContext(context: BrowserContext): void;
218
227
  /** @internal Marks the page closed after its tab is gone (its context was removed) and releases what it registered */
219
228
  detach(): Promise<void>;
220
229
  /**
221
- * Pretends things about this page only: locale, timezone, user agent, geolocation, offline, orientation, screen
222
- * size. It wins over what the page's context says; `null` puts the real (or the context's) value back.
230
+ * Changes the `use` options that can change while the browser runs for this page only: locale, timezone, user
231
+ * agent, geolocation, offline, orientation, screen, hasTouch, javaScriptEnabled. It wins over what the page's
232
+ * context says; `null` puts the real (or the context's) value back. Same options as `test.use()`, applied now.
223
233
  * @example
224
- * await page.emulate({ locale: "nl-NL", offline: true });
234
+ * await page.use({ locale: "nl-NL", offline: true });
225
235
  */
226
- emulate(options: EmulationOptions): Promise<void>;
236
+ use(options: RuntimeUseOptions): Promise<void>;
227
237
  /**
228
238
  * Grants a browser permission ("geolocation", "notifications", "camera", …) to the origin this page is on,
229
- * or to `options.origin`. Geolocation emulation only answers once this is granted.
239
+ * or to `options.origin`. A geolocation override only answers once this is granted.
230
240
  */
231
241
  grantPermission(name: string, options?: {
232
242
  origin?: string;
@@ -7,7 +7,7 @@ import { DEFAULT_VIEWPORT } from "../config/defaults.js";
7
7
  import logger from "../logger/index.js";
8
8
  import { ContextTree } from "./context-tree.js";
9
9
  import { printPdf } from "./pdf.js";
10
- import { applyEmulation } from "./emulation.js";
10
+ import { applyUse } from "./use-commands.js";
11
11
  import { takeScreenshot, writeOutput, } from "./screenshot.js";
12
12
  import { Evidence } from "./evidence.js";
13
13
  import { PageLogs } from "./page-logs.js";
@@ -22,6 +22,8 @@ import { PageTransfers } from "./page-transfers.js";
22
22
  import { InitScripts } from "./init-script.js";
23
23
  import { grantPermission } from "./page-permissions.js";
24
24
  import { PageLocators } from "./page-locators.js";
25
+ import Keyboard from "./keyboard.js";
26
+ import Mouse from "./mouse.js";
25
27
  import { sourcesFor, waitForPageEvent, } from "./page-events.js";
26
28
  import { PageConfig, } from "./page-settings.js";
27
29
  import { applyViewport } from "./viewport.js";
@@ -40,13 +42,15 @@ export default class Page {
40
42
  this.errorsAllowed = false;
41
43
  this.isClosed = false;
42
44
  this.knownViewport = null;
43
- /** Chrome's phone layout is on (`emulateMobile`): viewport changes keep it */
45
+ /** Chrome's phone layout is on (`setMobileLayout`): viewport changes keep it */
44
46
  this.mobile = false;
45
47
  this.biDiConnector = biDiConnector;
46
48
  this.id = contextId;
47
49
  this.pageConfig = new PageConfig(settings);
48
50
  this.helperRealm = new HelperRealm(biDiConnector, contextId);
49
51
  this.locators = new PageLocators(biDiConnector, contextId, this.helperRealm, this.pageConfig, () => this.assertOpen());
52
+ this.keyboard = new Keyboard(biDiConnector, contextId, () => this.assertOpen());
53
+ this.mouse = new Mouse(biDiConnector, contextId, () => this.assertOpen());
50
54
  this.documents = new DocumentWatcher(biDiConnector, contextId);
51
55
  this.net = new PageNetwork(biDiConnector, contextId, {
52
56
  config: this.pageConfig,
@@ -283,7 +287,7 @@ export default class Page {
283
287
  };
284
288
  }
285
289
  /** @internal Chrome: lays this page out like a phone from now on (`use.isMobile`), including the size it has now */
286
- async emulateMobile() {
290
+ async setMobileLayout() {
287
291
  this.mobile = true;
288
292
  await this.setViewport(this.knownViewport);
289
293
  }
@@ -352,6 +356,10 @@ export default class Page {
352
356
  throw new Error("this page has no browser context: open it with browser.newPage() or context.newPage()");
353
357
  return this.browserContext;
354
358
  }
359
+ /** Makes HTTP requests from the test with this page's cookies; the same as `page.context().request` */
360
+ get request() {
361
+ return this.context().request;
362
+ }
355
363
  /** @internal Called when the page is opened in a context */
356
364
  setContext(context) {
357
365
  this.browserContext = context;
@@ -362,18 +370,19 @@ export default class Page {
362
370
  await this.dispose().catch((err) => logger.debug("Releasing a detached page failed", { err }));
363
371
  }
364
372
  /**
365
- * Pretends things about this page only: locale, timezone, user agent, geolocation, offline, orientation, screen
366
- * size. It wins over what the page's context says; `null` puts the real (or the context's) value back.
373
+ * Changes the `use` options that can change while the browser runs for this page only: locale, timezone, user
374
+ * agent, geolocation, offline, orientation, screen, hasTouch, javaScriptEnabled. It wins over what the page's
375
+ * context says; `null` puts the real (or the context's) value back. Same options as `test.use()`, applied now.
367
376
  * @example
368
- * await page.emulate({ locale: "nl-NL", offline: true });
377
+ * await page.use({ locale: "nl-NL", offline: true });
369
378
  */
370
- async emulate(options) {
379
+ async use(options) {
371
380
  this.assertOpen();
372
- await applyEmulation(this.biDiConnector, { contexts: [this.id] }, options);
381
+ await applyUse(this.biDiConnector, { contexts: [this.id] }, options);
373
382
  }
374
383
  /**
375
384
  * Grants a browser permission ("geolocation", "notifications", "camera", …) to the origin this page is on,
376
- * or to `options.origin`. Geolocation emulation only answers once this is granted.
385
+ * or to `options.origin`. A geolocation override only answers once this is granted.
377
386
  */
378
387
  async grantPermission(name, options = {}) {
379
388
  this.assertOpen();
@@ -1,6 +1,4 @@
1
1
  import type { StorageState } from "../types/use.js";
2
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
3
  /** The state's cookies as `context.addCookies()` takes them; `expires` of -1 (or none) is a session cookie */
6
4
  export declare function cookiesOf(state: StorageState): CookieInput[];
@@ -1,23 +1,3 @@
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
1
  const SAME_SITE = { Strict: "strict", Lax: "lax", None: "none" };
22
2
  /** The state's cookies as `context.addCookies()` takes them; `expires` of -1 (or none) is a session cookie */
23
3
  export function cookiesOf(state) {
@@ -1,6 +1,6 @@
1
1
  import type { SupportedBrowser } from "../types/browser.js";
2
2
  import type { BiDiConnector } from "../transport/bidi-connection.js";
3
- import type { UseOptions } from "../types/use.js";
3
+ import type { GroupUse } from "../types/config.js";
4
4
  import type { BrowserContext } from "./browser-context.js";
5
5
  import type Page from "./page.js";
6
6
  /** The `use` options a browser applies to its contexts and pages, by what each browser can do (see the wiki's support matrix) */
@@ -9,7 +9,7 @@ export declare class UseApplier {
9
9
  private readonly options;
10
10
  private readonly state;
11
11
  private readonly chrome;
12
- constructor(connector: BiDiConnector, browserName: SupportedBrowser, options: UseOptions, projectDir: string);
12
+ constructor(connector: BiDiConnector, browserName: SupportedBrowser, options: GroupUse, projectDir: string);
13
13
  /** What new contexts accept unless they are told otherwise */
14
14
  get acceptInsecureCerts(): boolean | undefined;
15
15
  /** Whether pages are laid out like a phone: Chrome only */
@@ -17,11 +17,22 @@ export declare class UseApplier {
17
17
  /** Whether pages have touch input: Chrome only */
18
18
  get hasTouch(): boolean;
19
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).
20
+ * Sets up a context: the run-time options (`page.use()`'s) through BiDi, the extra headers, then the storage state.
21
+ * An option the browser does not support is skipped with a one-time warning (`page.use()` throws instead).
22
+ * Firefox gets JavaScript and the colour scheme when it starts (a profile setting).
22
23
  */
23
24
  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
+ /**
26
+ * Sets up a page of a context this applier set up: Chrome's phone layout and colour scheme (CDP, so per page), and
27
+ * the viewport a group asked for (after the phone layout, so the size keeps it)
28
+ */
25
29
  page(page: Page): Promise<void>;
30
+ /**
31
+ * The declared options that go through BiDi. Left out: those that are not set (or `null`), the ones that change
32
+ * nothing (`offline: false`), Chrome-only touch on Firefox (warned once in the constructor) and JavaScript on
33
+ * Firefox (a profile setting).
34
+ */
35
+ private startUp;
36
+ private chromePage;
26
37
  private restore;
27
38
  }
@@ -1,6 +1,8 @@
1
1
  import { warnOnce } from "../logger/warn-once.js";
2
2
  import { cdpSend, sendRaw } from "./cdp.js";
3
- import { cookiesOf, loadStorageState } from "./storage-state.js";
3
+ import { applyUse, RUNTIME_OPTIONS } from "./use-commands.js";
4
+ import { cookiesOf } from "./storage-state.js";
5
+ import { loadStorageState } from "../config/storage-state-file.js";
4
6
  /** The `use` options a browser applies to its contexts and pages, by what each browser can do (see the wiki's support matrix) */
5
7
  export class UseApplier {
6
8
  constructor(connector, browserName, options, projectDir) {
@@ -30,21 +32,17 @@ export class UseApplier {
30
32
  return this.chrome && this.options.hasTouch === true;
31
33
  }
32
34
  /**
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
+ * Sets up a context: the run-time options (`page.use()`'s) through BiDi, the extra headers, then the storage state.
36
+ * An option the browser does not support is skipped with a one-time warning (`page.use()` throws instead).
37
+ * Firefox gets JavaScript and the colour scheme when it starts (a profile setting).
35
38
  */
36
39
  async context(context) {
37
40
  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);
41
+ await applyUse(this.connector, { userContexts: [context.id] }, this.startUp(), (option) => warnOnce(`use.${option}`, `use.${option} is not supported by this browser: it is ignored`));
42
+ context.setRequestDefaults({
43
+ extraHTTPHeaders: options.extraHTTPHeaders,
44
+ ignoreHTTPSErrors: options.acceptInsecureCerts,
45
+ });
48
46
  if (options.extraHTTPHeaders)
49
47
  await sendRaw(this.connector, "network.setExtraHeaders", {
50
48
  headers: Object.entries(options.extraHTTPHeaders).map(([name, value]) => ({ name, value: { type: "string", value } })),
@@ -53,12 +51,41 @@ export class UseApplier {
53
51
  if (this.state)
54
52
  await this.restore(context, this.state);
55
53
  }
56
- /** Sets up a page of a context this applier set up: Chrome's phone layout and colour scheme (CDP, so per page) */
54
+ /**
55
+ * Sets up a page of a context this applier set up: Chrome's phone layout and colour scheme (CDP, so per page), and
56
+ * the viewport a group asked for (after the phone layout, so the size keeps it)
57
+ */
57
58
  async page(page) {
58
- if (!this.chrome)
59
- return;
59
+ if (this.chrome)
60
+ await this.chromePage(page);
61
+ if (this.options.viewport !== undefined)
62
+ await page.setViewport(this.options.viewport);
63
+ }
64
+ /**
65
+ * The declared options that go through BiDi. Left out: those that are not set (or `null`), the ones that change
66
+ * nothing (`offline: false`), Chrome-only touch on Firefox (warned once in the constructor) and JavaScript on
67
+ * Firefox (a profile setting).
68
+ */
69
+ startUp() {
70
+ const { options } = this;
71
+ const wanted = {};
72
+ for (const name of RUNTIME_OPTIONS) {
73
+ const value = options[name];
74
+ if (value === undefined || value === null)
75
+ continue;
76
+ if (name === "offline" && value === false)
77
+ continue;
78
+ if (name === "hasTouch" && !this.hasTouch)
79
+ continue;
80
+ if (name === "javaScriptEnabled" && !(this.chrome && value === false))
81
+ continue;
82
+ wanted[name] = value;
83
+ }
84
+ return wanted;
85
+ }
86
+ async chromePage(page) {
60
87
  if (this.isMobile)
61
- await page.emulateMobile();
88
+ await page.setMobileLayout();
62
89
  if (this.options.colorScheme !== undefined)
63
90
  await cdpSend(this.connector, page.contextId, "Emulation.setEmulatedMedia", {
64
91
  features: [
@@ -0,0 +1,36 @@
1
+ import { UnsupportedOperationError } from "../transport/unsupported.js";
2
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
3
+ import type { RuntimeUseOptions } from "../types/use.js";
4
+ export type UseTarget = {
5
+ contexts: string[];
6
+ } | {
7
+ userContexts: string[];
8
+ };
9
+ /** A part of the options could not be applied; what was applied before it stays applied */
10
+ export declare class UseError extends Error {
11
+ /** The options that were applied before the failure, in the order they were applied */
12
+ readonly applied: string[];
13
+ readonly failed: string;
14
+ readonly cause: unknown;
15
+ constructor(failed: string, applied: string[], cause: unknown);
16
+ }
17
+ /** The browser does not implement the command an option needs */
18
+ export declare class UseUnsupportedError extends UnsupportedOperationError {
19
+ readonly option: string;
20
+ readonly applied: string[];
21
+ constructor(option: string, applied: string[]);
22
+ }
23
+ /** The options that can change while the browser runs, in the order they are applied */
24
+ export declare const RUNTIME_OPTIONS: readonly ["locale", "timezone", "userAgent", "geolocation", "offline", "orientation", "screen", "hasTouch", "javaScriptEnabled"];
25
+ /**
26
+ * Checks the options of `page.use()` / `context.use()` / `newContext()`: the values like config `use` does, and
27
+ * only the options that can change at run time. Throws `use(): what is wrong`, before anything is sent.
28
+ */
29
+ export declare function validateRuntimeUse(options: RuntimeUseOptions): void;
30
+ /**
31
+ * Applies the options to `target`, one command after the other, after checking all of them. Nothing is rolled
32
+ * back when one fails: the error says what was applied. An option the browser does not support throws
33
+ * `UseUnsupportedError`, unless `onUnsupported` is given: then it is reported there and skipped (how `use` in
34
+ * config behaves: it warns, `page.use()` throws).
35
+ */
36
+ export declare function applyUse(connector: BiDiConnector, target: UseTarget, options: RuntimeUseOptions, onUnsupported?: (option: string) => void): Promise<void>;
@@ -0,0 +1,120 @@
1
+ import { isUnsupported, UnsupportedOperationError, } from "../transport/unsupported.js";
2
+ import { USE_OPTION_NAMES, validateUseOptions } from "../config/use-options.js";
3
+ /** A part of the options could not be applied; what was applied before it stays applied */
4
+ export class UseError extends Error {
5
+ constructor(failed, applied, cause) {
6
+ const detail = cause instanceof Error ? cause.message : String(cause);
7
+ super(`use(): ${failed} failed${applied.length ? ` after applying ${applied.join(", ")}` : ""}: ${detail}`);
8
+ this.name = "UseError";
9
+ this.failed = failed;
10
+ this.applied = applied;
11
+ this.cause = cause;
12
+ }
13
+ }
14
+ /** The browser does not implement the command an option needs */
15
+ export class UseUnsupportedError extends UnsupportedOperationError {
16
+ constructor(option, applied) {
17
+ super(`use(): ${option} is not supported by the browser${AT_START_ONLY.has(option) ? ` at run time; set it in config use, a group's use or test.use()` : ""}`);
18
+ this.name = "UseUnsupportedError";
19
+ this.option = option;
20
+ this.applied = applied;
21
+ }
22
+ }
23
+ /** Options Firefox can only take when it starts (a profile setting), so run time is unsupported there */
24
+ const AT_START_ONLY = new Set(["javaScriptEnabled"]);
25
+ /** The options that can change while the browser runs, in the order they are applied */
26
+ export const RUNTIME_OPTIONS = [
27
+ "locale",
28
+ "timezone",
29
+ "userAgent",
30
+ "geolocation",
31
+ "offline",
32
+ "orientation",
33
+ "screen",
34
+ "hasTouch",
35
+ "javaScriptEnabled",
36
+ ];
37
+ /**
38
+ * Checks the options of `page.use()` / `context.use()` / `newContext()`: the values like config `use` does, and
39
+ * only the options that can change at run time. Throws `use(): what is wrong`, before anything is sent.
40
+ */
41
+ export function validateRuntimeUse(options) {
42
+ validateUseOptions(options);
43
+ for (const key of Object.keys(options))
44
+ if (!RUNTIME_OPTIONS.includes(key))
45
+ throw new TypeError(`use(): ${USE_OPTION_NAMES.includes(key) ? `${key} can only be set before the browser starts: in config use, a group's use or test.use()` : `unknown option "${key}"`}`);
46
+ }
47
+ /** Turns validated options into BiDi commands, in a fixed order */
48
+ function plan(options) {
49
+ const steps = [];
50
+ const add = (option, method, params) => steps.push({ option, method, params });
51
+ if (options.locale !== undefined)
52
+ add("locale", "emulation.setLocaleOverride", { locale: options.locale });
53
+ if (options.timezone !== undefined)
54
+ add("timezone", "emulation.setTimezoneOverride", {
55
+ timezone: options.timezone,
56
+ });
57
+ if (options.userAgent !== undefined)
58
+ add("userAgent", "emulation.setUserAgentOverride", {
59
+ userAgent: options.userAgent,
60
+ });
61
+ if (options.geolocation !== undefined)
62
+ add("geolocation", "emulation.setGeolocationOverride", {
63
+ coordinates: options.geolocation,
64
+ });
65
+ if (options.offline !== undefined)
66
+ add("offline", "emulation.setNetworkConditions", {
67
+ networkConditions: options.offline ? { type: "offline" } : null,
68
+ });
69
+ if (options.orientation !== undefined) {
70
+ const type = options.orientation === null
71
+ ? null
72
+ : options.orientation === "portrait" ||
73
+ options.orientation === "landscape"
74
+ ? `${options.orientation}-primary`
75
+ : options.orientation;
76
+ add("orientation", "emulation.setScreenOrientationOverride", {
77
+ screenOrientation: type && {
78
+ natural: type.startsWith("portrait") ? "portrait" : "landscape",
79
+ type,
80
+ },
81
+ });
82
+ }
83
+ if (options.screen !== undefined)
84
+ add("screen", "emulation.setScreenSettingsOverride", {
85
+ screenArea: options.screen,
86
+ });
87
+ if (options.hasTouch !== undefined)
88
+ add("hasTouch", "emulation.setTouchOverride", {
89
+ maxTouchPoints: options.hasTouch ? 1 : null,
90
+ });
91
+ if (options.javaScriptEnabled !== undefined)
92
+ add("javaScriptEnabled", "emulation.setScriptingEnabled", {
93
+ enabled: options.javaScriptEnabled === false ? false : null,
94
+ });
95
+ return steps;
96
+ }
97
+ /**
98
+ * Applies the options to `target`, one command after the other, after checking all of them. Nothing is rolled
99
+ * back when one fails: the error says what was applied. An option the browser does not support throws
100
+ * `UseUnsupportedError`, unless `onUnsupported` is given: then it is reported there and skipped (how `use` in
101
+ * config behaves: it warns, `page.use()` throws).
102
+ */
103
+ export async function applyUse(connector, target, options, onUnsupported) {
104
+ validateRuntimeUse(options);
105
+ const applied = [];
106
+ for (const step of plan(options)) {
107
+ try {
108
+ await connector.send(step.method, { ...step.params, ...target });
109
+ }
110
+ catch (err) {
111
+ if (!isUnsupported(err))
112
+ throw new UseError(step.option, applied, err);
113
+ if (!onUnsupported)
114
+ throw new UseUnsupportedError(step.option, applied);
115
+ onUnsupported(step.option);
116
+ continue;
117
+ }
118
+ applied.push(step.option);
119
+ }
120
+ }
@@ -1,4 +1,4 @@
1
- import { type ProjectOptions, type RunnerOptions } from "../runner/run.js";
1
+ import { type ProjectOptions, type RunnerOptions, type Shard } from "../runner/run.js";
2
2
  /** `firefox` or `chrome` */
3
3
  type SupportedBrowser = NonNullable<RunnerOptions["browsers"]>[number];
4
4
  /** The values a command line can override for one run: the environment and the two timeouts */
@@ -13,8 +13,9 @@ export declare function parseCommandLine(argv: string[]): {
13
13
  port?: string | undefined;
14
14
  workers?: string | undefined;
15
15
  retries?: string | undefined;
16
+ shard?: string | undefined;
16
17
  grep?: string | undefined;
17
- group?: string | undefined;
18
+ group?: string[] | undefined;
18
19
  browser?: string[] | undefined;
19
20
  file?: string[] | undefined;
20
21
  json?: boolean | undefined;
@@ -43,13 +44,16 @@ export interface RunnerFlags {
43
44
  workers?: string;
44
45
  /** `--retries`: a whole number from 0, as typed */
45
46
  retries?: string;
47
+ /** `--shard=current/total`, parsed */
48
+ shard?: Shard;
46
49
  grep?: string;
47
- group?: string;
50
+ /** `--group`: one name as typed, or the list when it is repeated */
51
+ group?: string | string[];
48
52
  files?: string[];
49
53
  /** `--browser`, repeated for more than one; replaces the config's `browsers` for this run */
50
54
  browsers?: SupportedBrowser[];
51
55
  }
52
- /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--retries <n>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (both repeatable) from `argv`, or from values that were parsed already */
56
+ /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--retries <n>`, `--shard <current/total>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (all three repeatable) from `argv`, or from values that were parsed already */
53
57
  export declare function parseRunnerFlags(source: string[] | CommandValues): RunnerFlags;
54
58
  /** Reads `--env`, `--timeout` and `--expect-timeout` from `argv` (or values parsed already); the environment falls back to SAMURAI_ENV */
55
59
  export declare function parseRunOverrides(source: string[] | CommandValues, env?: Partial<NodeJS.ProcessEnv>): EnvAndTimeouts;
package/dist/cli/args.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { checkBrowserList, parseRetries, parseWorkers, } from "../runner/run.js";
2
+ import { checkBrowserList, parseRetries, parseShard, parseWorkers, } from "../runner/run.js";
3
3
  const FLAGS = {
4
4
  env: { type: "string" },
5
5
  timeout: { type: "string" },
@@ -8,8 +8,9 @@ const FLAGS = {
8
8
  port: { type: "string" },
9
9
  workers: { type: "string" },
10
10
  retries: { type: "string" },
11
+ shard: { type: "string" },
11
12
  grep: { type: "string" },
12
- group: { type: "string" },
13
+ group: { type: "string", multiple: true },
13
14
  browser: { type: "string", multiple: true },
14
15
  file: { type: "string", multiple: true },
15
16
  json: { type: "boolean" },
@@ -35,7 +36,7 @@ export function parseCommandLine(argv) {
35
36
  });
36
37
  }
37
38
  const valuesOf = (source) => Array.isArray(source) ? parseCommandLine(source).values : source;
38
- /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--retries <n>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (both repeatable) from `argv`, or from values that were parsed already */
39
+ /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--retries <n>`, `--shard <current/total>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (all three repeatable) from `argv`, or from values that were parsed already */
39
40
  export function parseRunnerFlags(source) {
40
41
  const values = valuesOf(source);
41
42
  const flags = {};
@@ -56,10 +57,12 @@ export function parseRunnerFlags(source) {
56
57
  parseRetries(values.retries, "--retries");
57
58
  flags.retries = values.retries;
58
59
  }
60
+ if (values.shard !== undefined)
61
+ flags.shard = parseShard(values.shard, "--shard");
59
62
  if (values.grep !== undefined)
60
63
  flags.grep = values.grep;
61
- if (values.group !== undefined)
62
- flags.group = values.group;
64
+ if (values.group?.length)
65
+ flags.group = values.group.length === 1 ? values.group[0] : values.group;
63
66
  if (values.file?.length)
64
67
  flags.files = values.file;
65
68
  if (values.browser?.length) {
@@ -20,7 +20,7 @@ export class ConsoleReporter {
20
20
  switch (event.type) {
21
21
  case "run-start":
22
22
  this.labelled = event.browsers.length > 1;
23
- this.write(`Running ${event.total} ${event.total === 1 ? "test" : "tests"}${event.workers ? ` with ${event.workers} workers` : ""} against ${event.environment}${this.labelled ? ` in ${event.browsers.join(", ")}` : ""}\n`);
23
+ this.write(`Running ${event.total} ${event.total === 1 ? "test" : "tests"}${event.shard ? ` (shard ${event.shard.current}/${event.shard.total})` : ""}${event.workers ? ` with ${event.workers} workers` : ""} against ${event.environment}${this.labelled ? ` in ${event.browsers.join(", ")}` : ""}\n`);
24
24
  return;
25
25
  case "test-end":
26
26
  this.write(` ${this.line(event.name, event.result, event.browser)}\n`);
@@ -47,6 +47,10 @@ export class ConsoleReporter {
47
47
  : `${this.paint(31, "✖")} ${name}${this.tag(browser)}${took}`;
48
48
  }
49
49
  summary(summary) {
50
+ if (summary.shard && summary.tests.length === 0) {
51
+ this.write(`\n${this.paint(90, `Shard ${summary.shard.current}/${summary.shard.total} has no tests to run`)}\n`);
52
+ return;
53
+ }
50
54
  const skipped = summary.tests.filter((test) => test.status === "skipped");
51
55
  const failed = summary.tests.filter((test) => test.status !== "success" && test.status !== "skipped");
52
56
  for (const test of failed) {
package/dist/cli/main.js CHANGED
@@ -49,6 +49,7 @@ export async function main(argv, io) {
49
49
  ...(flags.port !== undefined && { port: flags.port }),
50
50
  ...(flags.workers !== undefined && { workers: flags.workers }),
51
51
  ...(flags.retries !== undefined && { retries: flags.retries }),
52
+ ...(flags.shard && { shard: flags.shard }),
52
53
  ...(flags.grep !== undefined && { grep: flags.grep }),
53
54
  ...(flags.group !== undefined && { group: flags.group }),
54
55
  ...(flags.files && { files: flags.files }),
@@ -1 +1 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups; repeat it for several\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n --shard <current/total> Run one slice of the selected tests, to split a run over machines:\n --shard=2/4 runs the second of four. Whole files (and serial\n blocks) go to one shard; an empty shard succeeds\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
package/dist/cli/usage.js CHANGED
@@ -13,7 +13,7 @@ Options for run and list:
13
13
  --env <name> Environment to run against (or SAMURAI_ENV)
14
14
  --file <spec> Only this spec file; repeatable
15
15
  --grep <text> Only tests whose full name contains the text
16
- --group <name> Only the tests of this group of the config's groups
16
+ --group <name> Only the tests of this group of the config's groups; repeat it for several
17
17
  --browser <name> Run in this browser (firefox or chrome) instead of the config's
18
18
  browsers; repeat it for more than one
19
19
  --json Machine-readable output: JSON lines for run, one array for list
@@ -29,6 +29,9 @@ Options for run:
29
29
  workers (or SAMURAI_WORKERS), else half the CPUs
30
30
  --retries <n> Run a failing test again up to n times, each with a fresh browser
31
31
  (or SAMURAI_RETRIES; default 0)
32
+ --shard <current/total> Run one slice of the selected tests, to split a run over machines:
33
+ --shard=2/4 runs the second of four. Whole files (and serial
34
+ blocks) go to one shard; an empty shard succeeds
32
35
 
33
36
  Options for record (also --env, --port, --json, --headless, --no-headless):
34
37
  --browser <name> Record in this browser (default: the first of the config's browsers)
@@ -0,0 +1,4 @@
1
+ /** A URL starting with "/", "./" or "../" */
2
+ export declare const RELATIVE_URL: RegExp;
3
+ /** Resolves a slash-led `url` against `base`; any other URL is returned as given */
4
+ export declare function resolveRelativeURL(url: string, base: string): string;
@@ -0,0 +1,6 @@
1
+ /** A URL starting with "/", "./" or "../" */
2
+ export const RELATIVE_URL = /^\.{0,2}\//;
3
+ /** Resolves a slash-led `url` against `base`; any other URL is returned as given */
4
+ export function resolveRelativeURL(url, base) {
5
+ return RELATIVE_URL.test(url) ? new URL(url, base).href : url;
6
+ }