@itmetsam/samurai-framework 0.4.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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/browser-finder.d.ts +1 -0
  5. package/dist/browser/browser-finder.js +1 -0
  6. package/dist/browser/browser.d.ts +8 -0
  7. package/dist/browser/browser.js +33 -12
  8. package/dist/browser/warm-up.d.ts +14 -0
  9. package/dist/browser/warm-up.js +37 -0
  10. package/dist/browsers/cache-dir.d.ts +2 -0
  11. package/dist/browsers/cache-dir.js +15 -0
  12. package/dist/browsers/downloader.d.ts +23 -0
  13. package/dist/browsers/downloader.js +52 -0
  14. package/dist/browsers/index.d.ts +6 -0
  15. package/dist/browsers/index.js +5 -0
  16. package/dist/browsers/overrides.d.ts +17 -0
  17. package/dist/browsers/overrides.js +15 -0
  18. package/dist/browsers/pins.d.ts +16 -0
  19. package/dist/browsers/pins.js +14 -0
  20. package/dist/browsers/resolve.d.ts +26 -0
  21. package/dist/browsers/resolve.js +56 -0
  22. package/dist/browsers/store.d.ts +52 -0
  23. package/dist/browsers/store.js +170 -0
  24. package/dist/cli/args.d.ts +2 -0
  25. package/dist/cli/args.js +2 -0
  26. package/dist/cli/browsers.d.ts +6 -0
  27. package/dist/cli/browsers.js +119 -0
  28. package/dist/cli/flow-reporter.d.ts +5 -2
  29. package/dist/cli/flow-reporter.js +3 -2
  30. package/dist/cli/flow.d.ts +1 -1
  31. package/dist/cli/flow.js +4 -2
  32. package/dist/cli/main.d.ts +1 -1
  33. package/dist/cli/main.js +11 -1
  34. package/dist/config/config.d.ts +5 -0
  35. package/dist/config/config.js +13 -0
  36. package/dist/flows/core/steps.js +14 -3
  37. package/dist/flows/run/index.d.ts +1 -0
  38. package/dist/flows/run/index.js +1 -0
  39. package/dist/flows/run/project.js +18 -21
  40. package/dist/flows/steps/api.js +28 -5
  41. package/dist/flows/steps/catalog.d.ts +10 -4
  42. package/dist/flows/steps/catalog.js +23 -16
  43. package/dist/flows/steps/define.d.ts +2 -0
  44. package/dist/flows/steps/define.js +15 -4
  45. package/dist/flows/steps/handler.js +16 -4
  46. package/dist/flows/steps/index.d.ts +3 -2
  47. package/dist/flows/steps/index.js +2 -1
  48. package/dist/flows/steps/set-variable.js +14 -3
  49. package/dist/flows/steps/set.d.ts +26 -0
  50. package/dist/flows/steps/set.js +43 -0
  51. package/dist/flows/steps/wait.js +8 -1
  52. package/dist/recorder/session.js +4 -0
  53. package/dist/runner/groups.d.ts +5 -0
  54. package/dist/runner/groups.js +13 -0
  55. package/dist/runner/test-runner.js +9 -3
  56. package/dist/transport/bidi-connection.d.ts +2 -0
  57. package/dist/transport/bidi-connection.js +1 -1
  58. package/dist/types/config.d.ts +12 -0
  59. package/package.json +6 -1
@@ -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,10 +1,12 @@
1
- import { type FlowFile } from "../flows/core/index.js";
1
+ import { type AnyNodeDefinition, type FlowFile } from "../flows/core/index.js";
2
2
  import { type FlowEvent } from "../flows/run/index.js";
3
3
  export interface FlowReporterOptions {
4
4
  flow: FlowFile;
5
5
  write: (text: string) => void;
6
6
  /** Colour with ANSI codes */
7
7
  color: boolean;
8
+ /** Definitions of the project's steps, for their labels. @default the built-in steps */
9
+ steps?: readonly AnyNodeDefinition[];
8
10
  }
