@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.
@@ -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;
@@ -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,
@@ -1,5 +1,8 @@
1
1
  import { defineStep } from "./define.js";
2
- const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({ label: m, value: 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
- { path: "config.method", type: "select", label: "Method", options: METHODS, default: "GET" },
20
- { path: "config.url", type: "expression", label: "URL", placeholder: 'env.baseUrl + "/health"', default: "" },
21
- { path: "config.body", type: "code", label: "Body", placeholder: '{ "name": "test" }', default: "" },
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: { "content-type": isJson(body) ? "application/json" : "text/plain" },
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; the built-in ones for now (test-project steps come later) */
11
- export declare function listSteps(): Promise<StepInfo[]>;
12
- /** Only the steps' own `validate` problems: the app checks everything else itself */
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
- }): FlowProblem[];
20
+ projectDir?: string;
21
+ config?: SamuraiTestConfig;
22
+ }): Promise<FlowProblem[]>;
@@ -1,29 +1,36 @@
1
- import { api } from "./api.js";
2
- import { infoOf, stepNode } from "./define.js";
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 = [wait, setVariable, api];
9
- export const STEP_NODES = STEPS.map(stepNode);
10
- export const STEP_HANDLERS = Object.fromEntries(STEPS.map((s) => [s.kind, stepHandler(s)]));
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; the built-in ones for now (test-project steps come later) */
15
- export async function listSteps() {
16
- return stepCatalog();
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 = STEPS.find((s) => s.kind === n.kind);
21
+ const step = set.steps.find((s) => s.kind === n.kind);
24
22
  if (!step?.validate)
25
23
  return [];
26
- const def = STEP_NODES.find((d) => d.kind === n.kind);
27
- return step.validate({ ...n, config: { ...Object.fromEntries(step.fields.map((f) => [f.path.slice(7), "default" in f ? f.default : undefined])), ...n.config } }, { label: def.label, tests, groups });
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
- /** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
3
- export function defineStep(def) {
4
- const name = `Step "${def.kind}"`;
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 && !def.fields.some((f) => f.path === def.writesVar))
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"), { once: true });
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: { id: node.id, ...(node.key && { key: node.key }), ...(node.title && { title: node.title }) },
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 { status: "failed", error: `${step.label} returned outputs it doesn't declare: ${extra.join(", ")}.` };
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 { status: "failed", error: `${step.label} wrote variables it doesn't declare: ${wrongVars.join(", ")}.` };
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", type: "text", label: "Name", placeholder: "orderId", default: "",
10
- required: true, pattern: "^[a-z][a-z0-9_]*$",
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
- { path: "config.value", type: "expression", label: "Value", placeholder: '"order-" + run.environment', default: "" },
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
+ }
@@ -5,7 +5,14 @@ export const wait = defineStep({
5
5
  icon: "timer",
6
6
  section: "control",
7
7
  fields: [
8
- { path: "config.ms", type: "number", label: "Duration", unit: "ms", default: 1000, min: 0 },
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 }) {
@@ -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 */
@@ -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
- const dir = projectDir();
85
- const srcDir = path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR);
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,
@@ -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.4.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": {