@itmetsam/samurai-framework 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +4 -4
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/api.d.ts +1 -0
  4. package/dist/api.js +1 -0
  5. package/dist/browser/browser-finder.d.ts +1 -0
  6. package/dist/browser/browser-finder.js +1 -0
  7. package/dist/browser/browser.d.ts +8 -0
  8. package/dist/browser/browser.js +33 -12
  9. package/dist/browser/evidence.d.ts +19 -0
  10. package/dist/browser/evidence.js +35 -0
  11. package/dist/browser/page.d.ts +6 -0
  12. package/dist/browser/page.js +20 -3
  13. package/dist/browser/screenshot.d.ts +2 -0
  14. package/dist/browser/screenshot.js +5 -2
  15. package/dist/browser/warm-up.d.ts +14 -0
  16. package/dist/browser/warm-up.js +37 -0
  17. package/dist/browsers/cache-dir.d.ts +2 -0
  18. package/dist/browsers/cache-dir.js +15 -0
  19. package/dist/browsers/downloader.d.ts +23 -0
  20. package/dist/browsers/downloader.js +52 -0
  21. package/dist/browsers/index.d.ts +6 -0
  22. package/dist/browsers/index.js +5 -0
  23. package/dist/browsers/overrides.d.ts +17 -0
  24. package/dist/browsers/overrides.js +15 -0
  25. package/dist/browsers/pins.d.ts +16 -0
  26. package/dist/browsers/pins.js +14 -0
  27. package/dist/browsers/resolve.d.ts +26 -0
  28. package/dist/browsers/resolve.js +56 -0
  29. package/dist/browsers/store.d.ts +52 -0
  30. package/dist/browsers/store.js +170 -0
  31. package/dist/cli/args.d.ts +2 -0
  32. package/dist/cli/args.js +2 -0
  33. package/dist/cli/browsers.d.ts +6 -0
  34. package/dist/cli/browsers.js +117 -0
  35. package/dist/cli/main.d.ts +1 -1
  36. package/dist/cli/main.js +26 -2
  37. package/dist/project/config-edit.d.ts +16 -0
  38. package/dist/project/config-edit.js +218 -0
  39. package/dist/project/index.d.ts +3 -0
  40. package/dist/project/index.js +3 -0
  41. package/dist/project/secrets-file.d.ts +8 -0
  42. package/dist/project/secrets-file.js +69 -0
  43. package/dist/project/variables.d.ts +6 -0
  44. package/dist/project/variables.js +17 -0
  45. package/dist/recorder/candidates.d.ts +7 -0
  46. package/dist/recorder/candidates.js +118 -0
  47. package/dist/recorder/capture.d.ts +8 -1
  48. package/dist/recorder/capture.js +25 -116
  49. package/dist/recorder/index.d.ts +1 -1
  50. package/dist/recorder/normalise.d.ts +28 -1
  51. package/dist/recorder/normalise.js +40 -1
  52. package/dist/recorder/recorder.d.ts +54 -0
  53. package/dist/recorder/recorder.js +195 -10
  54. package/dist/recorder/session.d.ts +15 -0
  55. package/dist/recorder/session.js +61 -7
  56. package/dist/recorder/toolbar/controller.d.ts +136 -0
  57. package/dist/recorder/toolbar/controller.js +471 -0
  58. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  59. package/dist/recorder/toolbar/inspect.js +94 -0
  60. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  61. package/dist/recorder/toolbar/protocol.js +4 -0
  62. package/dist/recorder/toolbar/source.d.ts +32 -0
  63. package/dist/recorder/toolbar/source.js +856 -0
  64. package/dist/recorder/toolbar/styles.d.ts +6 -0
  65. package/dist/recorder/toolbar/styles.js +95 -0
  66. package/dist/runner/reporter.d.ts +5 -1
  67. package/dist/runner/reporter.js +4 -3
  68. package/dist/runner/test-runner.d.ts +9 -0
  69. package/dist/runner/test-runner.js +44 -4
  70. package/dist/steps/emit.js +4 -0
  71. package/dist/steps/model.d.ts +11 -0
  72. package/dist/steps/parse.js +22 -3
  73. package/dist/transport/bidi-connection.d.ts +2 -0
  74. package/dist/transport/bidi-connection.js +33 -2
  75. package/dist/types/bidi-modules/script.d.ts +20 -3
  76. package/dist/types/config.d.ts +5 -0
  77. package/dist/types/test.d.ts +4 -0
  78. package/package.json +10 -1
