@itmetsam/samurai-framework 0.21.4 → 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 (71) hide show
  1. package/dist/api.d.ts +3 -1
  2. package/dist/browser/browser.d.ts +9 -0
  3. package/dist/browser/browser.js +25 -5
  4. package/dist/browser/cdp.d.ts +11 -0
  5. package/dist/browser/cdp.js +28 -0
  6. package/dist/browser/chrome-launch.d.ts +4 -0
  7. package/dist/browser/chrome-launch.js +2 -2
  8. package/dist/browser/index.d.ts +5 -0
  9. package/dist/browser/index.js +4 -0
  10. package/dist/browser/launch-firefox.js +1 -1
  11. package/dist/browser/launch-flags.d.ts +4 -1
  12. package/dist/browser/launch-flags.js +18 -5
  13. package/dist/browser/page-settings.d.ts +1 -1
  14. package/dist/browser/page.d.ts +4 -0
  15. package/dist/browser/page.js +8 -1
  16. package/dist/browser/session-capabilities.d.ts +10 -0
  17. package/dist/browser/session-capabilities.js +11 -0
  18. package/dist/browser/session.d.ts +2 -1
  19. package/dist/browser/session.js +3 -3
  20. package/dist/browser/storage-state.d.ts +6 -0
  21. package/dist/browser/storage-state.js +37 -0
  22. package/dist/browser/use-applier.d.ts +27 -0
  23. package/dist/browser/use-applier.js +94 -0
  24. package/dist/browser/viewport.d.ts +3 -2
  25. package/dist/browser/viewport.js +15 -2
  26. package/dist/config/config.js +2 -0
  27. package/dist/config/use-options.d.ts +16 -0
  28. package/dist/config/use-options.js +128 -0
  29. package/dist/logger/warn-once.d.ts +4 -0
  30. package/dist/logger/warn-once.js +13 -0
  31. package/dist/runner/attachments.d.ts +16 -0
  32. package/dist/runner/attachments.js +60 -0
  33. package/dist/runner/fixture-args.d.ts +15 -0
  34. package/dist/runner/fixture-args.js +95 -0
  35. package/dist/runner/fixture-defs.d.ts +22 -0
  36. package/dist/runner/fixture-defs.js +103 -0
  37. package/dist/runner/fixture-scope.d.ts +33 -0
  38. package/dist/runner/fixture-scope.js +156 -0
  39. package/dist/runner/fixtures.d.ts +34 -0
  40. package/dist/runner/fixtures.js +39 -0
  41. package/dist/runner/hooks.d.ts +6 -1
  42. package/dist/runner/hooks.js +15 -3
  43. package/dist/runner/registry.d.ts +3 -39
  44. package/dist/runner/registry.js +93 -19
  45. package/dist/runner/reporter.d.ts +7 -1
  46. package/dist/runner/reporter.js +3 -0
  47. package/dist/runner/run-test.d.ts +1 -3
  48. package/dist/runner/run-test.js +92 -34
  49. package/dist/runner/steps.d.ts +13 -0
  50. package/dist/runner/steps.js +67 -0
  51. package/dist/runner/suite.d.ts +5 -0
  52. package/dist/runner/suite.js +8 -0
  53. package/dist/runner/test-annotations.d.ts +16 -0
  54. package/dist/runner/test-annotations.js +47 -0
  55. package/dist/runner/test-function.d.ts +103 -0
  56. package/dist/runner/test-function.js +1 -0
  57. package/dist/runner/test-info.d.ts +104 -0
  58. package/dist/runner/test-info.js +169 -0
  59. package/dist/runner/test-runner.js +2 -1
  60. package/dist/runner/test-timeout.d.ts +22 -0
  61. package/dist/runner/test-timeout.js +45 -0
  62. package/dist/runner/use-options.d.ts +7 -0
  63. package/dist/runner/use-options.js +18 -0
  64. package/dist/runner/worker-session.js +2 -1
  65. package/dist/script/element-handle.d.ts +1 -1
  66. package/dist/script/element-handle.js +1 -1
  67. package/dist/types/config.d.ts +3 -2
  68. package/dist/types/extend.d.ts +30 -0
  69. package/dist/types/test.d.ts +120 -0
  70. package/dist/types/use.d.ts +47 -0
  71. package/package.json +1 -1
