@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 +2 -2
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.d.ts +1 -0
- package/dist/browser/browser-finder.js +1 -0
- package/dist/browser/browser.d.ts +8 -0
- package/dist/browser/browser.js +33 -12
- package/dist/browser/warm-up.d.ts +14 -0
- package/dist/browser/warm-up.js +37 -0
- package/dist/browsers/cache-dir.d.ts +2 -0
- package/dist/browsers/cache-dir.js +15 -0
- package/dist/browsers/downloader.d.ts +23 -0
- package/dist/browsers/downloader.js +52 -0
- package/dist/browsers/index.d.ts +6 -0
- package/dist/browsers/index.js +5 -0
- package/dist/browsers/overrides.d.ts +17 -0
- package/dist/browsers/overrides.js +15 -0
- package/dist/browsers/pins.d.ts +16 -0
- package/dist/browsers/pins.js +14 -0
- package/dist/browsers/resolve.d.ts +26 -0
- package/dist/browsers/resolve.js +56 -0
- package/dist/browsers/store.d.ts +52 -0
- package/dist/browsers/store.js +170 -0
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/browsers.d.ts +6 -0
- package/dist/cli/browsers.js +119 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +11 -1
- package/dist/recorder/session.js +4 -0
- package/dist/runner/test-runner.js +5 -0
- package/dist/transport/bidi-connection.d.ts +2 -0
- package/dist/transport/bidi-connection.js +1 -1
- package/dist/types/config.d.ts +5 -0
- package/package.json +6 -1
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
|
|
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
|
|
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
|
@@ -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;
|
package/dist/browser/browser.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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,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
|
+
}
|
package/dist/cli/args.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -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") {
|
package/dist/recorder/session.js
CHANGED
|
@@ -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.
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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.
|
|
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
|
},
|