@itmetsam/samurai-framework 0.4.0 → 0.5.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/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/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/runner/groups.d.ts +5 -0
- package/dist/runner/groups.js +13 -0
- package/dist/runner/test-runner.js +4 -3
- package/dist/types/config.d.ts +7 -0
- package/package.json +1 -1
|
@@ -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/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,
|
package/dist/flows/steps/api.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { defineStep } from "./define.js";
|
|
2
|
-
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({
|
|
2
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({
|
|
3
|
+
label: m,
|
|
4
|
+
value: m,
|
|
5
|
+
}));
|
|
3
6
|
const isJson = (text) => {
|
|
4
7
|
try {
|
|
5
8
|
JSON.parse(text);
|
|
@@ -16,9 +19,27 @@ export const api = defineStep({
|
|
|
16
19
|
icon: "globe",
|
|
17
20
|
section: "integrations",
|
|
18
21
|
fields: [
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
{
|
|
23
|
+
path: "config.method",
|
|
24
|
+
type: "select",
|
|
25
|
+
label: "Method",
|
|
26
|
+
options: METHODS,
|
|
27
|
+
default: "GET",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
path: "config.url",
|
|
31
|
+
type: "expression",
|
|
32
|
+
label: "URL",
|
|
33
|
+
placeholder: 'env.baseUrl + "/health"',
|
|
34
|
+
default: "",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
path: "config.body",
|
|
38
|
+
type: "code",
|
|
39
|
+
label: "Body",
|
|
40
|
+
placeholder: '{ "name": "test" }',
|
|
41
|
+
default: "",
|
|
42
|
+
},
|
|
22
43
|
],
|
|
23
44
|
summary: "{config.method} {config.url}",
|
|
24
45
|
outputs: [
|
|
@@ -32,7 +53,9 @@ export const api = defineStep({
|
|
|
32
53
|
signal,
|
|
33
54
|
...(body !== undefined && {
|
|
34
55
|
body,
|
|
35
|
-
headers: {
|
|
56
|
+
headers: {
|
|
57
|
+
"content-type": isJson(body) ? "application/json" : "text/plain",
|
|
58
|
+
},
|
|
36
59
|
}),
|
|
37
60
|
});
|
|
38
61
|
return { outputs: { statusCode: res.status, body: await res.text() } };
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import type { AnyNodeDefinition, FlowFile, FlowProblem, StepInfo } from "../core/index.js";
|
|
2
2
|
import type { Handler } from "../run/handlers/support.js";
|
|
3
3
|
import { type StepDefinition } from "./define.js";
|
|
4
|
+
import type { SamuraiTestConfig } from "../../types/config.js";
|
|
4
5
|
/** The built-in steps, in library order */
|
|
5
6
|
export declare const STEPS: readonly StepDefinition<any>[];
|
|
6
7
|
export declare const STEP_NODES: readonly AnyNodeDefinition[];
|
|
7
8
|
export declare const STEP_HANDLERS: Readonly<Record<string, Handler>>;
|
|
8
9
|
/** The steps as data, for the app */
|
|
9
10
|
export declare const stepCatalog: (steps?: readonly StepDefinition<any>[]) => StepInfo[];
|
|
10
|
-
/** The project's steps
|
|
11
|
-
export declare function listSteps(
|
|
12
|
-
|
|
11
|
+
/** The project's steps as data: the built-ins, then its config's. `projectDir` defaults to the working directory. */
|
|
12
|
+
export declare function listSteps(options?: {
|
|
13
|
+
projectDir?: string;
|
|
14
|
+
config?: SamuraiTestConfig;
|
|
15
|
+
}): Promise<StepInfo[]>;
|
|
16
|
+
/** Only the steps' own `validate` problems (the project's steps included): the app checks everything else itself */
|
|
13
17
|
export declare function validateSteps(flow: FlowFile, options: {
|
|
14
18
|
tests?: string[];
|
|
15
19
|
groups?: string[];
|
|
16
|
-
|
|
20
|
+
projectDir?: string;
|
|
21
|
+
config?: SamuraiTestConfig;
|
|
22
|
+
}): Promise<FlowProblem[]>;
|
|
@@ -1,29 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { stepHandler } from "./handler.js";
|
|
4
|
-
import { setVariable } from "./set-variable.js";
|
|
5
|
-
import { wait } from "./wait.js";
|
|
1
|
+
import { infoOf } from "./define.js";
|
|
2
|
+
import { BUILTIN_SET, BUILTIN_STEPS, projectSteps } from "./set.js";
|
|
6
3
|
/** The built-in steps, in library order */
|
|
7
4
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
export const STEPS =
|
|
9
|
-
export const STEP_NODES =
|
|
10
|
-
export const STEP_HANDLERS =
|
|
5
|
+
export const STEPS = BUILTIN_STEPS;
|
|
6
|
+
export const STEP_NODES = BUILTIN_SET.nodes;
|
|
7
|
+
export const STEP_HANDLERS = BUILTIN_SET.handlers;
|
|
11
8
|
/** The steps as data, for the app */
|
|
12
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
10
|
export const stepCatalog = (steps = STEPS) => steps.map(infoOf);
|
|
14
|
-
/** The project's steps
|
|
15
|
-
export async function listSteps() {
|
|
16
|
-
return
|
|
11
|
+
/** The project's steps as data: the built-ins, then its config's. `projectDir` defaults to the working directory. */
|
|
12
|
+
export async function listSteps(options = {}) {
|
|
13
|
+
return [...(await projectSteps(options)).catalog];
|
|
17
14
|
}
|
|
18
|
-
/** Only the steps' own `validate` problems: the app checks everything else itself */
|
|
19
|
-
export function validateSteps(flow, options) {
|
|
15
|
+
/** Only the steps' own `validate` problems (the project's steps included): the app checks everything else itself */
|
|
16
|
+
export async function validateSteps(flow, options) {
|
|
17
|
+
const set = await projectSteps(options);
|
|
20
18
|
const tests = options.tests ? new Set(options.tests) : undefined;
|
|
21
19
|
const groups = options.groups ? new Set(options.groups) : undefined;
|
|
22
20
|
return flow.nodes.flatMap((n) => {
|
|
23
|
-
const step =
|
|
21
|
+
const step = set.steps.find((s) => s.kind === n.kind);
|
|
24
22
|
if (!step?.validate)
|
|
25
23
|
return [];
|
|
26
|
-
const def =
|
|
27
|
-
return step.validate({
|
|
24
|
+
const def = set.nodes.find((d) => d.kind === n.kind);
|
|
25
|
+
return step.validate({
|
|
26
|
+
...n,
|
|
27
|
+
config: {
|
|
28
|
+
...Object.fromEntries(step.fields.map((f) => [
|
|
29
|
+
f.path.slice(7),
|
|
30
|
+
"default" in f ? f.default : undefined,
|
|
31
|
+
])),
|
|
32
|
+
...n.config,
|
|
33
|
+
},
|
|
34
|
+
}, { label: def.label, tests, groups });
|
|
28
35
|
});
|
|
29
36
|
}
|
|
@@ -38,6 +38,8 @@ export interface StepDefinition<C = Record<string, unknown>> extends StepInfo {
|
|
|
38
38
|
/** What the step does in a run: return to pass, throw to fail */
|
|
39
39
|
run: (input: StepRunInput<C>) => Promise<StepResult | void>;
|
|
40
40
|
}
|
|
41
|
+
/** The checks every step passes: in `defineStep`, and again when a project lists it. Throws for a step that can't work. */
|
|
42
|
+
export declare function checkStep(def: StepDefinition<any>, where?: string): void;
|
|
41
43
|
/** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
|
|
42
44
|
export declare function defineStep<const F extends readonly StepField[]>(def: Omit<StepDefinition<RunConfigOf<F>>, "fields"> & {
|
|
43
45
|
fields: F;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { CLOSED_KINDS, RESERVED_OUTPUTS, STEP_KIND, stepNodeOf, } from "../core/index.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/** The checks every step passes: in `defineStep`, and again when a project lists it. Throws for a step that can't work. */
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
export function checkStep(def, where = "") {
|
|
5
|
+
const name = `Step "${def.kind}"${where}`;
|
|
5
6
|
if (CLOSED_KINDS.includes(def.kind))
|
|
6
7
|
throw new Error(`${name}: "${def.kind}" is a closed kind; steps can't replace it.`);
|
|
7
8
|
if (!STEP_KIND.test(def.kind))
|
|
8
9
|
throw new Error(`${name}: "${def.kind}" isn't a valid step kind. Use lowercase letters, digits and -.`);
|
|
10
|
+
if (!Array.isArray(def.fields))
|
|
11
|
+
throw new Error(`${name}: fields must be a list.`);
|
|
9
12
|
for (const f of def.fields) {
|
|
10
13
|
if (!f.path.startsWith("config."))
|
|
11
14
|
throw new Error(`${name}: field "${f.path}" must be a config. path.`);
|
|
@@ -24,8 +27,16 @@ export function defineStep(def) {
|
|
|
24
27
|
if (outputs.indexOf(o) !== outputs.lastIndexOf(o))
|
|
25
28
|
throw new Error(`${name}: output "${o}" is declared twice.`);
|
|
26
29
|
}
|
|
27
|
-
if (def.writesVar !== undefined &&
|
|
30
|
+
if (def.writesVar !== undefined &&
|
|
31
|
+
!def.fields.some((f) => f.path === def.writesVar))
|
|
28
32
|
throw new Error(`${name}: writesVar "${def.writesVar}" isn't one of its fields.`);
|
|
33
|
+
if (typeof def.run !== "function")
|
|
34
|
+
throw new Error(`${name}: run must be a function.`);
|
|
35
|
+
}
|
|
36
|
+
/** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
|
|
37
|
+
export function defineStep(def) {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
checkStep(def);
|
|
29
40
|
return def;
|
|
30
41
|
}
|
|
31
42
|
/** The step as data, without its functions: what the catalog holds */
|
|
@@ -27,7 +27,9 @@ export function stepHandler(step) {
|
|
|
27
27
|
const stopped = new Promise((resolve) => {
|
|
28
28
|
if (run.signal.aborted)
|
|
29
29
|
resolve("stopped");
|
|
30
|
-
run.signal.addEventListener("abort", () => resolve("stopped"), {
|
|
30
|
+
run.signal.addEventListener("abort", () => resolve("stopped"), {
|
|
31
|
+
once: true,
|
|
32
|
+
});
|
|
31
33
|
});
|
|
32
34
|
let result;
|
|
33
35
|
try {
|
|
@@ -36,7 +38,11 @@ export function stepHandler(step) {
|
|
|
36
38
|
config,
|
|
37
39
|
env: { ...run.context.env },
|
|
38
40
|
vars: { ...run.context.vars },
|
|
39
|
-
node: {
|
|
41
|
+
node: {
|
|
42
|
+
id: node.id,
|
|
43
|
+
...(node.key && { key: node.key }),
|
|
44
|
+
...(node.title && { title: node.title }),
|
|
45
|
+
},
|
|
40
46
|
signal: run.signal,
|
|
41
47
|
sleep: (ms) => run.sleep(ms, run.signal),
|
|
42
48
|
log: (level, text) => run.log(node.id, level, text),
|
|
@@ -54,11 +60,17 @@ export function stepHandler(step) {
|
|
|
54
60
|
const declared = new Set((step.outputs ?? []).map((o) => o.name));
|
|
55
61
|
const extra = keysOf(result?.outputs).filter((k) => !declared.has(k));
|
|
56
62
|
if (extra.length)
|
|
57
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
status: "failed",
|
|
65
|
+
error: `${step.label} returned outputs it doesn't declare: ${extra.join(", ")}.`,
|
|
66
|
+
};
|
|
58
67
|
const allowed = step.writesVar ? getPath(typed, step.writesVar) : undefined;
|
|
59
68
|
const wrongVars = keysOf(result?.vars).filter((k) => k !== allowed);
|
|
60
69
|
if (wrongVars.length)
|
|
61
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
status: "failed",
|
|
72
|
+
error: `${step.label} wrote variables it doesn't declare: ${wrongVars.join(", ")}.`,
|
|
73
|
+
};
|
|
62
74
|
Object.assign(run.context.vars, result?.vars);
|
|
63
75
|
return {
|
|
64
76
|
status: "passed",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Flow steps: define your own, and the built-in ones. Node only. */
|
|
2
|
-
export { defineStep, stepNode, type RunConfigOf, type StepDefinition, type StepResult, type StepRunInput, } from "./define.js";
|
|
2
|
+
export { checkStep, defineStep, stepNode, type RunConfigOf, type StepDefinition, type StepResult, type StepRunInput, } from "./define.js";
|
|
3
3
|
export { stepHandler } from "./handler.js";
|
|
4
|
+
export { BUILTIN_SET, BUILTIN_STEPS, projectSteps, stepSet, type StepSet, } from "./set.js";
|
|
4
5
|
export { STEPS, STEP_HANDLERS, STEP_NODES, listSteps, stepCatalog, validateSteps, } from "./catalog.js";
|
|
5
|
-
export type { StepField, StepInfo, StepOutput, StepSection } from "../core/index.js";
|
|
6
|
+
export type { StepField, StepInfo, StepOutput, StepSection, } from "../core/index.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Flow steps: define your own, and the built-in ones. Node only. */
|
|
2
|
-
export { defineStep, stepNode, } from "./define.js";
|
|
2
|
+
export { checkStep, defineStep, stepNode, } from "./define.js";
|
|
3
3
|
export { stepHandler } from "./handler.js";
|
|
4
|
+
export { BUILTIN_SET, BUILTIN_STEPS, projectSteps, stepSet, } from "./set.js";
|
|
4
5
|
export { STEPS, STEP_HANDLERS, STEP_NODES, listSteps, stepCatalog, validateSteps, } from "./catalog.js";
|
|
@@ -6,11 +6,22 @@ export const setVariable = defineStep({
|
|
|
6
6
|
section: "control",
|
|
7
7
|
fields: [
|
|
8
8
|
{
|
|
9
|
-
path: "config.name",
|
|
10
|
-
|
|
9
|
+
path: "config.name",
|
|
10
|
+
type: "text",
|
|
11
|
+
label: "Name",
|
|
12
|
+
placeholder: "orderId",
|
|
13
|
+
default: "",
|
|
14
|
+
required: true,
|
|
15
|
+
pattern: "^[a-z][a-z0-9_]*$",
|
|
11
16
|
patternMessage: "use lowercase letters, digits and _ for the name.",
|
|
12
17
|
},
|
|
13
|
-
{
|
|
18
|
+
{
|
|
19
|
+
path: "config.value",
|
|
20
|
+
type: "expression",
|
|
21
|
+
label: "Value",
|
|
22
|
+
placeholder: '"order-" + run.environment',
|
|
23
|
+
default: "",
|
|
24
|
+
},
|
|
14
25
|
],
|
|
15
26
|
summary: "vars.{config.name} = {config.value}",
|
|
16
27
|
writesVar: "config.name",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SamuraiTestConfig } from "../../types/config.js";
|
|
2
|
+
import type { AnyNodeDefinition, StepInfo } from "../core/index.js";
|
|
3
|
+
import type { Handler } from "../run/handlers/support.js";
|
|
4
|
+
import { type StepDefinition } from "./define.js";
|
|
5
|
+
type AnyStep = StepDefinition<any>;
|
|
6
|
+
/** The built-in steps, in library order */
|
|
7
|
+
export declare const BUILTIN_STEPS: readonly AnyStep[];
|
|
8
|
+
/** Everything a project's steps give the walker, the check and the app */
|
|
9
|
+
export interface StepSet {
|
|
10
|
+
/** The built-ins, then the project's in config order */
|
|
11
|
+
steps: readonly AnyStep[];
|
|
12
|
+
nodes: readonly AnyNodeDefinition[];
|
|
13
|
+
handlers: Readonly<Record<string, Handler>>;
|
|
14
|
+
/** The steps as data, for the app */
|
|
15
|
+
catalog: StepInfo[];
|
|
16
|
+
}
|
|
17
|
+
/** The built-in steps plus a project's (its config's `steps`). Throws for a list that can't work. */
|
|
18
|
+
export declare function stepSet(projectSteps?: unknown): StepSet;
|
|
19
|
+
/** The built-in steps alone */
|
|
20
|
+
export declare const BUILTIN_SET: StepSet;
|
|
21
|
+
/** The project's steps: its config's `steps` after the built-ins. `projectDir` defaults to the working directory. */
|
|
22
|
+
export declare function projectSteps(options?: {
|
|
23
|
+
projectDir?: string;
|
|
24
|
+
config?: SamuraiTestConfig;
|
|
25
|
+
}): Promise<StepSet>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { projectConfig } from "../../config/config.js";
|
|
2
|
+
import { api } from "./api.js";
|
|
3
|
+
import { checkStep, infoOf, stepNode } from "./define.js";
|
|
4
|
+
import { stepHandler } from "./handler.js";
|
|
5
|
+
import { setVariable } from "./set-variable.js";
|
|
6
|
+
import { wait } from "./wait.js";
|
|
7
|
+
/** The built-in steps, in library order */
|
|
8
|
+
export const BUILTIN_STEPS = [wait, setVariable, api];
|
|
9
|
+
const WHERE = " (samurai.config.ts)";
|
|
10
|
+
/** The built-in steps plus a project's (its config's `steps`). Throws for a list that can't work. */
|
|
11
|
+
export function stepSet(projectSteps = []) {
|
|
12
|
+
if (!Array.isArray(projectSteps))
|
|
13
|
+
throw new Error("samurai.config.ts: steps must be a list of steps.");
|
|
14
|
+
const builtin = new Set(BUILTIN_STEPS.map((s) => s.kind));
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
projectSteps.forEach((entry, i) => {
|
|
17
|
+
const kind = entry && typeof entry === "object"
|
|
18
|
+
? entry.kind
|
|
19
|
+
: undefined;
|
|
20
|
+
if (typeof kind !== "string" || kind === "")
|
|
21
|
+
throw new Error(`Step #${i + 1}${WHERE}: it has no kind; make it with defineStep.`);
|
|
22
|
+
checkStep(entry, WHERE);
|
|
23
|
+
if (builtin.has(kind))
|
|
24
|
+
throw new Error(`Step "${kind}"${WHERE}: a built-in step already uses this kind.`);
|
|
25
|
+
if (seen.has(kind))
|
|
26
|
+
throw new Error(`Step "${kind}"${WHERE}: declared twice.`);
|
|
27
|
+
seen.add(kind);
|
|
28
|
+
});
|
|
29
|
+
const steps = [...BUILTIN_STEPS, ...projectSteps];
|
|
30
|
+
return {
|
|
31
|
+
steps,
|
|
32
|
+
nodes: steps.map(stepNode),
|
|
33
|
+
handlers: Object.fromEntries(steps.map((s) => [s.kind, stepHandler(s)])),
|
|
34
|
+
catalog: steps.map(infoOf),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** The built-in steps alone */
|
|
38
|
+
export const BUILTIN_SET = stepSet();
|
|
39
|
+
/** The project's steps: its config's `steps` after the built-ins. `projectDir` defaults to the working directory. */
|
|
40
|
+
export async function projectSteps(options = {}) {
|
|
41
|
+
const config = await projectConfig(options);
|
|
42
|
+
return config.steps === undefined ? BUILTIN_SET : stepSet(config.steps);
|
|
43
|
+
}
|
package/dist/flows/steps/wait.js
CHANGED
|
@@ -5,7 +5,14 @@ export const wait = defineStep({
|
|
|
5
5
|
icon: "timer",
|
|
6
6
|
section: "control",
|
|
7
7
|
fields: [
|
|
8
|
-
{
|
|
8
|
+
{
|
|
9
|
+
path: "config.ms",
|
|
10
|
+
type: "number",
|
|
11
|
+
label: "Duration",
|
|
12
|
+
unit: "ms",
|
|
13
|
+
default: 1000,
|
|
14
|
+
min: 0,
|
|
15
|
+
},
|
|
9
16
|
],
|
|
10
17
|
summary: "{config.ms} ms",
|
|
11
18
|
async run({ config, sleep }) {
|
package/dist/runner/groups.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface ResolvedGroup {
|
|
|
23
23
|
title: string;
|
|
24
24
|
}[];
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
|
|
28
|
+
* from stack traces, which resolve symlinks, so paths compared with them must be real too
|
|
29
|
+
*/
|
|
30
|
+
export declare function realPath(p: string): string;
|
|
26
31
|
/** Separators of any platform become `/` */
|
|
27
32
|
export declare const toPosix: (p: string) => string;
|
|
28
33
|
/** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
|
package/dist/runner/groups.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import { matchesGlob, relative, resolve } from "node:path";
|
|
2
3
|
/** The test folder when the config names none */
|
|
3
4
|
export const DEFAULT_SRC_DIR = "./src";
|
|
4
5
|
/** The spec pattern when a group names none */
|
|
5
6
|
export const DEFAULT_TEST_MATCH = "**/*.spec.ts";
|
|
7
|
+
/**
|
|
8
|
+
* The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
|
|
9
|
+
* from stack traces, which resolve symlinks, so paths compared with them must be real too
|
|
10
|
+
*/
|
|
11
|
+
export function realPath(p) {
|
|
12
|
+
try {
|
|
13
|
+
return realpathSync(p);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
6
19
|
/** Separators of any platform become `/` */
|
|
7
20
|
export const toPosix = (p) => p.replace(/\\/g, "/");
|
|
8
21
|
/** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
|
|
@@ -14,7 +14,7 @@ import Page from "../browser/page.js";
|
|
|
14
14
|
import { takePendingAssertions } from "../assert/expect.js";
|
|
15
15
|
import { toTestError } from "./test-error.js";
|
|
16
16
|
import { ConnectionClosedError } from "../transport/bidi-connection.js";
|
|
17
|
-
import { DEFAULT_SRC_DIR, findGroup, resolveGroups, testId, toPosix, } from "./groups.js";
|
|
17
|
+
import { DEFAULT_SRC_DIR, findGroup, realPath, resolveGroups, testId, toPosix, } from "./groups.js";
|
|
18
18
|
import { judgePageLogs } from "./page-logs-report.js";
|
|
19
19
|
/** Whether a test with this full name is selected by the options */
|
|
20
20
|
export function isSelected(name, { grep, testNames }) {
|
|
@@ -81,8 +81,9 @@ export default class TestRunner {
|
|
|
81
81
|
/** The config's groups, resolved against the tests the specs register */
|
|
82
82
|
async resolveAll(tests) {
|
|
83
83
|
const config = await loadConfig();
|
|
84
|
-
|
|
85
|
-
const
|
|
84
|
+
// Real paths: registered test files come from stack traces, which resolve symlinks
|
|
85
|
+
const dir = realPath(projectDir());
|
|
86
|
+
const srcDir = realPath(path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR));
|
|
86
87
|
const groups = resolveGroups(config, tests.map(({ file, name }) => ({
|
|
87
88
|
file: toPosix(path.relative(srcDir, file)),
|
|
88
89
|
name,
|
package/dist/types/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StepDefinition } from "../flows/steps/define.js";
|
|
1
2
|
import type { SupportedBrowser } from "./browser.js";
|
|
2
3
|
|
|
3
4
|
interface SamuraiTest {
|
|
@@ -57,6 +58,12 @@ export interface SamuraiTestConfig {
|
|
|
57
58
|
* Define a group of test's you want to have executed with a certain configuration
|
|
58
59
|
**/
|
|
59
60
|
groups?: SamuraiGroup[];
|
|
61
|
+
/**
|
|
62
|
+
* Flow steps of this project, made with `defineStep`. Listed after the built-in steps;
|
|
63
|
+
* a kind a built-in or another step uses is refused.
|
|
64
|
+
*/
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
steps?: readonly StepDefinition<any>[];
|
|
60
67
|
/**
|
|
61
68
|
* The base URL to use when navigating the browser.
|
|
62
69
|
* When recording starts, the browser will start at this URL.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itmetsam/samurai-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
|
|
5
5
|
"main": "dist/api.js",
|
|
6
6
|
"exports": {
|