@itmetsam/samurai-framework 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +5 -0
  2. package/bin/samurai.mjs +7 -5
  3. package/dist/cli/args.d.ts +5 -1
  4. package/dist/cli/args.js +6 -1
  5. package/dist/cli/flow-reporter.d.ts +23 -0
  6. package/dist/cli/flow-reporter.js +97 -0
  7. package/dist/cli/flow.d.ts +4 -0
  8. package/dist/cli/flow.js +240 -0
  9. package/dist/cli/main.d.ts +1 -1
  10. package/dist/cli/main.js +14 -0
  11. package/dist/flows/core/check.d.ts +42 -0
  12. package/dist/flows/core/check.js +202 -0
  13. package/dist/flows/core/expression/check.d.ts +21 -0
  14. package/dist/flows/core/expression/check.js +95 -0
  15. package/dist/flows/core/expression/evaluate.d.ts +22 -0
  16. package/dist/flows/core/expression/evaluate.js +141 -0
  17. package/dist/flows/core/expression/help.d.ts +25 -0
  18. package/dist/flows/core/expression/help.js +75 -0
  19. package/dist/flows/core/expression/index.d.ts +5 -0
  20. package/dist/flows/core/expression/index.js +5 -0
  21. package/dist/flows/core/expression/parse.d.ts +61 -0
  22. package/dist/flows/core/expression/parse.js +317 -0
  23. package/dist/flows/core/expression/scope.d.ts +7 -0
  24. package/dist/flows/core/expression/scope.js +25 -0
  25. package/dist/flows/core/graph.d.ts +11 -0
  26. package/dist/flows/core/graph.js +65 -0
  27. package/dist/flows/core/index.d.ts +16 -0
  28. package/dist/flows/core/index.js +16 -0
  29. package/dist/flows/core/keys.d.ts +8 -0
  30. package/dist/flows/core/keys.js +46 -0
  31. package/dist/flows/core/nodes/api.d.ts +9 -0
  32. package/dist/flows/core/nodes/api.js +12 -0
  33. package/dist/flows/core/nodes/condition.d.ts +7 -0
  34. package/dist/flows/core/nodes/condition.js +21 -0
  35. package/dist/flows/core/nodes/database.d.ts +9 -0
  36. package/dist/flows/core/nodes/database.js +12 -0
  37. package/dist/flows/core/nodes/define.d.ts +128 -0
  38. package/dist/flows/core/nodes/define.js +21 -0
  39. package/dist/flows/core/nodes/email.d.ts +9 -0
  40. package/dist/flows/core/nodes/email.js +12 -0
  41. package/dist/flows/core/nodes/end.d.ts +1 -0
  42. package/dist/flows/core/nodes/end.js +7 -0
  43. package/dist/flows/core/nodes/group.d.ts +5 -0
  44. package/dist/flows/core/nodes/group.js +18 -0
  45. package/dist/flows/core/nodes/index.d.ts +107 -0
  46. package/dist/flows/core/nodes/index.js +81 -0
  47. package/dist/flows/core/nodes/parallel.d.ts +1 -0
  48. package/dist/flows/core/nodes/parallel.js +11 -0
  49. package/dist/flows/core/nodes/path.d.ts +5 -0
  50. package/dist/flows/core/nodes/path.js +21 -0
  51. package/dist/flows/core/nodes/script.d.ts +9 -0
  52. package/dist/flows/core/nodes/script.js +12 -0
  53. package/dist/flows/core/nodes/set-variable.d.ts +13 -0
  54. package/dist/flows/core/nodes/set-variable.js +39 -0
  55. package/dist/flows/core/nodes/start.d.ts +1 -0
  56. package/dist/flows/core/nodes/start.js +7 -0
  57. package/dist/flows/core/nodes/test.d.ts +1 -0
  58. package/dist/flows/core/nodes/test.js +18 -0
  59. package/dist/flows/core/nodes/wait.d.ts +15 -0
  60. package/dist/flows/core/nodes/wait.js +18 -0
  61. package/dist/flows/core/schema.d.ts +55 -0
  62. package/dist/flows/core/schema.js +33 -0
  63. package/dist/flows/run/events.d.ts +43 -0
  64. package/dist/flows/run/events.js +1 -0
  65. package/dist/flows/run/files.d.ts +12 -0
  66. package/dist/flows/run/files.js +54 -0
  67. package/dist/flows/run/handlers/condition.d.ts +2 -0
  68. package/dist/flows/run/handlers/condition.js +17 -0
  69. package/dist/flows/run/handlers/end.d.ts +2 -0
  70. package/dist/flows/run/handlers/end.js +1 -0
  71. package/dist/flows/run/handlers/group.d.ts +2 -0
  72. package/dist/flows/run/handlers/group.js +7 -0
  73. package/dist/flows/run/handlers/index.d.ts +6 -0
  74. package/dist/flows/run/handlers/index.js +22 -0
  75. package/dist/flows/run/handlers/parallel.d.ts +3 -0
  76. package/dist/flows/run/handlers/parallel.js +2 -0
  77. package/dist/flows/run/handlers/set-variable.d.ts +2 -0
  78. package/dist/flows/run/handlers/set-variable.js +22 -0
  79. package/dist/flows/run/handlers/start.d.ts +2 -0
  80. package/dist/flows/run/handlers/start.js +1 -0
  81. package/dist/flows/run/handlers/support.d.ts +26 -0
  82. package/dist/flows/run/handlers/support.js +22 -0
  83. package/dist/flows/run/handlers/test.d.ts +2 -0
  84. package/dist/flows/run/handlers/test.js +7 -0
  85. package/dist/flows/run/handlers/wait.d.ts +2 -0
  86. package/dist/flows/run/handlers/wait.js +10 -0
  87. package/dist/flows/run/index.d.ts +8 -0
  88. package/dist/flows/run/index.js +7 -0
  89. package/dist/flows/run/project.d.ts +43 -0
  90. package/dist/flows/run/project.js +163 -0
  91. package/dist/flows/run/report.d.ts +39 -0
  92. package/dist/flows/run/report.js +46 -0
  93. package/dist/flows/run/run.d.ts +42 -0
  94. package/dist/flows/run/run.js +244 -0
  95. package/dist/locator/locator.d.ts +8 -0
  96. package/dist/locator/locator.js +46 -0
  97. package/dist/recorder/capture.d.ts +2 -0
  98. package/dist/recorder/capture.js +14 -0
  99. package/dist/recorder/normalise.d.ts +5 -0
  100. package/dist/recorder/normalise.js +12 -0
  101. package/dist/recorder/recorder.js +20 -18
  102. package/dist/runner/groups.d.ts +39 -0
  103. package/dist/runner/groups.js +58 -0
  104. package/dist/runner/run.d.ts +4 -0
  105. package/dist/runner/run.js +12 -0
  106. package/dist/runner/test-runner.d.ts +12 -2
  107. package/dist/runner/test-runner.js +36 -6
  108. package/dist/steps/emit.js +2 -0
  109. package/dist/steps/model.d.ts +6 -0
  110. package/dist/steps/parse.js +4 -0
  111. package/package.json +9 -1
