@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.
- 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/flow-reporter.d.ts +5 -2
- package/dist/cli/flow-reporter.js +3 -2
- package/dist/cli/flow.d.ts +1 -1
- package/dist/cli/flow.js +4 -2
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +11 -1
- package/dist/config/config.d.ts +5 -0
- package/dist/config/config.js +13 -0
- package/dist/flows/core/steps.js +14 -3
- package/dist/flows/run/index.d.ts +1 -0
- package/dist/flows/run/index.js +1 -0
- package/dist/flows/run/project.js +18 -21
- package/dist/flows/steps/api.js +28 -5
- package/dist/flows/steps/catalog.d.ts +10 -4
- package/dist/flows/steps/catalog.js +23 -16
- package/dist/flows/steps/define.d.ts +2 -0
- package/dist/flows/steps/define.js +15 -4
- package/dist/flows/steps/handler.js +16 -4
- package/dist/flows/steps/index.d.ts +3 -2
- package/dist/flows/steps/index.js +2 -1
- package/dist/flows/steps/set-variable.js +14 -3
- package/dist/flows/steps/set.d.ts +26 -0
- package/dist/flows/steps/set.js +43 -0
- package/dist/flows/steps/wait.js +8 -1
- package/dist/recorder/session.js +4 -0
- package/dist/runner/groups.d.ts +5 -0
- package/dist/runner/groups.js +13 -0
- package/dist/runner/test-runner.js +9 -3
- package/dist/transport/bidi-connection.d.ts +2 -0
- package/dist/transport/bidi-connection.js +1 -1
- package/dist/types/config.d.ts +12 -0
- 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
|
+
}
|
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
|
+
}
|
|
@@ -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 =
|
|
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) : [];
|
package/dist/cli/flow.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
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/config/config.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/config/config.js
CHANGED
|
@@ -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 });
|
package/dist/flows/core/steps.js
CHANGED
|
@@ -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 = [
|
|
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({
|
|
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 &&
|
|
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";
|
package/dist/flows/run/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 })),
|
|
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(
|
|
71
|
+
handled: new Set([
|
|
72
|
+
...Object.keys(HANDLERS),
|
|
73
|
+
...Object.keys(steps.handlers),
|
|
74
|
+
]),
|
|
81
75
|
unsupported,
|
|
82
|
-
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,
|