@@ -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 follows `io.env` */
6
+ export declare function browsersCommand(argv: string[], io: Io, options?: StoreOptions): Promise<number>;
@@ -0,0 +1,117 @@
1
+ import { browsersCacheDir, browserStatus, installBrowser, isManagedBrowser, listInstalled, MANAGED_BROWSERS, PINNED, uninstallBrowser, } from "../browsers/index.js";
2
+ import { parseCommandLine } from "./args.js";
3
+ export const BROWSERS_USAGE = `Usage:
4
+ samurai browsers [list] The pinned browsers and every build in the cache
5
+ samurai browsers install [firefox|chrome|all] Download the pinned builds (default: all)
6
+ samurai browsers uninstall <name> [--build <id>]
7
+ Remove a build (default: the pinned one)
8
+ samurai browsers uninstall --unused Remove builds this framework version doesn't pin
9
+ `;
10
+ export function formatBytes(n) {
11
+ if (n < 1024)
12
+ return `${n} B`;
13
+ if (n < 1024 * 1024)
14
+ return `${(n / 1024).toFixed(1)} KB`;
15
+ if (n < 1024 * 1024 * 1024)
16
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
17
+ return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
18
+ }
19
+ class UnknownBrowser extends Error {
20
+ }
21
+ function names(arg) {
22
+ if (arg === undefined || arg === "all")
23
+ return [...MANAGED_BROWSERS];
24
+ if (!isManagedBrowser(arg))
25
+ throw new UnknownBrowser(arg);
26
+ return [arg];
27
+ }
28
+ async function list(io, store) {
29
+ for (const s of await browserStatus({ ...store, env: io.env })) {
30
+ const state = s.override
31
+ ? `uses ${s.override.path} (${s.override.from === "env" ? "environment variable" : "samurai.config.ts"})`
32
+ : s.state === "installed"
33
+ ? "installed"
34
+ : s.state === "missing"
35
+ ? "not installed"
36
+ : "no download for this computer";
37
+ io.out(`${s.label.padEnd(36)} ${state}\n`);
38
+ }
39
+ const other = listInstalled(store).filter((b) => !b.pinned);
40
+ if (other.length > 0) {
41
+ io.out("\nOther builds in the cache:\n");
42
+ for (const b of other)
43
+ io.out(` ${b.name} ${b.buildId} ${formatBytes(b.sizeBytes)}\n`);
44
+ }
45
+ }
46
+ async function install(io, targets, store) {
47
+ const baseUrl = io.env.SAMURAI_BROWSERS_BASE_URL;
48
+ if (baseUrl)
49
+ io.out(`Downloading from ${baseUrl}\n`);
50
+ for (const name of targets) {
51
+ const label = PINNED[name].label;
52
+ let shown = -1;
53
+ const exe = await installBrowser(name, {
54
+ ...store,
55
+ onProgress: (done, total) => {
56
+ const percent = total > 0 ? Math.floor((done / total) * 100) : 0;
57
+ if (io.color) {
58
+ io.out(`\r${label}: ${percent}%`);
59
+ }
60
+ else if (percent >= shown + 10 ||
61
+ (percent === 100 && shown !== 100)) {
62
+ shown = percent;
63
+ io.out(`${label}: ${percent}%\n`);
64
+ }
65
+ },
66
+ });
67
+ if (io.color)
68
+ io.out("\n");
69
+ io.out(`${label} installed at ${exe}\n`);
70
+ }
71
+ }
72
+ /** `samurai browsers …`; `options` is for tests. The cache folder follows `io.env` */
73
+ export async function browsersCommand(argv, io, options = {}) {
74
+ const store = {
75
+ ...options,
76
+ cacheDir: options.cacheDir ?? browsersCacheDir(io.env),
77
+ };
78
+ const { values, positionals } = parseCommandLine(argv);
79
+ const [sub = "list", target] = positionals;
80
+ try {
81
+ if (sub === "list") {
82
+ await list(io, store);
83
+ return 0;
84
+ }
85
+ if (sub === "install") {
86
+ await install(io, names(target), store);
87
+ return 0;
88
+ }
89
+ if (sub === "uninstall") {
90
+ if (values.unused) {
91
+ for (const b of listInstalled(store).filter((b) => !b.pinned)) {
92
+ uninstallBrowser(b.name, b.buildId, store);
93
+ io.out(`removed ${b.name} ${b.buildId}\n`);
94
+ }
95
+ io.out("Projects on other framework versions may still use the removed builds.\n");
96
+ return 0;
97
+ }
98
+ if (target === undefined || target === "all") {
99
+ io.err(`Which browser? \n\n${BROWSERS_USAGE}`);
100
+ return 2;
101
+ }
102
+ const [name] = names(target);
103
+ uninstallBrowser(name, values.build, store);
104
+ io.out(`removed ${name} ${values.build ?? PINNED[name].buildId}\n`);
105
+ return 0;
106
+ }
107
+ io.err(`Unknown browsers command "${sub}"\n\n${BROWSERS_USAGE}`);
108
+ return 2;
109
+ }
110
+ catch (e) {
111
+ if (e instanceof UnknownBrowser) {
112
+ io.err(`Unknown browser "${e.message}". Choose firefox, chrome or all.\n`);
113
+ return 1;
114
+ }
115
+ throw e;
116
+ }
117
+ }
@@ -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") {
@@ -95,6 +105,8 @@ export async function main(argv, io) {
95
105
  ? Number(values.test)
96
106
  : values.test;
97
107
  let count = 0;
108
+ let state = "recording";
109
+ let toolbarWarned = false;
98
110
  const result = await recordSpec({
99
111
  ...parseRunOverrides(rest, io.env),
100
112
  file,
@@ -106,7 +118,19 @@ export async function main(argv, io) {
106
118
  ...(io.signal && { signal: io.signal }),
107
119
  onStarted: () => {
108
120
  if (!values.json)
109
- io.out("Recording. Alt+click an element to assert on it. Press Ctrl+C or close the window to stop.\n");
121
+ io.out("Recording. Alt+click an element to assert on it. Press Stop in the toolbar, Ctrl+C or close the window to stop.\n");
122
+ },
123
+ onStatus: (status) => {
124
+ if (values.json)
125
+ return;
126
+ if (!status.toolbar && !toolbarWarned) {
127
+ toolbarWarned = true;
128
+ io.out("The toolbar couldn't load on this page. Alt+click still records assertions.\n");
129
+ }
130
+ if (status.state !== state) {
131
+ state = status.state;
132
+ io.out(state === "paused" ? "Paused\n" : "Recording\n");
133
+ }
110
134
  },
111
135
  onEvent: (event) => {
112
136
  if (values.json)
@@ -0,0 +1,16 @@
1
+ export type ConfigValue = string | number | boolean | ConfigValue[] | {
2
+ [key: string]: ConfigValue;
3
+ };
4
+ /** The config can't be edited in place, e.g. it builds its object from variables or spreads */
5
+ export declare class ConfigEditError extends Error {
6
+ }
7
+ /**
8
+ * Sets (or with `undefined`, removes) one value in samurai.config.ts, addressed by its path, e.g.
9
+ * ["environments", "staging", "baseURL"]. Only that property's text changes; missing objects on the way are
10
+ * created. Throws ConfigEditError when the file isn't shaped so that this is safe.
11
+ */
12
+ export declare function editConfig(source: string, path: string[], value: ConfigValue | undefined): string;
13
+ /** The text of one property (`staging: { … }`), indentation removed, for showing a part of the file; undefined when missing */
14
+ export declare function propertyText(source: string, path: string[]): string | undefined;
15
+ /** Whether the property at this path exists in the file's source, walking the same object literals `editConfig` does */
16
+ export declare function hasConfigPath(source: string, path: string[]): boolean;