@@ -0,0 +1,46 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { maskDeep, maskText } from "../../config/mask.js";
4
+ /** Follows a run's events and builds its report once it ends (or `finish` is given what is known) */
5
+ export class FlowReportBuilder {
6
+ constructor(flow) {
7
+ this.flow = flow;
8
+ this.environment = "";
9
+ this.tests = [];
10
+ this.started = Date.now();
11
+ this.startedAt = new Date(this.started).toISOString();
12
+ }
13
+ handle(event) {
14
+ if (event.type === "flow-start")
15
+ this.environment = event.environment;
16
+ if (event.type === "test")
17
+ this.collect(event.nodeId, event.event);
18
+ }
19
+ collect(nodeId, event) {
20
+ if (event.type === "test-end")
21
+ this.tests.push({ nodeId, ...event.result });
22
+ }
23
+ finish(summary, environment = this.environment) {
24
+ return {
25
+ flow: this.flow.id,
26
+ name: this.flow.name,
27
+ environment,
28
+ status: summary.status,
29
+ startedAt: this.startedAt,
30
+ durationMs: Date.now() - this.started,
31
+ nodes: summary.nodes,
32
+ vars: summary.vars,
33
+ tests: this.tests,
34
+ ...(summary.error ? { error: summary.error } : {}),
35
+ };
36
+ }
37
+ }
38
+ /** Writes `<projectDir>/result/flows/<id>.json` with the registered secrets masked, as `report.json` is. Returns the path */
39
+ export async function writeFlowReport(projectDir, report) {
40
+ const file = path.join(path.resolve(projectDir), "result", "flows", `${report.flow}.json`);
41
+ await mkdir(path.dirname(file), { recursive: true });
42
+ await writeFile(file, JSON.stringify(maskDeep(report), null, 2), "utf8");
43
+ return file;
44
+ }
45
+ /** Hides the registered secrets in a line of output (a node's error) */
46
+ export const maskSecrets = (text) => maskText(text);
@@ -0,0 +1,42 @@
1
+ import { type AnyNodeDefinition, type FlowFile } from "../core/index.js";
2
+ import type { RunEvent } from "../../runner/reporter.js";
3
+ import type { FlowEvent, FlowSummary, NodeTest } from "./events.js";
4
+ import { type Handler } from "./handlers/index.js";
5
+ export interface RunFlowOptions {
6
+ flow: FlowFile;
7
+ /** The tests of each Test and Group node, by node id */
8
+ nodeTests: Record<string, NodeTest[]>;
9
+ environment: {
10
+ name: string;
11
+ variables: Record<string, string | number | boolean>;
12
+ };
13
+ /** @default "manual" */
14
+ trigger?: "manual";
15
+ /** @default now */
16
+ startedAt?: string;
17
+ signal?: AbortSignal;
18
+ onEvent?: (event: FlowEvent) => void;
19
+ runTests: (input: {
20
+ tests: NodeTest[];
21
+ onEvent: (e: RunEvent) => void;
22
+ signal: AbortSignal;
23
+ }) => Promise<"passed" | "failed" | "cancelled">;
24
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
25
+ /**
26
+ * What a node of a kind with no handler does:
27
+ * - `"fail"` (default): the run does not start (an error is thrown)
28
+ * - `"skip"`: the node is skipped with the note "Not supported yet"
29
+ */
30
+ unsupported?: "fail" | "skip";
31
+ /** The handlers by kind. @default HANDLERS */
32
+ handlers?: Readonly<Record<string, Handler>>;
33
+ /** Definitions of kinds beyond the ones `flows/core` registers (a handler needs one to be walked) */
34
+ nodes?: readonly AnyNodeDefinition[];
35
+ }
36
+ /**
37
+ * Walks the flow from Start by the shape of each node's kind: a step runs and
38
+ * follows its one way out; a node with a fixed list of branches follows the one its
39
+ * handler names and skips the others; a node with a dynamic list walks every branch in
40
+ * fill order; an end stops. Branches meet again at `meetOf`.
41
+ */
42
+ export declare function runFlow(options: RunFlowOptions): Promise<FlowSummary>;
@@ -0,0 +1,244 @@
1
+ import { inFillOrder, meetOf, nodeDef, } from "../core/index.js";
2
+ import { HANDLERS } from "./handlers/index.js";
3
+ const defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
4
+ if (signal.aborted)
5
+ return reject(new Error("aborted"));
6
+ const onAbort = () => {
7
+ clearTimeout(timer);
8
+ reject(new Error("aborted"));
9
+ };
10
+ const timer = setTimeout(() => {
11
+ signal.removeEventListener("abort", onAbort);
12
+ resolve();
13
+ }, ms);
14
+ signal.addEventListener("abort", onAbort, { once: true });
15
+ });
16
+ class ShapeError extends Error {
17
+ }
18
+ const dynamicBranches = (def) => def.shape === "branches" && !!def.branches && !Array.isArray(def.branches);
19
+ /**
20
+ * Walks the flow from Start by the shape of each node's kind: a step runs and
21
+ * follows its one way out; a node with a fixed list of branches follows the one its
22
+ * handler names and skips the others; a node with a dynamic list walks every branch in
23
+ * fill order; an end stops. Branches meet again at `meetOf`.
24
+ */
25
+ export async function runFlow(options) {
26
+ const { flow, onEvent = () => { } } = options;
27
+ const signal = options.signal ?? new AbortController().signal;
28
+ const handlers = options.handlers ?? HANDLERS;
29
+ const defOf = (kind) => options.nodes?.find((d) => d.kind === kind) ??
30
+ (() => {
31
+ try {
32
+ return nodeDef(kind);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ })();
38
+ if ((options.unsupported ?? "fail") === "fail") {
39
+ const problems = flow.nodes
40
+ .map((n) => ({ n, def: defOf(n.kind) }))
41
+ .filter(({ n, def }) => def &&
42
+ (def.shape === "step" || def.shape === "branches") &&
43
+ !handlers[n.kind])
44
+ .map(({ def }) => `${def.label} can't run yet.`);
45
+ if (problems.length)
46
+ throw new Error(`This flow can't run: ${[...new Set(problems)].join(" ")}`);
47
+ }
48
+ const byId = new Map(flow.nodes.map((n) => [n.id, n]));
49
+ const results = {};
50
+ const context = {
51
+ env: { ...options.environment.variables },
52
+ vars: {},
53
+ nodes: {},
54
+ run: {
55
+ environment: options.environment.name,
56
+ trigger: options.trigger ?? "manual",
57
+ startedAt: options.startedAt ?? new Date().toISOString(),
58
+ },
59
+ };
60
+ const handlerRun = {
61
+ flow,
62
+ environment: options.environment,
63
+ context,
64
+ nodeTests: options.nodeTests,
65
+ signal,
66
+ sleep: options.sleep ?? defaultSleep,
67
+ runTests: (tests, onTestEvent) => options.runTests({ tests, onEvent: onTestEvent, signal }),
68
+ onTestEvent: (nodeId, event) => onEvent({ type: "test", nodeId, event }),
69
+ };
70
+ let stopped;
71
+ const visited = new Set();
72
+ const record = (node, result) => {
73
+ results[node.id] = result;
74
+ if (node.key)
75
+ context.nodes[node.key] = {
76
+ status: result.status,
77
+ durationMs: result.durationMs,
78
+ error: result.error,
79
+ };
80
+ };
81
+ const out = (id) => flow.edges.filter((e) => e.source === id);
82
+ /** Marks every node from `from` up to `stop` as skipped (a branch not taken) */
83
+ const skip = (from, stop) => {
84
+ const seen = new Set();
85
+ const visit = (id) => {
86
+ if (id === stop || seen.has(id))
87
+ return;
88
+ seen.add(id);
89
+ const node = byId.get(id);
90
+ if (node && !results[id]) {
91
+ record(node, { status: "skipped" });
92
+ onEvent({
93
+ type: "node-end",
94
+ nodeId: id,
95
+ result: { status: "skipped" },
96
+ });
97
+ }
98
+ for (const e of out(id))
99
+ visit(e.target);
100
+ };
101
+ visit(from);
102
+ };
103
+ const shapeError = (message) => new ShapeError(`This flow's shape can't run: ${message}`);
104
+ async function runNode(node, def) {
105
+ if (visited.has(node.id))
106
+ throw shapeError(`${node.key ?? node.id} is reached twice.`);
107
+ visited.add(node.id);
108
+ if (signal.aborted) {
109
+ stopped = "cancelled";
110
+ return undefined;
111
+ }
112
+ onEvent({ type: "node-start", nodeId: node.id });
113
+ const began = Date.now();
114
+ const handler = handlers[node.kind];
115
+ const outcome = handler
116
+ ? await handler(node, handlerRun)
117
+ : { status: "skipped", note: "Not supported yet" };
118
+ if (outcome === "cancelled" || signal.aborted) {
119
+ stopped = "cancelled";
120
+ return undefined;
121
+ }
122
+ const result = { ...outcome, durationMs: Date.now() - began };
123
+ record(node, result);
124
+ // A node with a dynamic list of branches ends after them: walk() reports it then
125
+ if (dynamicBranches(def))
126
+ return result;
127
+ onEvent({ type: "node-end", nodeId: node.id, result });
128
+ if (result.status === "failed" && node.onFailure === "stop")
129
+ stopped = "failed";
130
+ return result;
131
+ }
132
+ /** Whether `target` is on the chain from `from` before `stop` */
133
+ const reachesBefore = (from, target, stop) => {
134
+ const seen = new Set();
135
+ const visit = (id) => {
136
+ if (id === stop || seen.has(id))
137
+ return false;
138
+ if (id === target)
139
+ return true;
140
+ seen.add(id);
141
+ return out(id).some((e) => visit(e.target));
142
+ };
143
+ return visit(from);
144
+ };
145
+ /** Runs the chain from `from` up to `stop` (undefined: through End) */
146
+ async function walk(from, stop) {
147
+ let at = from;
148
+ while (at !== undefined && at !== stop && !stopped) {
149
+ const node = byId.get(at);
150
+ if (!node)
151
+ throw shapeError("an edge leads to a missing node.");
152
+ const def = defOf(node.kind);
153
+ if (!def)
154
+ throw shapeError(`${node.key ?? node.id} has an unknown kind (${node.kind}).`);
155
+ const result = await runNode(node, def);
156
+ if (!result || stopped)
157
+ return;
158
+ if (def.shape === "end")
159
+ return;
160
+ const edges = out(node.id);
161
+ if (def.shape === "branches") {
162
+ const meet = meetOf(flow, node.id);
163
+ if (!meet)
164
+ throw shapeError(`the branches of ${node.key ?? node.id} never meet.`);
165
+ if (!dynamicBranches(def)) {
166
+ const taken = result.status === "passed"
167
+ ? edges.find((e) => e.sourceHandle === result.branch)
168
+ : undefined;
169
+ for (const e of edges)
170
+ if (e !== taken)
171
+ skip(e.target, meet);
172
+ if (taken)
173
+ await walk(taken.target, meet);
174
+ }
175
+ else {
176
+ const opened = Date.now();
177
+ for (const e of inFillOrder(edges)) {
178
+ if (stopped)
179
+ break;
180
+ if (e.target !== meet)
181
+ await walk(e.target, meet);
182
+ }
183
+ if (stopped === "cancelled") {
184
+ // Stopped inside its branches: like any node cut short, it didn't run to the end
185
+ delete results[node.id];
186
+ return;
187
+ }
188
+ // The node fails when anything in its branches failed
189
+ const failed = flow.nodes.some((n) => results[n.id]?.status === "failed" &&
190
+ edges.some((e) => e.target !== meet && reachesBefore(e.target, n.id, meet)));
191
+ const durationMs = (result.durationMs ?? 0) + Date.now() - opened;
192
+ const ended = failed
193
+ ? {
194
+ ...result,
195
+ durationMs,
196
+ status: "failed",
197
+ error: "A branch failed.",
198
+ }
199
+ : { ...result, durationMs };
200
+ record(node, ended);
201
+ onEvent({ type: "node-end", nodeId: node.id, result: ended });
202
+ if (failed && node.onFailure === "stop")
203
+ stopped = "failed";
204
+ if (stopped)
205
+ return;
206
+ }
207
+ at = meet;
208
+ continue;
209
+ }
210
+ if (edges.length !== 1)
211
+ throw shapeError(`${node.key ?? node.id} has ${edges.length} ways out.`);
212
+ at = edges[0].target;
213
+ }
214
+ }
215
+ onEvent({ type: "flow-start", environment: options.environment.name });
216
+ let error;
217
+ const start = flow.nodes.find((n) => defOf(n.kind)?.shape === "start");
218
+ try {
219
+ if (!start)
220
+ throw shapeError("it has no Start.");
221
+ await walk(start.id);
222
+ }
223
+ catch (e) {
224
+ if (!(e instanceof ShapeError))
225
+ throw e;
226
+ error = e.message;
227
+ }
228
+ for (const node of flow.nodes)
229
+ if (!results[node.id])
230
+ results[node.id] = { status: "not-run" };
231
+ const status = stopped === "cancelled"
232
+ ? "cancelled"
233
+ : error || Object.values(results).some((r) => r.status === "failed")
234
+ ? "failed"
235
+ : "passed";
236
+ const summary = {
237
+ status,
238
+ nodes: results,
239
+ vars: context.vars,
240
+ ...(error ? { error } : {}),
241
+ };
242
+ onEvent({ type: "flow-end", summary });
243
+ return summary;
244
+ }
@@ -163,6 +163,14 @@ export default class Locator {
163
163
  isEditable(): Promise<boolean>;
164
164
  /** Wait for certain time (ms). Default: 300ms */
165
165
  wait(duration?: number): Promise<void>;
166
+ /**
167
+ * Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input.
168
+ * Named keys are `Enter`, `Escape`, `Tab`, `Backspace`, `Delete`, `Space`, `Home`, `End`, `PageUp`, `PageDown` and the arrows;
169
+ * any other key must be a single character.
170
+ * @example
171
+ * await page.getByLabel("New todo").press("Enter");
172
+ */
173
+ press(key: string, options?: ActionOptions): Promise<void>;
166
174
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
167
175
  fill(value: string, options?: ActionOptions): Promise<void>;
168
176
  }
@@ -42,7 +42,25 @@ const FILL_CHECKS = [
42
42
  "editable",
43
43
  "hit target",
44
44
  ];
45
+ const PRESS_CHECKS = ["attached", "visible", "enabled"];
45
46
  const ATTACHED_ONLY = ["attached"];
47
+ /** WebDriver code points of the named keys `press()` accepts; any other single character is typed as is */
48
+ const NAMED_KEYS = {
49
+ Backspace: "\uE003",
50
+ Tab: "\uE004",
51
+ Enter: "\uE007",
52
+ Escape: "\uE00C",
53
+ Space: " ",
54
+ ArrowLeft: "\uE012",
55
+ ArrowUp: "\uE013",
56
+ ArrowRight: "\uE014",
57
+ ArrowDown: "\uE015",
58
+ Delete: "\uE017",
59
+ Home: "\uE011",
60
+ End: "\uE010",
61
+ PageUp: "\uE00E",
62
+ PageDown: "\uE00F",
63
+ };
46
64
  /** Unicode code point WebDriver uses for the Backspace key */
47
65
  const BACKSPACE = "";
48
66
  export default class Locator {
@@ -556,6 +574,34 @@ export default class Locator {
556
574
  ],
557
575
  });
