@itmetsam/samurai-framework 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -22,14 +22,14 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
22
22
  - **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
23
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)).
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
 
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,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[]>;
@@ -0,0 +1,56 @@
1
+ import { existsSync } from "node:fs";
2
+ import { projectConfig, projectDir, readConfig } from "../config/config.js";
3
+ import logger from "../logger/index.js";
4
+ import { ENV_PATH, overrideOf } from "./overrides.js";
5
+ import { MANAGED_BROWSERS, PINNED } from "./pins.js";
6
+ import { managedBuildDir, managedExecutable, statusOf, } from "./store.js";
7
+ /** The browser a launch uses: the env path, else the config's `browserPaths`, else the pinned build. Never downloads */
8
+ export function resolveBrowser(name, sources = {}) {
9
+ const override = overrideOf(name, sources);
10
+ if (!override)
11
+ return {
12
+ executablePath: managedExecutable(name, sources),
13
+ label: PINNED[name].label,
14
+ buildDir: managedBuildDir(name, sources),
15
+ };
16
+ if (!existsSync(override.path))
17
+ throw new Error(override.from === "env"
18
+ ? `${ENV_PATH[name]} points to a missing file: ${override.path}`
19
+ : `browserPaths.${name} in samurai.config.ts points to a missing file: ${override.path}`);
20
+ return {
21
+ executablePath: override.path,
22
+ label: name.charAt(0).toUpperCase() + name.slice(1),
23
+ };
24
+ }
25
+ /** The binary a launch uses; see `resolveBrowser` */
26
+ export function resolveExecutable(name, sources = {}) {
27
+ return resolveBrowser(name, sources).executablePath;
28
+ }
29
+ /** `resolveExecutable` for the active project */
30
+ export async function projectExecutable(name) {
31
+ return (await projectBrowser(name)).executablePath;
32
+ }
33
+ /** `resolveBrowser` for the active project, with its config's `browserPaths` (none when the config can't be read) */
34
+ export async function projectBrowser(name) {
35
+ let browserPaths;
36
+ try {
37
+ browserPaths = await readConfig("browserPaths");
38
+ }
39
+ catch (err) {
40
+ logger.debug("Could not read browserPaths from config", { err });
41
+ }
42
+ return resolveBrowser(name, { browserPaths, projectDir: projectDir() });
43
+ }
44
+ /** Every pinned browser's status for a project (default: the working directory); a config that can't be read gives no config overrides */
45
+ export async function browserStatus(options = {}) {
46
+ const dir = options.projectDir ?? process.cwd();
47
+ let browserPaths;
48
+ try {
49
+ browserPaths = (await projectConfig({ ...options, projectDir: dir }))
50
+ .browserPaths;
51
+ }
52
+ catch (err) {
53
+ logger.debug("Could not read browserPaths from config", { err });
54
+ }
55
+ return MANAGED_BROWSERS.map((name) => statusOf(name, { ...options, browserPaths, projectDir: dir }));
56
+ }
@@ -0,0 +1,52 @@
1
+ import { COMPLETE_MARKER, type Downloader } from "./downloader.js";
2
+ import { type Override, type OverrideSources } from "./overrides.js";
3
+ import { type ManagedBrowser } from "./pins.js";
4
+ export { COMPLETE_MARKER };
5
+ export declare class BrowserNotInstalledError extends Error {
6
+ readonly browser: ManagedBrowser;
7
+ readonly label: string;
8
+ constructor(browser: ManagedBrowser, label: string);
9
+ }
10
+ export interface StoreOptions {
11
+ /** Default: `browsersCacheDir()` */
12
+ cacheDir?: string;
13
+ /** Default: @puppeteer/browsers */
14
+ downloader?: Downloader;
15
+ }
16
+ export interface BrowserStatus {
17
+ name: ManagedBrowser;
18
+ label: string;
19
+ buildId: string;
20
+ /** unavailable: no download exists for this OS and architecture */
21
+ state: "installed" | "missing" | "unavailable";
22
+ executablePath?: string;
23
+ /** Whether the framework can launch it yet */
24
+ launchable: boolean;
25
+ /** A path set on purpose, which launches use instead of the managed build */
26
+ override?: Override;
27
+ }
28
+ export interface InstalledBuild {
29
+ name: ManagedBrowser;
30
+ buildId: string;
31
+ dir: string;
32
+ sizeBytes: number;
33
+ /** Pinned by this framework version */
34
+ pinned: boolean;
35
+ }
36
+ /**
37
+ * Downloads the pinned build unless it is complete already; returns its executable. The download goes into a
38
+ * private staging folder and is moved into place once complete, so two installs at once do not break each other.
39
+ */
40
+ export declare function installBrowser(name: ManagedBrowser, options?: StoreOptions & {
41
+ onProgress?: (done: number, total: number) => void;
42
+ }): Promise<string>;
43
+ /** The pinned build of `name`: whether it is there, and any path set instead of it */
44
+ export declare function statusOf(name: ManagedBrowser, options?: StoreOptions & OverrideSources): BrowserStatus;
45
+ /** The pinned build's executable; throws BrowserNotInstalledError when it isn't complete */
46
+ export declare function managedExecutable(name: ManagedBrowser, options?: StoreOptions): string;
47
+ /** The folder the pinned build of `name` is, or would be, installed in */
48
+ export declare function managedBuildDir(name: ManagedBrowser, options?: StoreOptions): string;
49
+ /** Removes a build (default: the pinned one); Chrome takes its chromedriver along */
50
+ export declare function uninstallBrowser(name: ManagedBrowser, buildId?: string, options?: StoreOptions): void;
51
+ /** Every Firefox and Chrome build in the cache, also those other framework versions pinned */
52
+ export declare function listInstalled(options?: StoreOptions): InstalledBuild[];
@@ -0,0 +1,170 @@
1
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { browsersCacheDir } from "./cache-dir.js";
4
+ import { COMPLETE_MARKER, puppeteerDownloader, } from "./downloader.js";
5
+ import { overrideOf, } from "./overrides.js";
6
+ import { isManagedBrowser, LAUNCHABLE, PINNED, } from "./pins.js";
7
+ export { COMPLETE_MARKER };
8
+ /** Each install downloads into its own `<cache>/.staging-<random>/` and moves the finished build into place */
9
+ const STAGING_PREFIX = ".staging-";
10
+ /** A staging folder this old belongs to an install that was killed */
11
+ const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
12
+ export class BrowserNotInstalledError extends Error {
13
+ constructor(browser, label) {
14
+ super(`${label} is not installed. Run: samurai browsers install ${browser}`);
15
+ this.browser = browser;
16
+ this.label = label;
17
+ this.name = "BrowserNotInstalledError";
18
+ }
19
+ }
20
+ const store = (options = {}) => ({
21
+ cacheDir: options.cacheDir ?? browsersCacheDir(),
22
+ downloader: options.downloader ?? puppeteerDownloader(),
23
+ });
24
+ /** The cache folders one managed browser consists of */
25
+ const parts = (name) => name === "chrome" ? ["chrome", "chromedriver"] : [name];
26
+ const complete = (dir) => existsSync(path.join(dir, COMPLETE_MARKER));
27
+ function sizeOf(dir) {
28
+ let total = 0;
29
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
30
+ const full = path.join(dir, entry.name);
31
+ total += entry.isDirectory() ? sizeOf(full) : lstatSync(full).size;
32
+ }
33
+ return total;
34
+ }
35
+ /** Removes staging folders that killed installs left behind; best effort */
36
+ function removeStaleStaging(cacheDir) {
37
+ if (!existsSync(cacheDir))
38
+ return;
39
+ for (const entry of readdirSync(cacheDir)) {
40
+ if (!entry.startsWith(STAGING_PREFIX))
41
+ continue;
42
+ const dir = path.join(cacheDir, entry);
43
+ try {
44
+ if (Date.now() - statSync(dir).mtimeMs > STALE_STAGING_MS)
45
+ rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ catch {
48
+ // another process may be removing it too
49
+ }
50
+ }
51
+ }
52
+ /**
53
+ * Downloads the pinned build unless it is complete already; returns its executable. The download goes into a
54
+ * private staging folder and is moved into place once complete, so two installs at once do not break each other.
55
+ */
56
+ export async function installBrowser(name, options = {}) {
57
+ const { cacheDir, downloader } = store(options);
58
+ const pin = PINNED[name];
59
+ if (!downloader.platform())
60
+ throw new Error(`${pin.label} has no download for ${process.platform} ${process.arch}`);
61
+ const dir = downloader.installDir(cacheDir, name, pin.buildId);
62
+ const exe = () => downloader.executablePath(cacheDir, name, pin.buildId);
63
+ if (complete(dir))
64
+ return exe();
65
+ let staging;
66
+ try {
67
+ removeStaleStaging(cacheDir);
68
+ mkdirSync(cacheDir, { recursive: true });
69
+ staging = mkdtempSync(path.join(cacheDir, STAGING_PREFIX));
70
+ for (const part of parts(name))
71
+ await downloader.install(staging, part, pin.buildId, part === name ? options.onProgress : undefined);
72
+ writeFileSync(path.join(downloader.installDir(staging, name, pin.buildId), COMPLETE_MARKER), new Date().toISOString());
73
+ // The part holding the marker moves last, so the build counts as complete only once every part is in place
74
+ for (const part of parts(name).reverse()) {
75
+ // Another install finished first
76
+ if (complete(dir))
77
+ return exe();
78
+ // An unfinished folder from an earlier attempt would block the move
79
+ downloader.clean(cacheDir, part, pin.buildId);
80
+ const target = downloader.installDir(cacheDir, part, pin.buildId);
81
+ mkdirSync(path.dirname(target), { recursive: true });
82
+ try {
83
+ renameSync(downloader.installDir(staging, part, pin.buildId), target);
84
+ }
85
+ catch (e) {
86
+ if (complete(dir))
87
+ return exe();
88
+ throw e;
89
+ }
90
+ }
91
+ }
92
+ catch (e) {
93
+ throw new Error(`Could not download ${pin.label}: ${e instanceof Error ? e.message : String(e)}`);
94
+ }
95
+ finally {
96
+ if (staging)
97
+ rmSync(staging, { recursive: true, force: true });
98
+ }
99
+ return exe();
100
+ }
101
+ /** The pinned build of `name`: whether it is there, and any path set instead of it */
102
+ export function statusOf(name, options = {}) {
103
+ const { cacheDir, downloader } = store(options);
104
+ const pin = PINNED[name];
105
+ const override = overrideOf(name, options);
106
+ const base = {
107
+ name,
108
+ label: pin.label,
109
+ buildId: pin.buildId,
110
+ launchable: LAUNCHABLE.includes(name),
111
+ ...(override && { override }),
112
+ };
113
+ if (!downloader.platform())
114
+ return { ...base, state: "unavailable" };
115
+ if (!complete(downloader.installDir(cacheDir, name, pin.buildId)))
116
+ return { ...base, state: "missing" };
117
+ return {
118
+ ...base,
119
+ state: "installed",
120
+ executablePath: downloader.executablePath(cacheDir, name, pin.buildId),
121
+ };
122
+ }
123
+ /** The pinned build's executable; throws BrowserNotInstalledError when it isn't complete */
124
+ export function managedExecutable(name, options = {}) {
125
+ const status = statusOf(name, { ...options, env: {} });
126
+ if (status.state !== "installed")
127
+ throw new BrowserNotInstalledError(name, status.label);
128
+ return status.executablePath;
129
+ }
130
+ /** The folder the pinned build of `name` is, or would be, installed in */
131
+ export function managedBuildDir(name, options = {}) {
132
+ const { cacheDir, downloader } = store(options);
133
+ return downloader.installDir(cacheDir, name, PINNED[name].buildId);
134
+ }
135
+ /** Removes a build (default: the pinned one); Chrome takes its chromedriver along */
136
+ export function uninstallBrowser(name, buildId = PINNED[name].buildId, options = {}) {
137
+ const { cacheDir, downloader } = store(options);
138
+ const root = path.resolve(cacheDir);
139
+ const dirs = parts(name).map((part) => ({
140
+ part,
141
+ dir: downloader.installDir(cacheDir, part, buildId),
142
+ }));
143
+ if (/[/\\]|\.\./.test(buildId) ||
144
+ dirs.some(({ dir }) => !path.resolve(dir).startsWith(root + path.sep)))
145
+ throw new Error(`Invalid build id "${buildId}"`);
146
+ for (const { part, dir } of dirs) {
147
+ if (!existsSync(dir))
148
+ continue;
149
+ // Without its marker a build that is only partly removed reads as missing
150
+ rmSync(path.join(dir, COMPLETE_MARKER), { force: true });
151
+ downloader.uninstall(cacheDir, part, buildId);
152
+ }
153
+ }
154
+ /** Every Firefox and Chrome build in the cache, also those other framework versions pinned */
155
+ export function listInstalled(options = {}) {
156
+ const { cacheDir, downloader } = store(options);
157
+ return downloader
158
+ .list(cacheDir)
159
+ .filter((b) => isManagedBrowser(b.browser))
160
+ .map((b) => {
161
+ const name = b.browser;
162
+ return {
163
+ name,
164
+ buildId: b.buildId,
165
+ dir: b.dir,
166
+ sizeBytes: sizeOf(b.dir),
167
+ pinned: PINNED[name].buildId === b.buildId,
168
+ };
169
+ });
170
+ }
@@ -19,6 +19,8 @@ export declare function parseCommandLine(argv: string[]): {
19
19
  at?: string | undefined;
20
20
  new?: string | undefined;
21
21
  url?: string | undefined;
22
+ build?: string | undefined;
23
+ unused?: boolean | undefined;
22
24
  help?: boolean | undefined;
23
25
  version?: boolean | undefined;
24
26
  };
package/dist/cli/args.js CHANGED
@@ -15,6 +15,8 @@ const FLAGS = {
15
15
  at: { type: "string" },
16
16
  new: { type: "string" },
17
17
  url: { type: "string" },
18
+ build: { type: "string" },
19
+ unused: { type: "boolean" },
18
20
  help: { type: "boolean", short: "h" },
19
21
  version: { type: "boolean", short: "v" },
20
22
  };
@@ -0,0 +1,6 @@
1
+ import { type StoreOptions } from "../browsers/index.js";
2
+ import type { Io } from "./main.js";
3
+ export declare const BROWSERS_USAGE = "Usage:\n samurai browsers [list] The pinned browsers and every build in the cache\n samurai browsers install [firefox|chrome|all] Download the pinned builds (default: all)\n samurai browsers uninstall <name> [--build <id>]\n Remove a build (default: the pinned one)\n samurai browsers uninstall --unused Remove builds this framework version doesn't pin\n";
4
+ export declare function formatBytes(n: number): string;
5
+ /** `samurai browsers …`; `options` is for tests. The cache folder and download host follow `io.env` */
6
+ export declare function browsersCommand(argv: string[], io: Io, options?: StoreOptions): Promise<number>;
@@ -0,0 +1,119 @@
1
+ import { browsersCacheDir, browserStatus, installBrowser, isManagedBrowser, listInstalled, MANAGED_BROWSERS, PINNED, uninstallBrowser, } from "../browsers/index.js";
2
+ import { puppeteerDownloader } from "../browsers/downloader.js";
3
+ import { parseCommandLine } from "./args.js";
4
+ export const BROWSERS_USAGE = `Usage:
5
+ samurai browsers [list] The pinned browsers and every build in the cache
6
+ samurai browsers install [firefox|chrome|all] Download the pinned builds (default: all)
7
+ samurai browsers uninstall <name> [--build <id>]
8
+ Remove a build (default: the pinned one)
9
+ samurai browsers uninstall --unused Remove builds this framework version doesn't pin
10
+ `;
11
+ export function formatBytes(n) {
12
+ if (n < 1024)
13
+ return `${n} B`;
14
+ if (n < 1024 * 1024)
15
+ return `${(n / 1024).toFixed(1)} KB`;
16
+ if (n < 1024 * 1024 * 1024)
17
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
18
+ return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
19
+ }
20
+ class UnknownBrowser extends Error {
21
+ }
22
+ function names(arg) {
23
+ if (arg === undefined || arg === "all")
24
+ return [...MANAGED_BROWSERS];
25
+ if (!isManagedBrowser(arg))
26
+ throw new UnknownBrowser(arg);
27
+ return [arg];
28
+ }
29
+ async function list(io, store) {
30
+ for (const s of await browserStatus({ ...store, env: io.env })) {
31
+ const state = s.override
32
+ ? `uses ${s.override.path} (${s.override.from === "env" ? "environment variable" : "samurai.config.ts"})`
33
+ : s.state === "installed"
34
+ ? "installed"
35
+ : s.state === "missing"
36
+ ? "not installed"
37
+ : "no download for this computer";
38
+ io.out(`${s.label.padEnd(36)} ${state}\n`);
39
+ }
40
+ const other = listInstalled(store).filter((b) => !b.pinned);
41
+ if (other.length > 0) {
42
+ io.out("\nOther builds in the cache:\n");
43
+ for (const b of other)
44
+ io.out(` ${b.name} ${b.buildId} ${formatBytes(b.sizeBytes)}\n`);
45
+ }
46
+ }
47
+ async function install(io, targets, store) {
48
+ const baseUrl = io.env.SAMURAI_BROWSERS_BASE_URL;
49
+ if (baseUrl)
50
+ io.out(`Downloading from ${baseUrl}\n`);
51
+ for (const name of targets) {
52
+ const label = PINNED[name].label;
53
+ let shown = -1;
54
+ const exe = await installBrowser(name, {
55
+ ...store,
56
+ onProgress: (done, total) => {
57
+ const percent = total > 0 ? Math.floor((done / total) * 100) : 0;
58
+ if (io.color) {
59
+ io.out(`\r${label}: ${percent}%`);
60
+ }
61
+ else if (percent >= shown + 10 ||
62
+ (percent === 100 && shown !== 100)) {
63
+ shown = percent;
64
+ io.out(`${label}: ${percent}%\n`);
65
+ }
66
+ },
67
+ });
68
+ if (io.color)
69
+ io.out("\n");
70
+ io.out(`${label} installed at ${exe}\n`);
71
+ }
72
+ }
73
+ /** `samurai browsers …`; `options` is for tests. The cache folder and download host follow `io.env` */
74
+ export async function browsersCommand(argv, io, options = {}) {
75
+ const store = {
76
+ cacheDir: options.cacheDir ?? browsersCacheDir(io.env),
77
+ downloader: options.downloader ??
78
+ puppeteerDownloader(io.env.SAMURAI_BROWSERS_BASE_URL),
79
+ };
80
+ const { values, positionals } = parseCommandLine(argv);
81
+ const [sub = "list", target] = positionals;
82
+ try {
83
+ if (sub === "list") {
84
+ await list(io, store);
85
+ return 0;
86
+ }
87
+ if (sub === "install") {
88
+ await install(io, names(target), store);
89
+ return 0;
90
+ }
91
+ if (sub === "uninstall") {
92
+ if (values.unused) {
93
+ for (const b of listInstalled(store).filter((b) => !b.pinned)) {
94
+ uninstallBrowser(b.name, b.buildId, store);
95
+ io.out(`removed ${b.name} ${b.buildId}\n`);
96
+ }
97
+ io.out("Projects on other framework versions may still use the removed builds.\n");
98
+ return 0;
99
+ }
100
+ if (target === undefined || target === "all") {
101
+ io.err(`Which browser? \n\n${BROWSERS_USAGE}`);
102
+ return 2;
103
+ }
104
+ const [name] = names(target);
105
+ uninstallBrowser(name, values.build, store);
106
+ io.out(`removed ${name} ${values.build ?? PINNED[name].buildId}\n`);
107
+ return 0;
108
+ }
109
+ io.err(`Unknown browsers command "${sub}"\n\n${BROWSERS_USAGE}`);
110
+ return 2;
111
+ }
112
+ catch (e) {
113
+ if (e instanceof UnknownBrowser) {
114
+ io.err(`Unknown browser "${e.message}". Choose firefox, chrome or all.\n`);
115
+ return 1;
116
+ }
117
+ throw e;
118
+ }
119
+ }
@@ -1,4 +1,4 @@
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\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 --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\nOptions for record (also --env, --port, --json):\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\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\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\nOptions for record (also --env, --port, --json):\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";
2
2
  export interface Io {
3
3
  out: (text: string) => void;
4
4
  err: (text: string) => void;
package/dist/cli/main.js CHANGED
@@ -4,6 +4,7 @@ import { recordSpec } from "../recorder/index.js";
4
4
  import { listTests, runTests } from "../runner/run.js";
5
5
  import { stepToSource } from "../steps/index.js";
6
6
  import { ConsoleReporter } from "./console-reporter.js";
7
+ import { browsersCommand } from "./browsers.js";
7
8
  import { flowCommand } from "./flow.js";
8
9
  import { initProject } from "./init.js";
9
10
  export const USAGE = `samurai: browser tests over WebDriver BiDi
@@ -14,6 +15,7 @@ Usage:
14
15
  samurai record <spec> Open a browser and record what you do into a spec file
15
16
  samurai init [folder] Scaffold a project: config, an example test, .env.example
16
17
  samurai flow <command> List, check and run the flows in ./flows (see below)
18
+ samurai browsers [command] List, install and remove the browsers SAMURAI launches
17
19
 
18
20
  Options for run and list:
19
21
  --env <name> Environment to run against (or SAMURAI_ENV)
@@ -48,6 +50,12 @@ samurai flow:
48
50
  Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported
49
51
  Run \`samurai flow\` alone for the details.
50
52
 
53
+ samurai browsers:
54
+ list The pinned browsers and every build in the cache (the default)
55
+ install [firefox|chrome|all]
56
+ Download the pinned builds (default: all)
57
+ uninstall <name> [--build <id>] | --unused
58
+
51
59
  Run it in the project folder, the one holding samurai.config.ts.
52
60
  `;
53
61
  function version() {
@@ -72,13 +80,15 @@ export async function main(argv, io) {
72
80
  }
73
81
  if (command === "flow")
74
82
  return await flowCommand(rest, io);
83
+ if (command === "browsers")
84
+ return await browsersCommand(rest, io);
75
85
  if (command === "init") {
76
86
  const { written, skipped } = initProject(positionals[0] ?? ".");
77
87
  for (const file of written)
78
88
  io.out(`created ${file}\n`);
79
89
  for (const file of skipped)
80
90
  io.out(`kept ${file} (already there)\n`);
81
- io.out("\nNext: install the dependencies, then `samurai run`.\n");
91
+ io.out("\nNext: install the dependencies, then `samurai browsers install firefox`, then `samurai run`.\n");
82
92
  return 0;
83
93
  }
84
94
  if (command === "record") {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Browser } from "../browser/browser.js";
4
+ import { ensureWarm } from "../browser/warm-up.js";
4
5
  import { projectDir } from "../config/config.js";
5
6
  import { withProject } from "../runner/project.js";
6
7
  import { parseSpec } from "../steps/parse.js";
@@ -52,6 +53,9 @@ export async function recordSpec(options) {
52
53
  if (source === undefined && options.create === undefined) {
53
54
  throw new Error(`${options.file} does not exist; give a title with --new to create it`);
54
55
  }
56
+ // Before the spec is touched: a recording without its browser changes nothing
57
+ await ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
58
+ options.signal?.throwIfAborted();
55
59
  source ??= "";
56
60
  if (options.create !== undefined &&
57
61
  !parseSpec(source).some((t) => t.name === options.create)) {
@@ -3,6 +3,7 @@ import { glob } from "node:fs/promises";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
5
  import { Browser } from "../browser/browser.js";
6
+ import { ensureWarm } from "../browser/warm-up.js";
6
7
  import { loadConfig, projectDir, readConfig } from "../config/config.js";
7
8
  import logger from "../logger/index.js";
8
9
  import { assertModuleProject } from "./module-type.js";
@@ -115,6 +116,10 @@ export default class TestRunner {
115
116
  ...(onEvent && { onEvent }),
116
117
  });
117
118
  const selected = await this.register();
119
+ // A missing browser is one clear error before the run, not a failure per test; a fresh build's slow
120
+ // first start happens here, outside any test's timeout
121
+ if (selected.length > 0)
122
+ await ensureWarm((await readConfig("browser")) ?? "firefox", { port: this.options.port ?? 9223 }, signal);
118
123
  reporter.onStart(selected.length);
119
124
  for (const test of selected) {
120
125
  if (signal?.aborted) {
@@ -1,5 +1,7 @@
1
1
  import type { BiDiCommands, BiDiEvents } from "../types/bidi.js";
2
2
  import { type EventName, type Subscription, type SubscribeOptions } from "./subscriptions.js";
3
+ /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
4
+ export declare const DEFAULT_COMMAND_TIMEOUT = 30000;
3
5
  export interface ConnectorOptions {
4
6
  /** @default 30000 */
5
7
  commandTimeout?: number;
@@ -4,7 +4,7 @@ import { WaitTimeoutError } from "../wait/wait-until.js";
4
4
  import { BiDiError } from "./bidi-error.js";
5
5
  import { SubscriptionManager, } from "./subscriptions.js";
6
6
  /** Time (ms) a command may wait for its reply before it fails with code "timeout" */
7
- const DEFAULT_COMMAND_TIMEOUT = 30000;
7
+ export const DEFAULT_COMMAND_TIMEOUT = 30000;
8
8
  /**
9
9
  * The connection to the browser is gone (the browser exited or was closed, or the socket dropped). Commands that
10
10
  * were waiting fail with it, and so does every command sent afterwards: a closed socket would swallow them silently.
@@ -49,6 +49,11 @@ export interface SamuraiTestConfig {
49
49
  * Is overwritten by the 'browser' in a group's config
50
50
  */
51
51
  browser?: SupportedBrowser;
52
+ /**
53
+ * Browser binaries to launch instead of the builds SAMURAI downloads, relative to the project.
54
+ * `SAMURAI_FIREFOX_PATH` / `SAMURAI_CHROME_PATH` win over these.
55
+ */
56
+ browserPaths?: { firefox?: string; chrome?: string };
52
57
  /**
53
58
  * The timeout (in ms) to use for all tests
54
59
  * @default 30000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {
@@ -24,6 +24,10 @@
24
24
  "types": "./dist/cli/init.d.ts",
25
25
  "default": "./dist/cli/init.js"
26
26
  },
27
+ "./browsers": {
28
+ "types": "./dist/browsers/index.d.ts",
29
+ "default": "./dist/browsers/index.js"
30
+ },
27
31
  "./browser": {
28
32
  "types": "./dist/browser/index.d.ts",
29
33
  "default": "./dist/browser/index.js"
@@ -65,6 +69,7 @@
65
69
  "tsx": "^4.21.0"
66
70
  },
67
71
  "dependencies": {
72
+ "@puppeteer/browsers": "^3.2.4",
68
73
  "typescript": "^6.0.2",
69
74
  "winston": "^3.19.0"
70
75
  },