9
11
  /** Prints a flow run for a person: one line per node as it ends, then a count. Start and End are not listed. */
10
12
  export declare class FlowReporter {
@@ -12,8 +14,9 @@ export declare class FlowReporter {
12
14
  private readonly paint;
13
15
  private readonly nodes;
14
16
  private readonly flow;
17
+ private readonly steps;
15
18
  private started;
16
- constructor({ flow, write, color }: FlowReporterOptions);
19
+ constructor({ flow, write, color, steps }: FlowReporterOptions);
17
20
  private shapeOf;
18
21
  private listed;
19
22
  private nameOf;
@@ -5,8 +5,9 @@ const took = (ms) => ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(
5
5
  const SKIPPED_BY_SHAPE = new Set(["start", "end"]);
6
6
  /** Prints a flow run for a person: one line per node as it ends, then a count. Start and End are not listed. */
7
7
  export class FlowReporter {
8
- constructor({ flow, write, color }) {
8
+ constructor({ flow, write, color, steps }) {
9
9
  this.started = Date.now();
10
+ this.steps = steps ?? STEP_NODES;
10
11
  this.flow = flow;
11
12
  this.write = write;
12
13
  this.paint = (code, text) => color ? `\u001b[${code}m${text}\u001b[0m` : text;
@@ -29,7 +30,7 @@ export class FlowReporter {
29
30
  label = nodeDef(node.kind).label;
30
31
  }
31
32
  catch {
32
- label = STEP_NODES.find((d) => d.kind === node.kind)?.label ?? node.kind;
33
+ label = this.steps.find((d) => d.kind === node.kind)?.label ?? node.kind;
33
34
  }
34
35
  const base = node.title || label;
35
36
  const wrote = result.wrote ? Object.keys(result.wrote) : [];
@@ -1,4 +1,4 @@
1
1
  import type { Io } from "./main.js";
2
- export declare const FLOW_USAGE = "Usage:\n samurai flow list [--json] List the flows in ./flows\n samurai flow check [<flow>...] [options] Check flows without running them (all flows when none is given)\n samurai flow run <flow>... [options] Run flows, in the order given\n samurai flow run --all [options] Run every flow in ./flows, in file name order\n\nA flow is an id (flows/<id>.flow.json) or a path to a .flow.json file.\n\nOptions for check and run:\n --env <name> Environment to use (or SAMURAI_ENV)\n --allow-unsupported Skip nodes that can't run yet (HTTP request, ...) instead of refusing the flow\n --json Machine-readable output: JSON lines for run, one array for check and 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 --port <n> Browser debugging port (default 9223)\n\nEach run writes result/flows/<id>.json.\nExit code: 0 when every node that should run passed, 1 when a node failed or the run was cancelled,\n2 for a usage, configuration or flow-file error, or a flow that has errors (nothing ran).\n";
2
+ export declare const FLOW_USAGE = "Usage:\n samurai flow list [--json] List the flows in ./flows\n samurai flow check [<flow>...] [options] Check flows without running them (all flows when none is given)\n samurai flow run <flow>... [options] Run flows, in the order given\n samurai flow run --all [options] Run every flow in ./flows, in file name order\n\nA flow is an id (flows/<id>.flow.json) or a path to a .flow.json file.\n\nOptions for check and run:\n --env <name> Environment to use (or SAMURAI_ENV)\n --allow-unsupported Skip nodes whose kind has no handler instead of refusing the flow (every built-in kind runs)\n --json Machine-readable output: JSON lines for run, one array for check and 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 --port <n> Browser debugging port (default 9223)\n\nEach run writes result/flows/<id>.json.\nExit code: 0 when every node that should run passed, 1 when a node failed or the run was cancelled,\n2 for a usage, configuration or flow-file error, or a flow that has errors (nothing ran).\n";
3
3
  /** `samurai flow ...`: `argv` is what follows `flow`. Returns the exit code */
4
4
  export declare function flowCommand(argv: string[], io: Io): Promise<number>;
package/dist/cli/flow.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { parseCommandLine, parseRunnerFlags, parseRunOverrides, } from "./args.js";
3
3
  import { FlowReporter } from "./flow-reporter.js";
4
+ import { projectSteps } from "../flows/steps/index.js";
4
5
  import {} from "../flows/core/index.js";
5
6
  import { FLOW_SUFFIX, FlowCheckError, FlowReportBuilder, checkFlowInProject, flowsDir, listFlows, readFlow, readFlowPath, runFlowInProject, writeFlowReport, } from "../flows/run/index.js";
6
7
  export const FLOW_USAGE = `Usage:
@@ -13,7 +14,7 @@ A flow is an id (flows/<id>.flow.json) or a path to a .flow.json file.
13
14
 
14
15
  Options for check and run:
15
16
  --env <name> Environment to use (or SAMURAI_ENV)
16
- --allow-unsupported Skip nodes that can't run yet (HTTP request, ...) instead of refusing the flow
17
+ --allow-unsupported Skip nodes whose kind has no handler instead of refusing the flow (every built-in kind runs)
17
18
  --json Machine-readable output: JSON lines for run, one array for check and list
18
19
 
19
20
  Options for run:
@@ -136,6 +137,7 @@ async function run(refs, io, options) {
136
137
  io.err(`${flow.id} can't run:\n${problemLines(io, described)}Nothing was run.\n`);
137
138
  return 2;
138
139
  };
140
+ const { nodes: steps } = await projectSteps({ projectDir });
139
141
  // Several flows: refuse them all before the first one runs
140
142
  if (flows.length > 1) {
141
143
  for (const flow of flows) {
@@ -159,7 +161,7 @@ async function run(refs, io, options) {
159
161
  const builder = new FlowReportBuilder(flow);
160
162
  const human = options.json
161
163
  ? undefined
162
- : new FlowReporter({ flow, write: io.out, color: io.color });
164
+ : new FlowReporter({ flow, write: io.out, color: io.color, steps });
163
165
  if (flows.indexOf(flow) > 0 && !options.json)
164
166
  io.out("\n");
165
167
  let summary;
@@ -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") {
@@ -19,6 +19,11 @@ export declare function projectDir(): string;
19
19
  export declare function browsersDir(): string;
20
20
  /** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
21
21
  export declare function loadConfig(): Promise<SamuraiTestConfig>;
22
+ /** A project's config without keeping the project active (`withProject` is one at a time): the given one, else its samurai.config.ts */
23
+ export declare function projectConfig(options: {
24
+ projectDir?: string;
25
+ config?: SamuraiTestConfig;
26
+ }): Promise<SamuraiTestConfig>;
22
27
  export declare function readConfig<K extends keyof SamuraiTestConfig>(key: K): Promise<SamuraiTestConfig[K]>;
23
28
  export declare function defineConfig(config: SamuraiTestConfig): SamuraiTestConfig;
24
29
  export {};
@@ -32,6 +32,19 @@ export async function loadConfig() {
32
32
  }
33
33
  return config.default;
34
34
  }
35
+ /** A project's config without keeping the project active (`withProject` is one at a time): the given one, else its samurai.config.ts */
36
+ export async function projectConfig(options) {
37
+ if (options.config)
38
+ return options.config;
39
+ const previous = activeProject;
40
+ setActiveProject({ dir: path.resolve(options.projectDir ?? process.cwd()) });
41
+ try {
42
+ return await loadConfig();
43
+ }
44
+ finally {
45
+ setActiveProject(previous);
46
+ }
47
+ }
35
48
  export async function readConfig(key) {
36
49
  const value = (await loadConfig())[key];
37
50
  logger.debug("Resolved config value: ", { value });
@@ -1,7 +1,11 @@
1
1
  import { getPath } from "./nodes/path.js";
2
2
  export const STEP_KIND = /^[a-z][a-z0-9-]*$/;
3
3
  /** A node's own result fields: an output can't take these names */
4
- export const RESERVED_OUTPUTS = ["status", "durationMs", "error"];
4
+ export const RESERVED_OUTPUTS = [
5
+ "status",
6
+ "durationMs",
7
+ "error",
8
+ ];
5
9
  const TITLE_FIELDS = [
6
10
  { path: "title", type: "text", label: "Title" },
7
11
  { path: "subtitle", type: "text", label: "Description" },
@@ -10,7 +14,11 @@ const empty = (value) => value === undefined || (typeof value === "string" && va
10
14
  /** Problems with `node`'s values against the fields' rules, as "Label: message" */
11
15
  export function fieldProblems(fields, node, label) {
12
16
  const problems = [];
13
- const add = (message) => problems.push({ level: "error", nodeId: node.id, message: `${label}: ${message}` });
17
+ const add = (message) => problems.push({
18
+ level: "error",
19
+ nodeId: node.id,
20
+ message: `${label}: ${message}`,
21
+ });
14
22
  for (const f of fields) {
15
23
  const name = f.label ?? f.path;
16
24
  const value = getPath(node, f.path);
@@ -24,7 +32,10 @@ export function fieldProblems(fields, node, label) {
24
32
  if (f.max !== undefined && value > f.max)
25
33
  add(`${name} must be at most ${f.max}.`);
26
34
  }
27
- if (f.pattern && typeof value === "string" && value !== "" && !new RegExp(f.pattern).test(value))
35
+ if (f.pattern &&
36
+ typeof value === "string" &&
37
+ value !== "" &&
38
+ !new RegExp(f.pattern).test(value))
28
39
  add(f.patternMessage ?? `${name} doesn't match ${f.pattern}.`);
29
40
  }
30
41
  return problems;
@@ -5,4 +5,5 @@ export type { Handler, HandlerRun, Outcome } from "./handlers/index.js";
5
5
  export { runFlow, type RunFlowOptions } from "./run.js";
6
6
  export { FLOW_SUFFIX, flowsDir, listFlows, readFlow, readFlowPath, readFlows, } from "./files.js";
7
7
  export * from "./report.js";
8
+ export { projectSteps, type StepSet } from "../steps/set.js";
8
9
  export { FlowCheckError, checkFlowInProject, chooseEnvironment, runFlowInProject, type RunFlowInProjectOptions, } from "./project.js";
@@ -4,4 +4,5 @@ export { HANDLERS } from "./handlers/index.js";
4
4
  export { runFlow } from "./run.js";
5
5
  export { FLOW_SUFFIX, flowsDir, listFlows, readFlow, readFlowPath, readFlows, } from "./files.js";
6
6
  export * from "./report.js";
7
+ export { projectSteps } from "../steps/set.js";
7
8
  export { FlowCheckError, checkFlowInProject, chooseEnvironment, runFlowInProject, } from "./project.js";
@@ -1,12 +1,12 @@
1
1
  import path from "node:path";
2
- import { loadConfig, setActiveProject } from "../../config/config.js";
2
+ import { projectConfig } from "../../config/config.js";
3
3
  import { checkFlow, withKeys, } from "../core/index.js";
4
- import { DEFAULT_SRC_DIR, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
4
+ import { DEFAULT_SRC_DIR, realPath, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
5
5
  import { withProject } from "../../runner/project.js";
6
6
  import { runTests } from "../../runner/run.js";
7
7
  import TestRunner from "../../runner/test-runner.js";
8
8
  import { readFlow } from "./files.js";
9
- import { STEP_NODES } from "../steps/catalog.js";
9
+ import { projectSteps } from "../steps/set.js";
10
10
  import { HANDLERS } from "./handlers/index.js";
11
11
  import { runFlow } from "./run.js";
12
12
  /** The flow has errors; nothing was run */
@@ -17,18 +17,6 @@ export class FlowCheckError extends Error {
17
17
  this.name = "FlowCheckError";
18
18
  }
19
19
  }
20
- /** Reads the project's config without keeping the project active (`withProject` is one at a time) */
21
- async function configOf(options) {
22
- if (options.config)
23
- return options.config;
24
- setActiveProject({ dir: path.resolve(options.projectDir ?? process.cwd()) });
25
- try {
26
- return await loadConfig();
27
- }
28
- finally {
29
- setActiveProject(undefined);
30
- }
31
- }
32
20
  /** The environment: the option, else the flow's `envDefault` (when the project has it), else the config's default, else the first, else "default" */
33
21
  export function chooseEnvironment(config, flow, option) {
34
22
  const names = Object.keys(config.environments ?? {});
@@ -46,7 +34,8 @@ async function prepare(options) {
46
34
  const flow = withKeys(options.flow ?? (await readFlow(projectDir, options.flowId)));
47
35
  const unsupported = options.unsupported ?? "fail";
48
36
  // Config, environment, tests and groups (one project session, then it ends)
49
- const config = await configOf({ ...options, projectDir });
37
+ const config = await projectConfig({ ...options, projectDir });
38
+ const steps = await projectSteps({ config });
50
39
  const name = chooseEnvironment(config, flow, options.environment);
51
40
  const environment = {
52
41
  name,
@@ -56,7 +45,9 @@ async function prepare(options) {
56
45
  : {}),
57
46
  },
58
47
  };
59
- const srcDir = path.resolve(projectDir, config.srcDir ?? DEFAULT_SRC_DIR);
48
+ // Real paths, like the registered test files
49
+ const realDir = realPath(projectDir);
50
+ const srcDir = realPath(path.resolve(realDir, config.srcDir ?? DEFAULT_SRC_DIR));
60
51
  const { tests, groups } = await withProject({
61
52
  projectDir,
62
53
  config,
@@ -70,22 +61,26 @@ async function prepare(options) {
70
61
  name: t.name,
71
62
  relative: toPosix(path.relative(srcDir, t.file)),
72
63
  }));
73
- const groups = resolveGroups(config, tests.map((t) => ({ file: t.relative, name: t.name })), projectDir, srcDir);
64
+ const groups = resolveGroups(config, tests.map((t) => ({ file: t.relative, name: t.name })), realDir, srcDir);
74
65
  return { tests, groups };
75
66
  });
76
67
  const problems = checkFlow(flow, {
77
68
  tests: tests.map((t) => t.id),
78
69
  groups: groups.map((g) => g.name),
79
70
  environment,
80
- handled: new Set(Object.keys(HANDLERS)),
71
+ handled: new Set([
72
+ ...Object.keys(HANDLERS),
73
+ ...Object.keys(steps.handlers),
74
+ ]),
81
75
  unsupported,
82
- nodes: STEP_NODES,
76
+ nodes: steps.nodes,
83
77
  });
84
78
  return {
85
79
  projectDir,
86
80
  flow,
87
81
  unsupported,
88
82
  config,
83
+ steps,
89
84
  name,
90
85
  environment,
91
86
  tests,
@@ -107,7 +102,7 @@ export async function checkFlowInProject(options) {
107
102
  * its tests through `runTests`, one after the other. One at a time per process.
108
103
  */
109
104
  export async function runFlowInProject(options) {
110
- const { projectDir, flow, unsupported, config, name, environment, tests, groups, problems, } = await prepare(options);
105
+ const { projectDir, flow, unsupported, config, steps, name, environment, tests, groups, problems, } = await prepare(options);
111
106
  const errors = problems.filter((p) => p.level === "error");
112
107
  if (errors.length)
113
108
  throw new FlowCheckError(errors);
@@ -136,6 +131,8 @@ export async function runFlowInProject(options) {
136
131
  ...(options.onEvent && { onEvent: options.onEvent }),
137
132
  ...(options.sleep && { sleep: options.sleep }),
138
133
  unsupported,
134
+ handlers: { ...HANDLERS, ...steps.handlers },
135
+ nodes: steps.nodes,
139
136
  runTests: async ({ tests, onEvent, signal }) => {
140
137
  const summary = await run({
141
138
  projectDir,