@itmetsam/samurai-framework 0.5.0 → 0.7.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 (78) hide show
  1. package/README.md +4 -4
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/api.d.ts +1 -0
  4. package/dist/api.js +1 -0
  5. package/dist/browser/browser-finder.d.ts +1 -0
  6. package/dist/browser/browser-finder.js +1 -0
  7. package/dist/browser/browser.d.ts +8 -0
  8. package/dist/browser/browser.js +33 -12
  9. package/dist/browser/evidence.d.ts +19 -0
  10. package/dist/browser/evidence.js +35 -0
  11. package/dist/browser/page.d.ts +6 -0
  12. package/dist/browser/page.js +20 -3
  13. package/dist/browser/screenshot.d.ts +2 -0
  14. package/dist/browser/screenshot.js +5 -2
  15. package/dist/browser/warm-up.d.ts +14 -0
  16. package/dist/browser/warm-up.js +37 -0
  17. package/dist/browsers/cache-dir.d.ts +2 -0
  18. package/dist/browsers/cache-dir.js +15 -0
  19. package/dist/browsers/downloader.d.ts +23 -0
  20. package/dist/browsers/downloader.js +52 -0
  21. package/dist/browsers/index.d.ts +6 -0
  22. package/dist/browsers/index.js +5 -0
  23. package/dist/browsers/overrides.d.ts +17 -0
  24. package/dist/browsers/overrides.js +15 -0
  25. package/dist/browsers/pins.d.ts +16 -0
  26. package/dist/browsers/pins.js +14 -0
  27. package/dist/browsers/resolve.d.ts +26 -0
  28. package/dist/browsers/resolve.js +56 -0
  29. package/dist/browsers/store.d.ts +52 -0
  30. package/dist/browsers/store.js +170 -0
  31. package/dist/cli/args.d.ts +2 -0
  32. package/dist/cli/args.js +2 -0
  33. package/dist/cli/browsers.d.ts +6 -0
  34. package/dist/cli/browsers.js +117 -0
  35. package/dist/cli/main.d.ts +1 -1
  36. package/dist/cli/main.js +26 -2
  37. package/dist/project/config-edit.d.ts +16 -0
  38. package/dist/project/config-edit.js +218 -0
  39. package/dist/project/index.d.ts +3 -0
  40. package/dist/project/index.js +3 -0
  41. package/dist/project/secrets-file.d.ts +8 -0
  42. package/dist/project/secrets-file.js +69 -0
  43. package/dist/project/variables.d.ts +6 -0
  44. package/dist/project/variables.js +17 -0
  45. package/dist/recorder/candidates.d.ts +7 -0
  46. package/dist/recorder/candidates.js +118 -0
  47. package/dist/recorder/capture.d.ts +8 -1
  48. package/dist/recorder/capture.js +25 -116
  49. package/dist/recorder/index.d.ts +1 -1
  50. package/dist/recorder/normalise.d.ts +28 -1
  51. package/dist/recorder/normalise.js +40 -1
  52. package/dist/recorder/recorder.d.ts +54 -0
  53. package/dist/recorder/recorder.js +195 -10
  54. package/dist/recorder/session.d.ts +15 -0
  55. package/dist/recorder/session.js +61 -7
  56. package/dist/recorder/toolbar/controller.d.ts +136 -0
  57. package/dist/recorder/toolbar/controller.js +471 -0
  58. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  59. package/dist/recorder/toolbar/inspect.js +94 -0
  60. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  61. package/dist/recorder/toolbar/protocol.js +4 -0
  62. package/dist/recorder/toolbar/source.d.ts +32 -0
  63. package/dist/recorder/toolbar/source.js +856 -0
  64. package/dist/recorder/toolbar/styles.d.ts +6 -0
  65. package/dist/recorder/toolbar/styles.js +95 -0
  66. package/dist/runner/reporter.d.ts +5 -1
  67. package/dist/runner/reporter.js +4 -3
  68. package/dist/runner/test-runner.d.ts +9 -0
  69. package/dist/runner/test-runner.js +44 -4
  70. package/dist/steps/emit.js +4 -0
  71. package/dist/steps/model.d.ts +11 -0
  72. package/dist/steps/parse.js +22 -3
  73. package/dist/transport/bidi-connection.d.ts +2 -0
  74. package/dist/transport/bidi-connection.js +33 -2
  75. package/dist/types/bidi-modules/script.d.ts +20 -3
  76. package/dist/types/config.d.ts +5 -0
  77. package/dist/types/test.d.ts +4 -0
  78. package/package.json +10 -1
package/README.md CHANGED
@@ -20,16 +20,16 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
20
20
  - **Semantic locators.** By role, label, text, test id, CSS or XPath, with ordered fallbacks.