package/dist/api.d.ts CHANGED
@@ -2,5 +2,7 @@ export { test, describe, beforeEach, afterEach, beforeAll, afterAll, } from "./r
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
4
  export { defineStep, type StepDefinition, type StepResult, type StepRunInput, } from "./flows/steps/index.js";
5
- export type { AllHookFixtures, TestDetails, TestFixtures, TestOptions, TestOutputs, } from "./types/test.js";
5
+ export type { AllHookFixtures, TestAnnotation, TestAttachment, TestDetails, TestFixtures, TestInfo, TestOptions, TestOutputs, TestStepEntry, } from "./types/test.js";
6
+ export type { FixtureOptions } from "./types/extend.js";
7
+ export type { ColorScheme, StorageState, StorageStateCookie, UseOptions, } from "./types/use.js";
6
8
  export { generatedFile, type GeneratedFile, type FileInput, } from "./browser/generated-files.js";
@@ -5,6 +5,8 @@ import { type BrowserProcess } from "./chrome-launch.js";
5
5
  import { BrowserContext } from "./browser-context.js";
6
6
  import type { UserPromptHandler } from "../types/bidi-modules/session.js";
7
7
  import { type EmulationOptions } from "./emulation.js";
8
+ import { UseApplier } from "./use-applier.js";
9
+ import type { UseOptions } from "../types/use.js";
8
10
  import { ChromeDownloadFolders } from "./download-folders.js";
9
11
  import { type BrowserLaunchOptions } from "./launch-flags.js";
10
12
  import type { BiDiCommands } from "../types/bidi.js";