558
576
  }
577
+ /**
578
+ * Waits until the element is attached, visible and enabled, focuses it and presses `key` with real keyboard input.
579
+ * Named keys are `Enter`, `Escape`, `Tab`, `Backspace`, `Delete`, `Space`, `Home`, `End`, `PageUp`, `PageDown` and the arrows;
580
+ * any other key must be a single character.
581
+ * @example
582
+ * await page.getByLabel("New todo").press("Enter");
583
+ */
584
+ async press(key, options) {
585
+ const code = NAMED_KEYS[key] ?? (Array.from(key).length === 1 ? key : "");
586
+ if (code === "") {
587
+ throw new Error(`press(): unknown key "${key}". Use one of ${Object.keys(NAMED_KEYS).join(", ")} or a single character`);
588
+ }
589
+ await this.waitForActionable("press", PRESS_CHECKS, { scroll: true, hitTest: false }, options);
590
+ await this.callOnElement(undefined, `el.focus();`);
591
+ await this.biDiConnector.send("input.performActions", {
592
+ context: this.contextId,
593
+ actions: [
594
+ {
595
+ type: "key",
596
+ id: "samurai-keyboard",
597
+ actions: [
598
+ { type: "keyDown", value: code },
599
+ { type: "keyUp", value: code },
600
+ ],
601
+ },
602
+ ],
603
+ });
604
+ }
559
605
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
560
606
  async fill(value, options) {
561
607
  const state = await this.waitForActionable("fill", FILL_CHECKS, { scroll: true, hitTest: true }, options);
@@ -6,6 +6,8 @@
6
6
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
7
7
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
8
8
  * values never leave the page: `secret` is set and `value` is left out.
9
+ * - `{ type: "press", target, candidates, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
10
+ * click or typing, which are reported as such).
9
11
  * - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
10
12
  * While Alt is held, the element under the pointer is outlined.
11
13
  *
@@ -8,6 +8,8 @@ const OFF_KEY = "samurai.recorder.off";
8
8
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
9
9
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
10
10
  * values never leave the page: `secret` is set and `value` is left out.
11
+ * - `{ type: "press", target, candidates, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
12
+ * click or typing, which are reported as such).
11
13
  * - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
12
14
  * While Alt is held, the element under the pointer is outlined.
13
15
  *
@@ -184,6 +186,18 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
184
186
  });