21
21
  - **Environments and secrets.** One test file runs against `dev`, `staging` or `production`; secrets come from the environment or a `.env.<name>` file and are masked in logs and reports.
22
22
  - **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
23
- - **Recorder building blocks.** Specs are parsed into steps and edited back with minimal diffs, and an engine records what a person does in the browser. The UI that ties them together is not built yet (see the [roadmap](docs/roadmap.md)).
23
+ - **Recorder building blocks.** Specs are parsed into steps and edited back with minimal diffs, and an engine records what a person does in the browser. A toolbar in the recorded page adds assertions, variables and secrets, waits, screenshots and pause (see [Steps and the recorder](docs/recorder.md)). The Electron app that ties them together lives in a separate repository (see the [roadmap](docs/roadmap.md)).
24
24
 
25
- > **Status:** early. Firefox only, run from a checkout of this repository (not published to npm yet). See [what is still open](docs/roadmap.md).
25
+ > **Status:** early. Firefox only. See [what is still open](docs/roadmap.md).
26
26
 
27
27
  ## Getting started
28
28
 
29
29
  ### 1. Requirements
30
30
 
31
31
  - [Node.js](https://nodejs.org) 26 or newer and [Bun](https://bun.sh) 1.3 or newer (CI uses these versions)
32
- - Firefox installed on the machine. It is found in the usual install locations on Linux, macOS and Windows.
32
+ - Firefox, downloaded by the framework: run `npx samurai browsers install firefox` once in a project (`bun run dev browsers install firefox` in this checkout; see [Which browser runs](docs/pages-and-browsers.md#which-browser-runs)). The Firefox installed on the machine is not used.
33
33
 
34
34
  ### 2. Install
35
35
 
@@ -119,7 +119,7 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
119
119
  | [Command line](docs/cli.md) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
120
120
  | [Flows](docs/flows.md) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
121
121
  | [Running tests and reports](docs/running-and-reports.md) | What a run does, `report.json`, page logs, timeouts |
122
- | [Steps and the recorder](docs/recorder.md) | The step codec and the recording engine |
122
+ | [Steps and the recorder](docs/recorder.md) | The step codec, the recording engine and the recorder toolbar |
123
123
  | [Embedding the framework](docs/embedding.md) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
124
124
  | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
125
125
  | [Roadmap](docs/roadmap.md) | What is built and what is still open |
package/bin/samurai.mjs CHANGED
@@ -1,4 +1,29 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ // Specs import the framework from their project. A global (or linked) samurai must run that same copy, or the specs
8
+ // register their tests in the project's copy and this one finds none
9
+ const own = realpathSync(
10
+ path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
11
+ );
12
+ let local;
13
+ try {
14
+ local = path.dirname(
15
+ createRequire(path.join(process.cwd(), "package.json")).resolve(
16
+ "@itmetsam/samurai-framework/package.json",
17
+ ),
18
+ );
19
+ } catch {
20
+ local = undefined;
21
+ }
22
+ if (local !== undefined && realpathSync(local) !== own) {
23
+ // That copy's binary runs the command and exits
24
+ await import(pathToFileURL(path.join(local, "bin/samurai.mjs")).href);
25
+ }
26
+
2
27
  // Quiet the framework's console logging unless the caller asked for more; the log files keep everything
3
28
  process.env.SAMURAI_LOG_LEVEL ??= "warn";
4
29
 
package/dist/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { test, describe } from "./runner/registry.js";
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
+ export { defineStep, type StepDefinition, type StepResult, type StepRunInput, } from "./flows/steps/index.js";
4
5
  export type { TestFixtures } from "./types/test.js";
package/dist/api.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { test, describe } from "./runner/registry.js";
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
+ export { defineStep, } from "./flows/steps/index.js";
@@ -1,2 +1,3 @@
1
1
  import type { SupportedBrowser } from "../types/browser.js";
2
+ /** @deprecated Launches use the builds SAMURAI downloads (`samurai browsers install`); removed in a later major */
2
3
  export declare function findBrowser(browserName: SupportedBrowser): string;
@@ -7,6 +7,7 @@ const macBrowserPaths = {
7
7
  // chrome: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
8
8
  firefox: "/Applications/Firefox.app/Contents/MacOS/Firefox",
9
9
  };
10
+ /** @deprecated Launches use the builds SAMURAI downloads (`samurai browsers install`); removed in a later major */
10
11
  export function findBrowser(browserName) {
11
12
  logger.verbose("Checking install location for %s", browserName);
12
13
  if (!supportedBrowsers.includes(browserName)) {
@@ -18,6 +18,12 @@ export declare const SESSION_CAPABILITIES: {
18
18
  };
19
19
  };
20
20
  };
21
+ /** session.new got no reply in time */
22
+ export declare class BrowserStartTimeoutError extends Error {
23
+ constructor(label: string, timeout: number);
24
+ }
25
+ /** Sends session.new with its own, longer timeout; `label` names the browser in the timeout error */
26
+ export declare function startSession(connector: Pick<BiDiConnector, "send">, label: string, commandTimeout: number | undefined): Promise<void>;
21
27
  export declare const browserLaunchFlag: (browserName: SupportedBrowser, launchOptions: Partial<BrowserLaunchOptions>) => string[];
22
28
  /**
23
29
  * Stops what a failed launch started. A browser left running holds the profile (the next launch
@@ -28,6 +34,8 @@ export declare function abandonLaunch(browserProc: {
28
34
  }, biDiConnector: {
29
35
  kill(): void;
30
36
  } | undefined): void;
37
+ /** Env added to a browser's process */
38
+ export declare const browserLaunchEnv: (browserName: SupportedBrowser, appDataPath?: string) => Record<string, string>;
31
39
  export interface BrowserLaunchOptions {
32
40
  /** Which port to use for the WebDeveloper tools */
33
41
  port: number;
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { findBrowser } from "./browser-finder.js";
4
+ import { projectBrowser } from "../browsers/resolve.js";
5
5
  import path from "node:path";
6
- import { BiDiConnector } from "../transport/bidi-connection.js";
6
+ import { BiDiConnector, DEFAULT_COMMAND_TIMEOUT, } from "../transport/bidi-connection.js";
7
+ import { BiDiError } from "../transport/bidi-error.js";
7
8
  import Page from "./page.js";
8
9
  import { BrowserContext } from "./browser-context.js";
9
10
  import { validateEmulation } from "./emulation.js";
@@ -17,6 +18,27 @@ import { browsersDir, projectDir, readConfig } from "../config/config.js";
17
18
  export const SESSION_CAPABILITIES = {
18
19
  alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } },
19
20
  };
21
+ /** session.new waits at least this long (ms): the first start after an install can take a while (macOS checks the new app) */
22
+ const SESSION_START_TIMEOUT = 120_000;
23
+ /** session.new got no reply in time */
24
+ export class BrowserStartTimeoutError extends Error {
25
+ constructor(label, timeout) {
26
+ super(`${label} did not finish starting within ${Math.round(timeout / 1000)}s. The first start after an install can be slow; run again.`);
27
+ this.name = "BrowserStartTimeoutError";
28
+ }
29
+ }
30
+ /** Sends session.new with its own, longer timeout; `label` names the browser in the timeout error */
31
+ export async function startSession(connector, label, commandTimeout) {
32
+ const timeout = Math.max(commandTimeout ?? DEFAULT_COMMAND_TIMEOUT, SESSION_START_TIMEOUT);
33
+ try {
34
+ await connector.send("session.new", { capabilities: SESSION_CAPABILITIES }, { timeout });
35
+ }
36
+ catch (err) {
37
+ if (err instanceof BiDiError && err.code === "timeout")
38
+ throw new BrowserStartTimeoutError(label, timeout);
39
+ throw err;
40
+ }
41
+ }
20
42
  /** Time (ms) to wait for Firefox to answer browser.close before killing the process */
21
43
  const BROWSER_CLOSE_GRACE = 2000;
22
44
  const browserProfilePath = (browserName) => path.join(browsersDir(), "profiles", browserName, "user.js");
@@ -70,13 +92,15 @@ export function abandonLaunch(browserProc, biDiConnector) {
70
92
  // On macOS a spawned browser is attributed to the terminal, which is denied access to
71
93
  // ~/Library/Application Support/Firefox (profiles.ini). Point Firefox at a local app data dir instead.
72
94
  const browserAppDataPath = (browserName) => path.join(browsersDir(), "app-data", browserName);
73
- const browserLaunchEnv = (browserName) => {
74
- const appDataPath = browserAppDataPath(browserName);
95
+ /** Env added to a browser's process */
96
+ export const browserLaunchEnv = (browserName, appDataPath = browserAppDataPath(browserName)) => {
75
97
  mkdirSync(appDataPath, { recursive: true });
76
98
  logger.debug("Using app data dir for %s: %s", browserName, appDataPath);
77
99
  switch (browserName) {
78
100
  case "firefox":
79
- return { MOZ_APP_DATA: appDataPath };
101
+ // The project profile may have been opened by a newer Firefox (the system one, before 0.6);
102
+ // without this Firefox refuses to start on it
103
+ return { MOZ_APP_DATA: appDataPath, MOZ_ALLOW_DOWNGRADE: "1" };
80
104
  }
81
105
  };
82
106
  const browserProfiles = {
@@ -127,7 +151,7 @@ export class Browser {
127
151
  signal) {
128
152
  signal?.throwIfAborted();
129
153
  logger.verbose("Trying to launch browser %s", browserName);
130
- const browserLocation = findBrowser(browserName);
154
+ const { executablePath: browserLocation, label } = await projectBrowser(browserName);
131
155
  logger.debug("Found browser install location at: %s", browserLocation);
132
156
  ensureBrowserProfile(browserName);
133
157
  logger.verbose("Trying to spawn %s process", browserName);
@@ -163,15 +187,12 @@ export class Browser {
163
187
  try {
164
188
  let url = urlMatch[0] + browserWsPath[browserName];
165
189
  logger.verbose("Websocket URL match: %s", url);
166
- biDiConnector = await BiDiConnector.connect(url, {
167
- commandTimeout: await readCommandTimeout(),
168
- });
190
+ const commandTimeout = await readCommandTimeout();
191
+ biDiConnector = await BiDiConnector.connect(url, { commandTimeout });
169
192
  logger.verbose("Starting new session");
170
193
  const status = await biDiConnector.send("session.status", {});
171
194
  logger.verbose("Session status", status);
172
- await biDiConnector.send("session.new", {
173
- capabilities: SESSION_CAPABILITIES,
174
- });
195
+ await startSession(biDiConnector, label, commandTimeout);
175
196
  logger.verbose("Session started successfully");
176
197
  const downloadsDir = await prepareDownloadsDir(biDiConnector, path.resolve(projectDir(), (await readOptionalConfig("downloadsDir")) ?? "result/downloads"));
177
198
  const browser = new Browser({
@@ -0,0 +1,19 @@
1
+ /** A screenshot name as a file name: letters, digits, - and _ (the rest becomes -) */
2
+ export declare function cleanName(name: string): string;
3
+ /** Where a run (or one test) saves named screenshots, and the ones it saved */
4
+ export declare class Evidence {
5
+ private readonly resultDir;
6
+ private readonly testDir?;
7
+ readonly screenshots: {
8
+ name: string;
9
+ path: string;
10
+ }[];
11
+ private readonly used;
12
+ constructor(resultDir: string, testDir?: string | undefined);
13
+ /** The file for the next screenshot called `name` (`.jpg` for a jpeg, else `.png`); a repeat gets -2, -3, … */
14
+ fileFor(name: string, type?: "png" | "jpeg"): {
15
+ file: string;
16
+ name: string;
17
+ path: string;
18
+ };
19
+ }
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ /** A screenshot name as a file name: letters, digits, - and _ (the rest becomes -) */
3
+ export function cleanName(name) {
4
+ const cleaned = name.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
5
+ return cleaned === "" ? "screenshot" : cleaned;
6
+ }
7
+ /** Where a run (or one test) saves named screenshots, and the ones it saved */
8
+ export class Evidence {
9
+ constructor(resultDir, testDir) {
10
+ this.resultDir = resultDir;
11
+ this.testDir = testDir;
12
+ this.screenshots = [];
13
+ this.used = new Set();
14
+ }
15
+ /** The file for the next screenshot called `name` (`.jpg` for a jpeg, else `.png`); a repeat gets -2, -3, … */
16
+ fileFor(name, type = "png") {
17
+ const base = cleanName(name);
18
+ let unique = base;
19
+ for (let n = 2; this.used.has(unique); n++)
20
+ unique = `${base}-${n}`;
21
+ this.used.add(unique);
22
+ const relative = [
23
+ "screenshots",
24
+ ...(this.testDir ? this.testDir.split("/") : []),
25
+ `${unique}.${type === "jpeg" ? "jpg" : "png"}`,
26
+ ].join("/");
27
+ const entry = {
28
+ file: path.join(this.resultDir, ...relative.split("/")),
29
+ name: unique,
30
+ path: relative,
31
+ };
32
+ this.screenshots.push({ name: entry.name, path: entry.path });
33
+ return entry;
34
+ }
35
+ }
@@ -9,6 +9,7 @@ import { type EmulationOptions } from "./emulation.js";
9
9
  import { Download } from "./download.js";
10
10
  import { FileChooser } from "./file-chooser.js";
11
11
  import { type ScreenshotOptions } from "./screenshot.js";
12
+ import { Evidence } from "./evidence.js";
12
13
  import { type ConsoleMessage, type LogRecord, type PageError } from "./page-logs.js";
13
14
  import { type RouteHandler } from "../network/router.js";
14
15
  import { Response } from "../network/response.js";
@@ -182,6 +183,11 @@ export default class Page {
182
183
  * await page.screenshot({ path: "out/home.png", fullPage: true });
183
184
  */
184
185
  screenshot(options?: ScreenshotOptions): Promise<Buffer>;
186
+ private evidence;
187
+ /** @internal The runner gives each test its own evidence folder */
188
+ setEvidence(evidence: Evidence | undefined): void;
189
+ /** Waits `ms` milliseconds. Prefer waiting for something (an assertion, `waitForNetworkIdle`) when you can */
190
+ waitForTimeout(ms: number): Promise<void>;
185
191
  /** Renders the page as a PDF and returns it (also written to `options.path`) */
186
192
  pdf(options?: PdfOptions): Promise<Buffer>;
187
193
  /** The browser context (cookies, storage) this page belongs to */
@@ -1,7 +1,8 @@
1
1
  import Locator, {} from "../locator/locator.js";
2
2
  import { cssSelector, labelSelector, roleSelector, testIdSelector, textSelector, } from "../locator/selector.js";
3
3
  import EventEmitter from "node:events";
4
- import { readConfig } from "../config/config.js";
4
+ import path from "node:path";
5
+ import { projectDir, readConfig } from "../config/config.js";
5
6
  import { peekRunSettings } from "../config/run-settings.js";
6
7
  import logger from "../logger/index.js";
7
8
  import { NetworkTracker, } from "../network/network-tracker.js";
@@ -13,7 +14,8 @@ import { applyEmulation } from "./emulation.js";
13
14
  import { Download, DownloadTracker } from "./download.js";
14
15
  import { FileChooser, FileChooserTracker } from "./file-chooser.js";
15
16
  import { DownloadWaitTimeoutError, FileChooserTimeoutError, } from "./page-wait.js";
16
- import { takeScreenshot } from "./screenshot.js";
17
+ import { takeScreenshot, writeOutput, } from "./screenshot.js";
18
+ import { Evidence } from "./evidence.js";
17
19
  import { PageLogs, } from "./page-logs.js";
18
20
  import { Router } from "../network/router.js";
19
21
  import { DataCollector, ResponseBodyUnavailableError, } from "../network/data-collector.js";
@@ -405,7 +407,22 @@ export default class Page {
405
407
  */
406
408
  async screenshot(options = {}) {
407
409
  this.assertOpen();
408
- return takeScreenshot(this.biDiConnector, this.id, options);
410
+ const image = await takeScreenshot(this.biDiConnector, this.id, options);
411
+ if (options.name !== undefined) {
412
+ const evidence = this.evidence ?? new Evidence(path.join(projectDir(), "result"));
413
+ await writeOutput(evidence.fileFor(options.name, options.type).file, image);
414
+ }
415
+ return image;
416
+ }
417
+ /** @internal The runner gives each test its own evidence folder */
418
+ setEvidence(evidence) {
419
+ this.evidence = evidence;
420
+ }
421
+ /** Waits `ms` milliseconds. Prefer waiting for something (an assertion, `waitForNetworkIdle`) when you can */
422
+ async waitForTimeout(ms) {
423
+ if (!Number.isFinite(ms) || ms < 0)
424
+ throw new RangeError(`waitForTimeout needs 0 or more milliseconds, got ${ms}`);
425
+ await new Promise((resolve) => setTimeout(resolve, ms));
409
426
  }
410
427
  /** Renders the page as a PDF and returns it (also written to `options.path`) */
411
428
  async pdf(options = {}) {
@@ -11,6 +11,8 @@ export interface ScreenshotOptions {
11
11
  width: number;
12
12
  height: number;
13
13
  };
14
+ /** Also save the image as run evidence under this name (see the Page API docs) */
15
+ name?: string;
14
16
  /** @default "png" */
15
17
  type?: "png" | "jpeg";
16
18
  /** 0 to 100, jpeg only */
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  const MIME = { png: "image/png", jpeg: "image/jpeg" };
4
4
  /** The `format` of a capture command; validates the type/quality combination */
5
- function imageFormat({ type, quality }) {
5
+ function imageFormat({ type, quality, }) {
6
6
  if (quality !== undefined) {
7
7
  if (type !== "jpeg")
8
8
  throw new RangeError("screenshot(): quality only applies to jpeg");
@@ -12,7 +12,10 @@ function imageFormat({ type, quality }) {
12
12
  }
13
13
  if (type === undefined)
14
14
  return undefined;
15
- return { type: MIME[type], ...(quality !== undefined && { quality: quality / 100 }) };
15
+ return {
16
+ type: MIME[type],
17
+ ...(quality !== undefined && { quality: quality / 100 }),
18
+ };
16
19
  }
17
20
  /**
18
21
  * Captures the page (or, with `element`, that element) and returns the image. Writes it to `options.path` too.
@@ -0,0 +1,14 @@
1
+ import { type ResolvedBrowser } from "../browsers/resolve.js";
2
+ import type { SupportedBrowser } from "../types/browser.js";
3
+ import { type BrowserLaunchOptions } from "./browser.js";
4
+ /** Written into a managed build's folder once it started there; its first start can be slow (macOS checks the new app) */
5
+ export declare const LAUNCHED_STAMP = ".samurai-launched";
6
+ /**
7
+ * Starts a managed build once, before its first real use, so that slow first start happens outside a test's
8
+ * timeout. A browser set by env or config, a stamped build, and an aborted run are skipped.
9
+ */
10
+ export declare function warmUp(target: ResolvedBrowser, launch: () => Promise<{
11
+ close(): Promise<void>;
12
+ }>, signal?: AbortSignal): Promise<void>;
13
+ /** Resolves the project's browser (throws when it is missing) and warms a managed build up, headless */
14
+ export declare function ensureWarm(name: SupportedBrowser, launchOptions?: Partial<BrowserLaunchOptions>, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,37 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { projectBrowser } from "../browsers/resolve.js";
4
+ import logger from "../logger/index.js";
5
+ import { Browser, BrowserStartTimeoutError, } from "./browser.js";
6
+ /** Written into a managed build's folder once it started there; its first start can be slow (macOS checks the new app) */
7
+ export const LAUNCHED_STAMP = ".samurai-launched";
8
+ /**
9
+ * Starts a managed build once, before its first real use, so that slow first start happens outside a test's
10
+ * timeout. A browser set by env or config, a stamped build, and an aborted run are skipped.
11
+ */
12
+ export async function warmUp(target, launch, signal) {
13
+ if (!target.buildDir)
14
+ return;
15
+ const stamp = path.join(target.buildDir, LAUNCHED_STAMP);
16
+ if (existsSync(stamp) || signal?.aborted)
17
+ return;
18
+ logger.verbose("First start of %s", target.label);
19
+ let browser;
20
+ try {
21
+ browser = await launch();
22
+ }
23
+ catch (err) {
24
+ if (signal?.aborted)
25
+ return;
26
+ if (err instanceof BrowserStartTimeoutError)
27
+ throw err;
28
+ throw new Error(`Could not start ${target.label} for the first time: ${err instanceof Error ? err.message : String(err)}`);
29
+ }
30
+ await browser.close();
31
+ writeFileSync(stamp, new Date().toISOString());
32
+ }
33
+ /** Resolves the project's browser (throws when it is missing) and warms a managed build up, headless */
34
+ export async function ensureWarm(name, launchOptions = {}, signal) {
35
+ await warmUp(await projectBrowser(name), async () => (await Browser.launch(name, { ...launchOptions, headless: true }, signal))
36
+ .browser, signal);
37
+ }
@@ -0,0 +1,2 @@
1
+ /** Where downloaded browsers live, shared by every project of this user: `SAMURAI_BROWSERS_PATH`, else the OS cache folder */
2
+ export declare function browsersCacheDir(env?: Partial<NodeJS.ProcessEnv>, platform?: NodeJS.Platform, home?: string): string;
@@ -0,0 +1,15 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ /** Where downloaded browsers live, shared by every project of this user: `SAMURAI_BROWSERS_PATH`, else the OS cache folder */
4
+ export function browsersCacheDir(env = process.env, platform = process.platform, home = homedir()) {
5
+ if (env.SAMURAI_BROWSERS_PATH)
6
+ return path.resolve(env.SAMURAI_BROWSERS_PATH);
7
+ switch (platform) {
8
+ case "darwin":
9
+ return path.join(home, "Library", "Caches", "samurai", "browsers");
10
+ case "win32":
11
+ return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "samurai", "browsers");
12
+ default:
13
+ return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "samurai", "browsers");
14
+ }
15
+ }
@@ -0,0 +1,23 @@
1
+ /** Written into a build folder once its download and unpack finished; a folder without it is unfinished */
2
+ export declare const COMPLETE_MARKER = ".samurai-complete";
3
+ /** One build folder in the cache, as the downloader names it ("firefox", "chrome", "chromedriver") */
4
+ export interface CachedBuild {
5
+ browser: string;
6
+ buildId: string;
7
+ dir: string;
8
+ }
9
+ /** What the store needs to fetch and find builds; tests pass a fake */
10
+ export interface Downloader {
11
+ /** undefined: no builds exist for this OS and architecture */
12
+ platform(): string | undefined;
13
+ /** The folder a build is, or would be, unpacked into */
14
+ installDir(cacheDir: string, browser: string, buildId: string): string;
15
+ executablePath(cacheDir: string, browser: string, buildId: string): string;
16
+ install(cacheDir: string, browser: string, buildId: string, onProgress?: (done: number, total: number) => void): Promise<void>;
17
+ uninstall(cacheDir: string, browser: string, buildId: string): void;
18
+ /** Removes a build's folder (its marker first) and any archive a killed download left behind */
19
+ clean(cacheDir: string, browser: string, buildId: string): void;
20
+ list(cacheDir: string): CachedBuild[];
21
+ }
22
+ /** Downloads from Mozilla and Google with @puppeteer/browsers; `baseUrl` (tests only) points it elsewhere */
23
+ export declare function puppeteerDownloader(baseUrl?: string | undefined): Downloader;
@@ -0,0 +1,52 @@
1
+ import { existsSync, readdirSync, rmSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Browser as PuppeteerBrowser, Cache, detectBrowserPlatform, install, } from "@puppeteer/browsers";
4
+ /** Written into a build folder once its download and unpack finished; a folder without it is unfinished */
5
+ export const COMPLETE_MARKER = ".samurai-complete";
6
+ /** Downloads from Mozilla and Google with @puppeteer/browsers; `baseUrl` (tests only) points it elsewhere */
7
+ export function puppeteerDownloader(baseUrl = process.env.SAMURAI_BROWSERS_BASE_URL) {
8
+ const platform = () => {
9
+ const found = detectBrowserPlatform();
10
+ if (!found)
11
+ throw new Error(`No browser downloads for ${process.platform} ${process.arch}`);
12
+ return found;
13
+ };
14
+ const as = (browser) => browser;
15
+ return {
16
+ platform: () => detectBrowserPlatform(),
17
+ installDir: (cacheDir, browser, buildId) => new Cache(cacheDir).installationDir(as(browser), platform(), buildId),
18
+ executablePath: (cacheDir, browser, buildId) => new Cache(cacheDir).computeExecutablePath({
19
+ browser: as(browser),
20
+ platform: platform(),
21
+ buildId,
22
+ }),
23
+ async install(cacheDir, browser, buildId, onProgress) {
24
+ await install({
25
+ cacheDir,
26
+ browser: as(browser),
27
+ buildId,
28
+ platform: platform(),
29
+ ...(onProgress && { downloadProgressCallback: onProgress }),
30
+ ...(baseUrl && { baseUrl }),
31
+ });
32
+ },
33
+ uninstall: (cacheDir, browser, buildId) => new Cache(cacheDir).uninstall(as(browser), platform(), buildId),
34
+ clean(cacheDir, browser, buildId) {
35
+ const cache = new Cache(cacheDir);
36
+ const dir = cache.installationDir(as(browser), platform(), buildId);
37
+ // The marker goes first: a removal that stops halfway leaves a folder that reads as missing
38
+ rmSync(path.join(dir, COMPLETE_MARKER), { force: true });
39
+ rmSync(dir, { recursive: true, force: true });
40
+ // The archive is downloaded next to the build folders, as <buildId>-<file>
41
+ const root = cache.browserRoot(as(browser));
42
+ if (!existsSync(root))
43
+ return;
44
+ for (const entry of readdirSync(root))
45
+ if (entry.startsWith(`${buildId}-`))
46
+ rmSync(path.join(root, entry), { recursive: true, force: true });
47
+ },
48
+ list: (cacheDir) => new Cache(cacheDir)
49
+ .getInstalledBrowsers()
50
+ .map((b) => ({ browser: b.browser, buildId: b.buildId, dir: b.path })),
51
+ };
52
+ }
@@ -0,0 +1,6 @@
1
+ export { browsersCacheDir } from "./cache-dir.js";
2
+ export type { CachedBuild, Downloader } from "./downloader.js";
3
+ export { ENV_PATH, type Override } from "./overrides.js";
4
+ export { LAUNCHABLE, MANAGED_BROWSERS, PINNED, isManagedBrowser, type ManagedBrowser, } from "./pins.js";
5
+ export { browserStatus, projectBrowser, projectExecutable, resolveBrowser, resolveExecutable, type ResolvedBrowser, } from "./resolve.js";
6
+ export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedExecutable, uninstallBrowser, type BrowserStatus, type InstalledBuild, type StoreOptions, } from "./store.js";
@@ -0,0 +1,5 @@
1
+ export { browsersCacheDir } from "./cache-dir.js";
2
+ export { ENV_PATH } from "./overrides.js";
3
+ export { LAUNCHABLE, MANAGED_BROWSERS, PINNED, isManagedBrowser, } from "./pins.js";
4
+ export { browserStatus, projectBrowser, projectExecutable, resolveBrowser, resolveExecutable, } from "./resolve.js";
5
+ export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedExecutable, uninstallBrowser, } from "./store.js";
@@ -0,0 +1,17 @@
1
+ import type { ManagedBrowser } from "./pins.js";
2
+ /** A browser binary set on purpose, and where */
3
+ export interface Override {
4
+ path: string;
5
+ from: "env" | "config";
6
+ }
7
+ /** Where the overrides come from; each defaults to the running process */
8
+ export interface OverrideSources {
9
+ env?: Partial<NodeJS.ProcessEnv>;
10
+ /** `browserPaths` of samurai.config.ts */
11
+ browserPaths?: Partial<Record<ManagedBrowser, string>> | undefined;
12
+ /** What relative `browserPaths` resolve from */
13
+ projectDir?: string;
14
+ }
15
+ export declare const ENV_PATH: Record<ManagedBrowser, string>;
16
+ /** The env variable, else the config's `browserPaths`; undefined: use the managed build */
17
+ export declare function overrideOf(name: ManagedBrowser, { env, browserPaths, projectDir, }?: OverrideSources): Override | undefined;
@@ -0,0 +1,15 @@
1
+ import path from "node:path";
2
+ export const ENV_PATH = {
3
+ firefox: "SAMURAI_FIREFOX_PATH",
4
+ chrome: "SAMURAI_CHROME_PATH",
5
+ };
6
+ /** The env variable, else the config's `browserPaths`; undefined: use the managed build */
7
+ export function overrideOf(name, { env = process.env, browserPaths, projectDir = process.cwd(), } = {}) {
8
+ const fromEnv = env[ENV_PATH[name]];
9
+ if (fromEnv)
10
+ return { path: path.resolve(fromEnv), from: "env" };
11
+ const fromConfig = browserPaths?.[name];
12
+ if (fromConfig)
13
+ return { path: path.resolve(projectDir, fromConfig), from: "config" };
14
+ return undefined;
15
+ }
@@ -0,0 +1,16 @@
1
+ /** The browser builds this framework version downloads and launches. Bumped by hand in a release */
2
+ export declare const PINNED: {
3
+ readonly firefox: {
4
+ readonly buildId: "stable_158.0";
5
+ readonly label: "Firefox 158.0";
6
+ };
7
+ readonly chrome: {
8
+ readonly buildId: "155.0.8059.79";
9
+ readonly label: "Chrome for Testing 155.0.8059.79";
10
+ };
11
+ };
12
+ export type ManagedBrowser = keyof typeof PINNED;
13
+ export declare const MANAGED_BROWSERS: ManagedBrowser[];
14
+ /** The managed browsers the framework can launch; Chrome joins when it can drive it */
15
+ export declare const LAUNCHABLE: readonly ManagedBrowser[];
16
+ export declare function isManagedBrowser(name: string): name is ManagedBrowser;
@@ -0,0 +1,14 @@
1
+ /** The browser builds this framework version downloads and launches. Bumped by hand in a release */
2
+ export const PINNED = {
3
+ firefox: { buildId: "stable_158.0", label: "Firefox 158.0" },
4
+ chrome: {
5
+ buildId: "155.0.8059.79",
6
+ label: "Chrome for Testing 155.0.8059.79",
7
+ },
8
+ };
9
+ export const MANAGED_BROWSERS = Object.keys(PINNED);
10
+ /** The managed browsers the framework can launch; Chrome joins when it can drive it */
11
+ export const LAUNCHABLE = ["firefox"];
12
+ export function isManagedBrowser(name) {
13
+ return Object.prototype.hasOwnProperty.call(PINNED, name);
14
+ }
@@ -0,0 +1,26 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ import { type OverrideSources } from "./overrides.js";
3
+ import { type ManagedBrowser } from "./pins.js";
4
+ import { type BrowserStatus, type StoreOptions } from "./store.js";
5
+ /** The browser a launch uses, and how to name it */
6
+ export interface ResolvedBrowser {
7
+ executablePath: string;
8
+ /** "Firefox 158.0" for the managed build, else "Firefox" */
9
+ label: string;
10
+ /** The managed build's folder; undefined when an env or config path is used instead */
11
+ buildDir?: string;
12
+ }
13
+ /** The browser a launch uses: the env path, else the config's `browserPaths`, else the pinned build. Never downloads */
14
+ export declare function resolveBrowser(name: ManagedBrowser, sources?: OverrideSources & StoreOptions): ResolvedBrowser;
15
+ /** The binary a launch uses; see `resolveBrowser` */
16
+ export declare function resolveExecutable(name: ManagedBrowser, sources?: OverrideSources & StoreOptions): string;
17
+ /** `resolveExecutable` for the active project */
18
+ export declare function projectExecutable(name: ManagedBrowser): Promise<string>;
19
+ /** `resolveBrowser` for the active project, with its config's `browserPaths` (none when the config can't be read) */
20
+ export declare function projectBrowser(name: ManagedBrowser): Promise<ResolvedBrowser>;
21
+ /** Every pinned browser's status for a project (default: the working directory); a config that can't be read gives no config overrides */
22
+ export declare function browserStatus(options?: StoreOptions & {
23
+ projectDir?: string;
24
+ config?: SamuraiTestConfig;
25
+ env?: Partial<NodeJS.ProcessEnv>;
26
+ }): Promise<BrowserStatus[]>;