@@ -27,6 +29,8 @@ export declare class Browser {
27
29
  readonly downloadsDir: string | undefined;
28
30
  private exitedPromise;
29
31
  private downloadFolders;
32
+ /** The `use` options of the run, once `setUse` has been called: what every context and page gets */
33
+ private use;
30
34
  constructor({ browserProc, biDiConnector, downloadsDir, downloadFolders, }: {
31
35
  browserProc: BrowserProcess;
32
36
  biDiConnector: BiDiConnector;
@@ -43,6 +47,11 @@ export declare class Browser {
43
47
  }>;
44
48
  /** Sets up a browser whose BiDi session is open: downloads folder, the first page and what it tracks */
45
49
  private static attach;
50
+ /**
51
+ * @internal The runner calls this after launch: the `use` options now apply to the default context and its pages,
52
+ * and to every context and page created later. Options passed to `newContext` win over them.
53
+ */
54
+ setUse(options: UseOptions, projectDir: string): Promise<UseApplier>;
46
55
  /** Closes the browser via BiDi browser.close, then makes sure the process has exited */
47
56
  close(): Promise<void>;
48
57
  /** Alias of close() */
@@ -6,6 +6,7 @@ import Page from "./page.js";
6
6
  import { launchChrome } from "./chrome-launch.js";
7
7
  import { BrowserContext } from "./browser-context.js";
8
8
  import { validateEmulation } from "./emulation.js";
9
+ import { UseApplier } from "./use-applier.js";
9
10
  import { prepareDownloadsDir, removeIfEmpty } from "./downloads-dir.js";
10
11
  import { ChromeDownloadFolders } from "./download-folders.js";
11
12
  import { launchFirefox } from "./launch-firefox.js";
@@ -46,7 +47,7 @@ export class Browser {
46
47
  const resolved = await projectBrowser(browserName);
47
48
  logger.debug("Found browser install location at: %s", resolved.executablePath);
48
49
  ensureBrowserProfile(browserName, launchOptions.profileDir &&
49
- path.join(launchOptions.profileDir, "user.js"));
50
+ path.join(launchOptions.profileDir, "user.js"), launchOptions.use);
50
51
  const commandTimeout = await readCommandTimeout();
51
52
  // Both launchers hand back an open BiDi session: Firefox after session.new, Chrome with the one
52
53
  // its chromedriver created
@@ -58,6 +59,7 @@ export class Browser {
58
59
  defaultLaunchOptions("chrome").profileDir,
59
60
  headless: launchOptions.headless ?? defaultLaunchOptions("chrome").headless,
60
61
  commandTimeout,
62
+ use: launchOptions.use,
61
63
  signal,
62
64
  })
63
65
  : await launchFirefox({
@@ -122,6 +124,19 @@ export class Browser {
122
124
  await page.startTracking();
123
125
  return { browser, page };
124
126
  }
127
+ /**
128
+ * @internal The runner calls this after launch: the `use` options now apply to the default context and its pages,
129
+ * and to every context and page created later. Options passed to `newContext` win over them.
130
+ */
131
+ async setUse(options, projectDir) {
132
+ const name = this.biDiConnector.browserName ?? "firefox";
133
+ const applier = new UseApplier(this.biDiConnector, name, options, projectDir);
134
+ this.use = applier;
135
+ await applier.context(this.defaultContext);
136
+ for (const page of this.defaultContext.pages())
137
+ await applier.page(page);
138
+ return applier;
139
+ }
125
140
  /** Closes the browser via BiDi browser.close, then makes sure the process has exited */
126
141
  async close() {
127
142
  await this.downloadFolders?.dispose();
@@ -163,6 +178,7 @@ export class Browser {
163
178
  const page = new Page(this.biDiConnector, createBrowserContext.context);
164
179
  this.contextFor(userContext).register(page);
165
180
  await page.startTracking();
181
+ await this.use?.page(page);
166
182
  return page;
167
183
  }
168
184
  /**
@@ -173,18 +189,22 @@ export class Browser {
173
189
  * const page = await alice.newPage();
174
190
  */
175
191
  async newContext(options = {}) {
176
- const { acceptInsecureCerts, unhandledPromptBehavior, ...emulation } = options;
192
+ const { unhandledPromptBehavior, ...emulation } = options;
193
+ const insecure = options.acceptInsecureCerts ?? this.use?.acceptInsecureCerts;
194
+ delete emulation.acceptInsecureCerts;
177
195
  // A wrong value must not leave a context behind, so it is refused before one is created
178
196
  validateEmulation(emulation);
179
197
  const { userContext } = await this.biDiConnector.send("browser.createUserContext", {
180
- ...(acceptInsecureCerts !== undefined && { acceptInsecureCerts }),
198
+ ...(insecure !== undefined && { acceptInsecureCerts: insecure }),
181
199
  ...(unhandledPromptBehavior && { unhandledPromptBehavior }),
182
200
  });
183
201
  const context = new BrowserContext(this.host, userContext, false);
184
202
  this.userContexts.set(userContext, context);
185
- if (Object.keys(emulation).length > 0) {
203
+ if (this.use || Object.keys(emulation).length > 0) {
186
204
  try {
187
- await context.emulate(emulation);
205
+ await this.use?.context(context);
206
+ if (Object.keys(emulation).length > 0)
207
+ await context.emulate(emulation);
188
208
  }
189
209
  catch (err) {
190
210
  // Firefox keeps a context in its profile until it is removed
@@ -0,0 +1,11 @@
1
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
2
+ /**
3
+ * Sends a command the generated BiDi types do not list: `network.setExtraHeaders` (both browsers answer it) and
4
+ * the `goog:cdp.*` commands of Chrome
5
+ */
6
+ export declare function sendRaw(connector: BiDiConnector, method: string, params: object): Promise<any>;
7
+ /**
8
+ * Runs a Chrome DevTools Protocol command on the page `context` through the BiDi connection (`goog:cdp`, Chrome
9
+ * only). Use it where BiDi has no command yet; CDP state belongs to one page, so it is set per page.
10
+ */
11
+ export declare function cdpSend(connector: BiDiConnector, context: string, method: string, params: object): Promise<void>;
@@ -0,0 +1,28 @@
1
+ /** The CDP session of each page, per connection: `goog:cdp.getSession` attaches on every call */
2
+ const sessions = new WeakMap();
3
+ /**
4
+ * Sends a command the generated BiDi types do not list: `network.setExtraHeaders` (both browsers answer it) and
5
+ * the `goog:cdp.*` commands of Chrome
6
+ */
7
+ export function sendRaw(connector, method, params) {
8
+ return connector.send(method, params);
9
+ }
10
+ /**
11
+ * Runs a Chrome DevTools Protocol command on the page `context` through the BiDi connection (`goog:cdp`, Chrome
12
+ * only). Use it where BiDi has no command yet; CDP state belongs to one page, so it is set per page.
13
+ */
14
+ export async function cdpSend(connector, context, method, params) {
15
+ let known = sessions.get(connector);
16
+ if (!known)
17
+ sessions.set(connector, (known = new Map()));
18
+ let session = known.get(context);
19
+ if (session === undefined) {
20
+ const reply = await sendRaw(connector, "goog:cdp.getSession", { context });
21
+ known.set(context, (session = reply.session));
22
+ }
23
+ await sendRaw(connector, "goog:cdp.sendCommand", {
24
+ method,
25
+ params,
26
+ session,
27
+ });
28
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import EventEmitter from "node:events";
3
3
  import { BiDiConnector } from "../transport/bidi-connection.js";
4
+ import type { UseOptions } from "../types/use.js";
4
5
  /** What `Browser` needs of the thing it launched: the browser process, or for Chrome the chromedriver in front of it */
5
6
  export interface BrowserProcess {
6
7
  readonly exitCode: number | null;
@@ -14,6 +15,8 @@ interface ChromeLaunch {
14
15
  profileDir: string;
15
16
  headless: boolean;
16
17
  commandTimeout: number | undefined;
18
+ /** The `use` options that are fixed when the browser starts */
19
+ use?: UseOptions | undefined;
17
20
  signal?: AbortSignal | undefined;
18
21
  }
19
22
  export declare const chromeFlags: ({ headless, profileDir, }: Pick<ChromeLaunch, "headless" | "profileDir">) => string[];
@@ -25,6 +28,7 @@ export declare const chromeCapabilities: (launch: ChromeLaunch) => {
25
28
  binary: string;
26
29
  args: string[];
27
30
  };
31
+ acceptInsecureCerts?: boolean | undefined;
28
32
  unhandledPromptBehavior: {
29
33
  default: string;
30
34
  };
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import logger from "../logger/index.js";
7
7
  import { BiDiConnector } from "../transport/bidi-connection.js";
8
- import { SESSION_CAPABILITIES } from "./session-capabilities.js";
8
+ import { sessionCapabilities } from "./session-capabilities.js";
9
9
  export const chromeFlags = ({ headless, profileDir, }) => [
10
10
  "--no-sandbox",
11
11
  "--no-first-run",
@@ -21,7 +21,7 @@ export const chromeCapabilities = (launch) => ({
21
21
  alwaysMatch: {
22
22
  browserName: "chrome",
23
23
  webSocketUrl: true,
24
- ...SESSION_CAPABILITIES.alwaysMatch,
24
+ ...sessionCapabilities(launch.use).alwaysMatch,
25
25
  "goog:chromeOptions": {
26
26
  binary: launch.executablePath,
27
27
  args: chromeFlags(launch),
@@ -2,5 +2,10 @@ export { Browser, type BrowserLaunchOptions, type ContextOptions, } from "./brow
2
2
  export { BrowserContext } from "./browser-context.js";
3
3
  export { default as Page } from "./page.js";
4
4
  export { DownloadTimeoutError } from "./download.js";
5
+ export { UrlTimeoutError } from "./navigation-error.js";
6
+ export { ScriptError, ScriptSerializationError, type PageFunction, } from "../script/call-function.js";
7
+ export { ElementHandle } from "../script/element-handle.js";
8
+ export { RemoteObject } from "../script/serialize.js";
9
+ export type { BoundingBox } from "../locator/element-reads.js";
5
10
  export { EmulationUnsupportedError } from "./emulation.js";
6
11
  export { findBrowser } from "./browser-finder.js";
@@ -2,5 +2,9 @@ export { Browser, } from "./browser.js";
2
2
  export { BrowserContext } from "./browser-context.js";
3
3
  export { default as Page } from "./page.js";
4
4
  export { DownloadTimeoutError } from "./download.js";
5
+ export { UrlTimeoutError } from "./navigation-error.js";
6
+ export { ScriptError, ScriptSerializationError, } from "../script/call-function.js";
7
+ export { ElementHandle } from "../script/element-handle.js";
8
+ export { RemoteObject } from "../script/serialize.js";
5
9
  export { EmulationUnsupportedError } from "./emulation.js";
6
10
  export { findBrowser } from "./browser-finder.js";
@@ -51,7 +51,7 @@ export function launchFirefox(launch) {
51
51
  connector = await BiDiConnector.connect(url, { commandTimeout });
52
52
  const status = await connector.send("session.status", {});
53
53
  logger.verbose("Session status", status);
54
- await startSession(connector, label, commandTimeout);
54
+ await startSession(connector, label, commandTimeout, launchOptions.use);
55
55
  logger.verbose("Session started successfully");
56
56
  settled();
57
57
  resolve({ proc, connector });
@@ -1,4 +1,5 @@
1
1
  import type { SupportedBrowser } from "../types/browser.js";
2
+ import type { UseOptions } from "../types/use.js";
2
3
  export interface BrowserLaunchOptions {
3
4
  /** Which port to use for the WebDeveloper tools */
4
5
  port: number;
@@ -6,6 +7,8 @@ export interface BrowserLaunchOptions {
6
7
  profileDir: string;
7
8
  /** Start the browser in headless mode or in UI mode */
8
9
  headless: boolean;
10
+ /** The `use` options that can only be set when the browser starts (certificates, and Firefox's JavaScript and colour scheme) */
11
+ use?: UseOptions;
9
12
  }
10
13
  /** The profile folder of a worker: worker 0 keeps the browser's own folder, worker N gets `<browser>-wN` beside it */
11
14
  export declare const workerProfileDir: (browserName: SupportedBrowser, workerIndex: number) => string;
@@ -15,4 +18,4 @@ export declare const browserLaunchFlag: (browserName: SupportedBrowser, launchOp
15
18
  /** Env added to a browser's process */
16
19
  export declare const browserLaunchEnv: (browserName: SupportedBrowser, appDataPath?: string) => Record<string, string>;
17
20
  /** Writes the browser's user.js unless it already holds the current prefs; an outdated one is replaced */
18
- export declare function ensureBrowserProfile(browserName: SupportedBrowser, profilePath?: string | undefined): void;
21
+ export declare function ensureBrowserProfile(browserName: SupportedBrowser, profilePath?: string | undefined, use?: UseOptions): void;
@@ -71,15 +71,28 @@ const browserProfiles = {
71
71
  user_pref("browser.newtabpage.enabled", false);
72
72
  `,
73
73
  };
74
+ /**
75
+ * The prefs a Firefox run chooses. Always written, with the browser's own values when nothing is chosen: Firefox
76
+ * keeps a pref a user.js set in the profile, so a run without `use` would otherwise inherit the one before it.
77
+ */
78
+ function usePrefs(use) {
79
+ const scheme = { light: 1, dark: 0, "no-preference": 2 }[use?.colorScheme ?? "no-preference"];
80
+ return `
81
+ user_pref("javascript.enabled", ${use?.javaScriptEnabled !== false});
82
+ user_pref("layout.css.prefers-color-scheme.content-override", ${scheme});
83
+ `;
84
+ }
85
+ const profileText = (browserName, use) => browserProfiles[browserName] +
86
+ (browserName === "firefox" ? usePrefs(use) : "");
74
87
  /** Writes the browser's user.js unless it already holds the current prefs; an outdated one is replaced */
75
- export function ensureBrowserProfile(browserName, profilePath = browserProfilePath(browserName)) {
88
+ export function ensureBrowserProfile(browserName, profilePath = browserProfilePath(browserName), use) {
76
89
  logger.verbose("Checking browser profile for: %s", browserName);
77
90
  if (!profilePath) {
78
91
  logger.verbose("No browser profile path declared, skipping...");
79
92
  return;
80
93
  }
81
94
  if (existsSync(profilePath)) {
82
- if (readFileSync(profilePath, "utf8") === browserProfiles[browserName]) {
95
+ if (readFileSync(profilePath, "utf8") === profileText(browserName, use)) {
83
96
  logger.verbose("Browser profile is up to date at: %s", profilePath);
84
97
  return;
85
98
  }
@@ -88,12 +101,12 @@ export function ensureBrowserProfile(browserName, profilePath = browserProfilePa
88
101
  else {
89
102
  logger.verbose("No browser profile created yet for %s", browserName);
90
103
  }
91
- createBrowserProfile(browserName, profilePath);
104
+ createBrowserProfile(browserName, profilePath, use);
92
105
  }
93
- function createBrowserProfile(browserName, profilePath) {
106
+ function createBrowserProfile(browserName, profilePath, use) {
94
107
  logger.verbose("Creating browser profile for %s at %s", browserName, profilePath);
95
108
  mkdirSync(path.dirname(profilePath), { recursive: true });
96
- writeFileSync(profilePath, browserProfiles[browserName], {
109
+ writeFileSync(profilePath, profileText(browserName, use), {
97
110
  encoding: "utf8",
98
111
  });
99
112
  logger.verbose("Browser profile created");
@@ -19,7 +19,7 @@ export declare class PageConfig {
19
19
  timeout?: number;
20
20
  waitUntil?: "none" | "interactive" | "complete";
21
21
  }>;
22
- use(): Promise<{
22
+ use(): Promise<import("../api.js").UseOptions & {
23
23
  viewport?: {
24
24
  width: number;
25
25
  height: number;
@@ -48,6 +48,8 @@ export default class Page {
48
48
  private transfers;
49
49
  private evidence;
50
50
  private knownViewport;
51
+ /** Chrome's phone layout is on (`emulateMobile`): viewport changes keep it */
52
+ private mobile;
51
53
  /** `settings` override what `samurai.config.ts` says; without them the config is read when needed */
52
54
  constructor(biDiConnector: BiDiConnector, contextId: string, settings?: PageSettings);
53
55
  /** The BiDi browsing context id of this page's top-level context */
@@ -178,6 +180,8 @@ export default class Page {
178
180
  * await page.setViewport({ width: 390, height: 844, devicePixelRatio: 3 });
179
181
  */
180
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>;
181
185
  /** Playwright's name for `setViewport` */
182
186
  setViewportSize(size: Viewport): Promise<void>;
183
187
  /**
@@ -40,6 +40,8 @@ export default class Page {
40
40
  this.errorsAllowed = false;
41
41
  this.isClosed = false;
42
42
  this.knownViewport = null;
43
+ /** Chrome's phone layout is on (`emulateMobile`): viewport changes keep it */
44
+ this.mobile = false;
43
45
  this.biDiConnector = biDiConnector;
44
46
  this.id = contextId;
45
47
  this.pageConfig = new PageConfig(settings);
@@ -274,12 +276,17 @@ export default class Page {
274
276
  */
275
277
  async setViewport(viewport) {
276
278
  this.assertOpen();
277
- await applyViewport(this.biDiConnector, this.id, viewport);
279
+ await applyViewport(this.biDiConnector, this.id, viewport, this.mobile);
278
280
  this.knownViewport = viewport && {
279
281
  width: viewport.width,
280
282
  height: viewport.height,
281
283
  };
282
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
+ }
283
290
  /** Playwright's name for `setViewport` */
284
291
  setViewportSize(size) {
285
292
  return this.setViewport(size);
@@ -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,