185
187
  }, true);
186
188
 
189
+ // Enter, Tab and Escape change what the page does next (an Enter adds a todo and clears the field), so they are steps
190
+ const RECORDED_KEYS = ["Enter", "Tab", "Escape"];
191
+ // Enter on these is a click, which is recorded as one
192
+ const ACTIVATES = "a[href],button,summary,select,input[type=button],input[type=submit],input[type=reset],input[type=checkbox],input[type=radio]";
193
+ document.addEventListener("keydown", (event) => {
194
+ if (off() || !event.isTrusted || event.isComposing || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
195
+ if (RECORDED_KEYS.indexOf(event.key) < 0 || !(event.target instanceof Element)) return;
196
+ const el = event.target;
197
+ if (event.key === "Enter" && (el.closest(ACTIVATES) || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
198
+ report({ type: "press", target: el, candidates: candidatesOf(el), pressed: event.key });
199
+ }, true);
200
+
187
201
  document.addEventListener("mousemove", (event) => {
188
202
  if (off() || !event.altKey || !(event.target instanceof Element) || event.target === outlined) return;
189
203
  unhighlight();
@@ -22,6 +22,11 @@ export type Observation = {
22
22
  value?: string;
23
23
  secret?: boolean;
24
24
  secretName?: string;
25
+ } | {
26
+ type: "press";
27
+ key: string;
28
+ locator: LocatorSpec;
29
+ pressed: string;
25
30
  } | {
26
31
  type: "assert";
27
32
  key: string;
@@ -60,6 +60,18 @@ export class Normaliser {
60
60
  this.lastAction = at;
61
61
  return [this.insert({ kind: "click", locator: observation.locator })];
62
62
  }
63
+ if (observation.type === "press") {
64
+ // The page may react by clearing the field: what is typed next is a new fill, not a rewrite of this one
65
+ this.lastFill = undefined;
66
+ this.lastAction = at;
67
+ return [
68
+ this.insert({
69
+ kind: "press",
70
+ locator: observation.locator,
71
+ key: observation.pressed,
72
+ }),
73
+ ];
74
+ }
63
75
  if (observation.type === "assert") {
64
76
  this.lastFill = undefined;
65
77
  return [
@@ -99,24 +99,26 @@ export class Recorder {
99
99
  const key = message.target.sharedId;
100
100
  const observation = message.type === "click"
101
101
  ? { type: "click", key, locator, textEntry: message.textEntry === true }
102
- : message.type === "assert"
103
- ? {
104
- type: "assert",
105
- key,
106
- locator,
107
- text: message.text ?? "",
108
- ...(message.value !== undefined && { value: message.value }),
109
- }
110
- : {
111
- type: "input",
112
- key,
113
- locator,
114
- ...(message.value !== undefined && { value: message.value }),
115
- ...(message.secret && { secret: true }),
116
- ...(message.secretName !== undefined && {
117
- secretName: message.secretName,
118
- }),
119
- };
102
+ : message.type === "press"
103
+ ? { type: "press", key, locator, pressed: message.pressed ?? "" }
104
+ : message.type === "assert"
105
+ ? {
106
+ type: "assert",
107
+ key,
108
+ locator,
109
+ text: message.text ?? "",
110
+ ...(message.value !== undefined && { value: message.value }),
111
+ }
112
+ : {
113
+ type: "input",
114
+ key,
115
+ locator,
116
+ ...(message.value !== undefined && { value: message.value }),
117
+ ...(message.secret && { secret: true }),
118
+ ...(message.secretName !== undefined && {
119
+ secretName: message.secretName,
120
+ }),
121
+ };
120
122
  this.emit(this.normaliser.observe(observation, Date.now()));
121
123
  }
122
124
  async release() {
@@ -0,0 +1,39 @@
1
+ import type { SamuraiGroup } from "../types/config.js";
2
+ /** The test folder when the config names none */
3
+ export declare const DEFAULT_SRC_DIR = "./src";
4
+ /** The spec pattern when a group names none */
5
+ export declare const DEFAULT_TEST_MATCH = "**/*.spec.ts";
6
+ /** A test as groups see it: its spec file (relative to `srcDir`, or any path inside it) and full name */
7
+ export interface GroupTest {
8
+ file: string;
9
+ name: string;
10
+ }
11
+ /** A group with the tests it holds */
12
+ export interface ResolvedGroup {
13
+ name: string;
14
+ /** `picked`: the group lists its tests; `pattern`: `src` and `testMatch` select them */
15
+ kind: "picked" | "pattern";
16
+ /** What selects the tests of a pattern group, for display */
17
+ pattern?: string;
18
+ /** Ids (see `testId`) of the found tests in the group */
19
+ testIds: string[];
20
+ /** Picked tests that no longer exist */
21
+ missing: {
22
+ file: string;
23
+ title: string;
24
+ }[];
25
+ }
26
+ /** Separators of any platform become `/` */
27
+ export declare const toPosix: (p: string) => string;
28
+ /** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
29
+ export declare const testId: (file: string, name: string) => string;
30
+ /** Works out which of `tests` (files relative to `srcDir`) belong to the group */
31
+ export declare function resolveGroup(group: SamuraiGroup, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup;
32
+ /** Resolves every group of the config */
33
+ export declare function resolveGroups(config: {
34
+ groups?: SamuraiGroup[];
35
+ }, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup[];
36
+ /** The group called `name`, or an error naming the groups there are */
37
+ export declare function findGroup<T extends {
38
+ name: string;
39
+ }>(groups: T[], name: string): T;
@@ -0,0 +1,58 @@
1
+ import { matchesGlob, relative, resolve } from "node:path";
2
+ /** The test folder when the config names none */
3
+ export const DEFAULT_SRC_DIR = "./src";
4
+ /** The spec pattern when a group names none */
5
+ export const DEFAULT_TEST_MATCH = "**/*.spec.ts";
6
+ /** Separators of any platform become `/` */
7
+ export const toPosix = (p) => p.replace(/\\/g, "/");
8
+ /** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
9
+ export const testId = (file, name) => `${toPosix(file)}::${name}`;
10
+ /** Works out which of `tests` (files relative to `srcDir`) belong to the group */
11
+ export function resolveGroup(group, tests, projectDir, srcDir) {
12
+ const found = tests.map((t) => ({ ...t, id: testId(t.file, t.name) }));
13
+ // Tests picked by hand override src and testMatch
14
+ if (group.tests) {
15
+ const known = new Set(found.map((t) => t.id));
16
+ const picked = group.tests.map(({ file, title }) => ({
17
+ file,
18
+ title,
19
+ id: testId(file, title),
20
+ }));
21
+ return {
22
+ name: group.name,
23
+ kind: "picked",
24
+ testIds: picked.filter((t) => known.has(t.id)).map((t) => t.id),
25
+ missing: picked
26
+ .filter((t) => !known.has(t.id))
27
+ .map(({ file, title }) => ({ file, title })),
28
+ };
29
+ }
30
+ const base = group.src ? resolve(projectDir, group.src) : srcDir;
31
+ const pattern = group.testMatch ?? DEFAULT_TEST_MATCH;
32
+ const testIds = found
33
+ .filter((t) => {
34
+ const fromBase = toPosix(relative(base, resolve(srcDir, t.file)));
35
+ return !fromBase.startsWith("..") && matchesGlob(fromBase, pattern);
36
+ })
37
+ .map((t) => t.id);
38
+ return {
39
+ name: group.name,
40
+ kind: "pattern",
41
+ pattern: [group.src, group.testMatch].filter(Boolean).join(" · ") || pattern,
42
+ testIds,
43
+ missing: [],
44
+ };
45
+ }
46
+ /** Resolves every group of the config */
47
+ export function resolveGroups(config, tests, projectDir, srcDir) {
48
+ return (config.groups ?? []).map((group) => resolveGroup(group, tests, projectDir, srcDir));
49
+ }
50
+ /** The group called `name`, or an error naming the groups there are */
51
+ export function findGroup(groups, name) {
52
+ const group = groups.find((g) => g.name === name);
53
+ if (group)
54
+ return group;
55
+ throw new Error(groups.length === 0
56
+ ? `Unknown group "${name}". No groups are configured.`
57
+ : `Unknown group "${name}". Groups: ${groups.map((g) => g.name).join(", ")}.`);
58
+ }
@@ -1,5 +1,6 @@
1
1
  import type { TestSummary } from "../types/test.js";
2
2
  import { type ProjectOptions } from "./project.js";
3
+ import type { ResolvedGroup } from "./groups.js";
3
4
  import { type RunnerOptions } from "./test-runner.js";
4
5
  export interface RunTestsOptions extends RunnerOptions, ProjectOptions {
5
6
  }
@@ -21,7 +22,10 @@ export interface FoundTest {
21
22
  }
22
23
  /** Imports the project's specs without running anything and lists the tests the options select */
23
24
  export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
25
+ /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
26
+ export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
24
27
  export type { RunEvent } from "./reporter.js";
25
28
  export type { TestError, TestResult, TestSummary } from "../types/test.js";
29
+ export type { ResolvedGroup } from "./groups.js";
26
30
  export type { RunnerOptions } from "./test-runner.js";
27
31
  export type { ProjectOptions } from "./project.js";
@@ -32,3 +32,15 @@ export async function listTests(options = {}) {
32
32
  expectTimeout,
33
33
  }, async (run) => (await (await TestRunner.init(run, undefined, runner)).register()).map(({ name, file }) => ({ name, file })));
34
34
  }
35
+ /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
36
+ export async function listGroups(options = {}) {
37
+ const { projectDir, config, dataDir, environment, timeout, expectTimeout, ...runner } = options;
38
+ return withProject({
39
+ projectDir,
40
+ config,
41
+ dataDir,
42
+ environment,
43
+ timeout,
44
+ expectTimeout,
45
+ }, async (run) => (await TestRunner.init(run, undefined, runner)).groups());
46
+ }