@appthrust/kest 0.2.0 → 0.3.1

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,126 +1,92 @@
1
- interface BaseEvent<
2
- Kind extends string,
3
- Data extends Record<string, unknown> = Record<string, never>,
4
- > {
5
- readonly kind: Kind;
6
- readonly data: Data;
7
- }
8
-
9
1
  export type Event =
10
- | ScenarioStartedEvent
11
- | CommandRunEvent
12
- | CommandResultEvent
13
- | RetryEvent
2
+ | ScenarioEvent
14
3
  | ActionEvent
4
+ | CommandEvent
5
+ | RetryEvent
15
6
  | RevertingsEvent
16
7
  | BDDEvent;
17
8
 
18
- export type ScenarioStartedEvent = BaseEvent<
19
- "ScenarioStarted",
20
- {
21
- readonly name: string;
22
- }
23
- >;
24
-
25
- export type RetryEvent =
26
- | RetryStartEvent
27
- | RetryAttemptEvent
28
- | RetryFailureEvent
29
- | RetryEndEvent;
9
+ type ScenarioEvent =
10
+ | BaseEvent<
11
+ "ScenarioStart",
12
+ {
13
+ readonly name: string;
14
+ }
15
+ >
16
+ | BaseEvent<"ScenarioEnd", Record<string, never>>;
17
+
18
+ type ActionEvent =
19
+ | BaseEvent<
20
+ "ActionStart",
21
+ {
22
+ readonly description: string;
23
+ }
24
+ >
25
+ | BaseEvent<
26
+ "ActionEnd",
27
+ {
28
+ readonly ok: boolean;
29
+ readonly error?: undefined | Error;
30
+ }
31
+ >;
32
+
33
+ type CommandEvent =
34
+ | BaseEvent<
35
+ "CommandRun",
36
+ {
37
+ readonly cmd: string;
38
+ readonly args: ReadonlyArray<string>;
39
+ readonly stdin?: undefined | string;
40
+ readonly stdinLanguage?: undefined | string;
41
+ }
42
+ >
43
+ | BaseEvent<
44
+ "CommandResult",
45
+ {
46
+ readonly exitCode: number;
47
+ readonly stdout: string;
48
+ readonly stderr: string;
49
+ readonly stdoutLanguage?: undefined | string;
50
+ readonly stderrLanguage?: undefined | string;
51
+ }
52
+ >;
53
+
54
+ type RetryEvent =
55
+ | BaseEvent<"RetryStart", Record<string, never>>
56
+ | BaseEvent<
57
+ "RetryEnd",
58
+ | {
59
+ readonly attempts: number;
60
+ readonly success: true;
61
+ readonly reason: "success";
62
+ }
63
+ | {
64
+ readonly attempts: number;
65
+ readonly success: false;
66
+ readonly reason: "timeout";
67
+ readonly error: Error;
68
+ }
69
+ >;
70
+
71
+ type RevertingsEvent =
72
+ | BaseEvent<"RevertingsStart">
73
+ | BaseEvent<"RevertingsEnd">;
74
+
75
+ type BDDEvent =
76
+ | BaseEvent<"BDDGiven", { readonly description: string }>
77
+ | BaseEvent<"BDDWhen", { readonly description: string }>
78
+ | BaseEvent<"BDDThen", { readonly description: string }>
79
+ | BaseEvent<"BDDAnd", { readonly description: string }>
80
+ | BaseEvent<"BDBut", { readonly description: string }>;
30
81
 
31
- export interface ErrorSummary {
32
- readonly name?: undefined | string;
33
- readonly message: string;
82
+ interface BaseEvent<
83
+ Kind extends string,
84
+ Data extends Record<string, unknown> = Record<string, never>,
85
+ > {
86
+ readonly kind: Kind;
87
+ readonly data: Data;
34
88
  }
35
89
 
36
- export type RetryStartEvent = BaseEvent<"RetryStart", Record<string, never>>;
37
-
38
- export type RetryAttemptEvent = BaseEvent<
39
- "RetryAttempt",
40
- {
41
- readonly attempt: number;
42
- }
43
- >;
44
-
45
- export type RetryFailureEvent = BaseEvent<
46
- "RetryFailure",
47
- {
48
- readonly attempt: number;
49
- readonly error: ErrorSummary;
50
- }
51
- >;
52
-
53
- export type RetryEndEvent = BaseEvent<
54
- "RetryEnd",
55
- | {
56
- readonly attempts: number;
57
- readonly success: true;
58
- readonly reason: "success";
59
- }
60
- | {
61
- readonly attempts: number;
62
- readonly success: false;
63
- readonly reason: "timeout";
64
- readonly error: ErrorSummary;
65
- }
66
- >;
67
-
68
- export type CommandRunEvent = BaseEvent<
69
- "CommandRun",
70
- {
71
- readonly cmd: string;
72
- readonly args: ReadonlyArray<string>;
73
- readonly stdin?: undefined | string;
74
- readonly stdinLanguage?: undefined | string;
75
- }
76
- >;
77
-
78
- export type CommandResultEvent = BaseEvent<
79
- "CommandResult",
80
- {
81
- readonly exitCode: number;
82
- readonly stdout: string;
83
- readonly stderr: string;
84
- readonly stdoutLanguage?: undefined | string;
85
- readonly stderrLanguage?: undefined | string;
86
- }
87
- >;
88
-
89
- export type ActionEvent = ActionStartEvent | ActionEndEvent;
90
-
91
- export type ActionPhase = "mutate" | "revert" | "query";
92
-
93
- export type ActionStartEvent = BaseEvent<
94
- "ActionStart",
95
- {
96
- readonly action: string; // e.g. "CreateNamespaceAction"
97
- readonly phase: ActionPhase;
98
- readonly input?: undefined | Readonly<Record<string, unknown>>;
99
- }
100
- >;
101
-
102
- export type ActionEndEvent = BaseEvent<
103
- "ActionEnd",
104
- {
105
- readonly action: string; // e.g. "CreateNamespaceAction"
106
- readonly phase: ActionPhase;
107
- readonly ok: boolean;
108
- readonly error?: undefined | ErrorSummary;
109
- readonly output?: undefined | Readonly<Record<string, unknown>>;
110
- }
111
- >;
112
-
113
- export type RevertingsEvent = RevertingsStartEvent | RevertingsEndEvent;
114
- export type RevertingsStartEvent = BaseEvent<"RevertingsStart">;
115
- export type RevertingsEndEvent = BaseEvent<"RevertingsEnd">;
116
-
117
- export type BDDEvent = BaseEvent<
118
- "BDDGiven" | "BDDWhen" | "BDDThen" | "BDDAnd" | "BDBut",
119
- {
120
- readonly description: string;
121
- }
122
- >;
123
-
124
90
  export class Recorder {
125
91
  private readonly events: Array<Event> = [];
126
92
 
@@ -0,0 +1,23 @@
1
+ import type { Event } from "../../recording";
2
+ import type { Reporter } from "../interface";
3
+ import { parseEvents } from "./parser";
4
+ import { renderReport } from "./renderer";
5
+
6
+ export interface MarkdownReporterOptions {
7
+ /**
8
+ * If true, keep ANSI escape codes (colors) in error messages.
9
+ * If false (default), remove ANSI escape codes.
10
+ */
11
+ enableANSI?: undefined | boolean;
12
+ }
13
+
14
+ export function newMarkdownReporter(
15
+ options: MarkdownReporterOptions = {}
16
+ ): Reporter {
17
+ return {
18
+ report(events: ReadonlyArray<Event>): Promise<string> {
19
+ const report = parseEvents(events);
20
+ return renderReport(report, options);
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,63 @@
1
+ export interface Report {
2
+ scenarios: Array<Scenario>;
3
+ }
4
+
5
+ export interface Scenario {
6
+ name: string;
7
+ overview: Array<OverviewItem>;
8
+ details: Array<Tagged<"BDDSection", BDDSection> | Tagged<"Action", Action>>;
9
+ cleanup: Array<CleanupItem>;
10
+ }
11
+
12
+ type Tagged<Tag extends string, Target extends object> = Target & {
13
+ readonly type: Tag;
14
+ };
15
+
16
+ export interface OverviewItem {
17
+ name: string;
18
+ status: "pending" | "success" | "failure";
19
+ }
20
+
21
+ export interface BDDSection {
22
+ keyword: "given" | "when" | "then" | "and" | "but";
23
+ description: string;
24
+ actions: Array<Action>;
25
+ }
26
+
27
+ export interface Action {
28
+ name: string;
29
+ attempts?: undefined | number;
30
+ command?: undefined | Command;
31
+ error?: undefined | Error;
32
+ }
33
+
34
+ export interface Command {
35
+ cmd: string;
36
+ args: Array<string>;
37
+ stdin?: Text;
38
+ stdout?: Text;
39
+ stderr?: Text;
40
+ }
41
+
42
+ export interface Text {
43
+ text: string;
44
+ language?: undefined | string;
45
+ }
46
+
47
+ export interface Error {
48
+ message: Text;
49
+ stack?: undefined | string;
50
+ }
51
+
52
+ export interface CleanupItem {
53
+ action: string;
54
+ resource?: undefined | string;
55
+ status: "success" | "failure";
56
+ command: CleanupCommand;
57
+ }
58
+
59
+ export interface CleanupCommand {
60
+ cmd: string;
61
+ args: Array<string>;
62
+ output: string;
63
+ }
@@ -0,0 +1,361 @@
1
+ import type { Event } from "../../../recording";
2
+ import type {
3
+ Action,
4
+ BDDSection,
5
+ CleanupItem,
6
+ Command,
7
+ Report,
8
+ Scenario,
9
+ } from "../model";
10
+
11
+ const bddKeywordByKind = {
12
+ BDDGiven: "given",
13
+ BDDWhen: "when",
14
+ BDDThen: "then",
15
+ BDDAnd: "and",
16
+ BDBut: "but",
17
+ } as const;
18
+
19
+ type BDDEvent = Extract<
20
+ Event,
21
+ { kind: "BDDGiven" | "BDDWhen" | "BDDThen" | "BDDAnd" | "BDBut" }
22
+ >;
23
+
24
+ interface ParseState {
25
+ report: Report;
26
+ currentScenario: Scenario | undefined;
27
+ currentBDDSection: BDDSection | undefined;
28
+ inCleanup: boolean;
29
+ currentAction: Action | undefined;
30
+ currentOverviewIndex: number | undefined;
31
+ currentCleanup: CleanupItem | undefined;
32
+ }
33
+
34
+ export function parseEvents(events: ReadonlyArray<Event>): Report {
35
+ const state: ParseState = {
36
+ report: { scenarios: [] },
37
+ currentScenario: undefined,
38
+ currentBDDSection: undefined,
39
+ inCleanup: false,
40
+ currentAction: undefined,
41
+ currentOverviewIndex: undefined,
42
+ currentCleanup: undefined,
43
+ };
44
+
45
+ for (const event of events) {
46
+ const bdd = bddFromEvent(event);
47
+ if (bdd) {
48
+ handleBDDEvent(state, bdd);
49
+ continue;
50
+ }
51
+ handleNonBDDEvent(state, event);
52
+ }
53
+
54
+ return state.report;
55
+ }
56
+
57
+ function handleNonBDDEvent(state: ParseState, event: Event): void {
58
+ if (event.kind.startsWith("BDD")) {
59
+ return;
60
+ }
61
+
62
+ switch (event.kind) {
63
+ case "ScenarioStart":
64
+ handleScenarioStart(state, event);
65
+ return;
66
+ case "ScenarioEnd":
67
+ clearScenarioProgressState(state);
68
+ return;
69
+ case "RevertingsStart":
70
+ state.inCleanup = true;
71
+ clearCurrentActionState(state);
72
+ return;
73
+ case "RevertingsEnd":
74
+ state.inCleanup = false;
75
+ clearCurrentActionState(state);
76
+ return;
77
+ case "ActionStart":
78
+ handleActionStart(state, event);
79
+ return;
80
+ case "ActionEnd":
81
+ handleActionEnd(state, event);
82
+ return;
83
+ case "CommandRun":
84
+ handleCommandRun(state, event);
85
+ return;
86
+ case "CommandResult":
87
+ handleCommandResult(state, event);
88
+ return;
89
+ case "RetryEnd":
90
+ handleRetryEnd(state, event);
91
+ return;
92
+ case "RetryStart":
93
+ return;
94
+ default:
95
+ return;
96
+ }
97
+ }
98
+
99
+ function handleActionStart(
100
+ state: ParseState,
101
+ event: Extract<Event, { kind: "ActionStart" }>
102
+ ): void {
103
+ const scenario = ensureScenario(state.currentScenario, state.report);
104
+ if (state.inCleanup) {
105
+ const cleanup: CleanupItem = {
106
+ action: event.data.description,
107
+ status: "success",
108
+ command: { cmd: "", args: [], output: "" },
109
+ };
110
+ scenario.cleanup.push(cleanup);
111
+ state.currentCleanup = cleanup;
112
+ state.currentAction = undefined;
113
+ state.currentOverviewIndex = undefined;
114
+ return;
115
+ }
116
+
117
+ const action: Action = { name: event.data.description };
118
+ scenario.overview.push({
119
+ name: event.data.description,
120
+ status: "pending",
121
+ });
122
+ state.currentOverviewIndex = scenario.overview.length - 1;
123
+
124
+ if (state.currentBDDSection) {
125
+ state.currentAction = action;
126
+ state.currentBDDSection.actions.push(action);
127
+ return;
128
+ }
129
+
130
+ // NOTE: keep a shared reference between `state.currentAction` and `details`
131
+ // so that subsequent Command*/Retry*/ActionEnd events update what is shown in
132
+ // the scenario details even when the action is outside a BDD section.
133
+ const taggedAction = { type: "Action" as const, ...action };
134
+ scenario.details.push(taggedAction);
135
+ state.currentAction = taggedAction;
136
+ }
137
+
138
+ function applyRegularActionEnd(
139
+ state: ParseState,
140
+ event: Extract<Event, { kind: "ActionEnd" }>
141
+ ): void {
142
+ const { currentScenario, currentAction } = state;
143
+ if (!currentScenario) {
144
+ return;
145
+ }
146
+ if (!currentAction) {
147
+ return;
148
+ }
149
+
150
+ if (state.currentOverviewIndex !== undefined) {
151
+ const overviewItem = currentScenario.overview[state.currentOverviewIndex];
152
+ if (overviewItem) {
153
+ overviewItem.status = event.data.ok ? "success" : "failure";
154
+ }
155
+ }
156
+
157
+ if (!event.data.ok && event.data.error) {
158
+ currentAction.error = {
159
+ message: {
160
+ text: event.data.error.message,
161
+ language: isDiffLike(event.data.error.message) ? "diff" : "text",
162
+ },
163
+ };
164
+ }
165
+
166
+ state.currentAction = undefined;
167
+ state.currentOverviewIndex = undefined;
168
+ }
169
+
170
+ function handleCommandResult(
171
+ state: ParseState,
172
+ event: Extract<Event, { kind: "CommandResult" }>
173
+ ): void {
174
+ if (state.inCleanup) {
175
+ if (state.currentCleanup) {
176
+ state.currentCleanup.command.output =
177
+ event.data.stdout.length > 0 ? event.data.stdout : event.data.stderr;
178
+ }
179
+ return;
180
+ }
181
+
182
+ const { currentAction } = state;
183
+ if (!currentAction?.command) {
184
+ return;
185
+ }
186
+
187
+ currentAction.command.stdout = {
188
+ text: event.data.stdout,
189
+ ...(event.data.stdoutLanguage
190
+ ? { language: event.data.stdoutLanguage }
191
+ : {}),
192
+ };
193
+ currentAction.command.stderr = {
194
+ text: event.data.stderr,
195
+ ...(event.data.stderrLanguage
196
+ ? { language: event.data.stderrLanguage }
197
+ : {}),
198
+ };
199
+ }
200
+
201
+ function handleCommandRun(
202
+ state: ParseState,
203
+ event: Extract<Event, { kind: "CommandRun" }>
204
+ ): void {
205
+ if (state.inCleanup) {
206
+ if (state.currentCleanup) {
207
+ state.currentCleanup.command = {
208
+ cmd: event.data.cmd,
209
+ args: [...event.data.args],
210
+ output: "",
211
+ };
212
+ }
213
+ return;
214
+ }
215
+
216
+ if (!state.currentAction) {
217
+ return;
218
+ }
219
+ state.currentAction.command = createCommandFromRun(event);
220
+ }
221
+
222
+ function handleScenarioStart(
223
+ state: ParseState,
224
+ event: Extract<Event, { kind: "ScenarioStart" }>
225
+ ): void {
226
+ state.currentScenario = {
227
+ name: event.data.name,
228
+ overview: [],
229
+ details: [],
230
+ cleanup: [],
231
+ };
232
+ state.report.scenarios.push(state.currentScenario);
233
+ clearScenarioProgressState(state);
234
+ }
235
+
236
+ function handleActionEnd(
237
+ state: ParseState,
238
+ event: Extract<Event, { kind: "ActionEnd" }>
239
+ ): void {
240
+ if (handleCleanupActionEnd(state, event)) {
241
+ return;
242
+ }
243
+
244
+ applyRegularActionEnd(state, event);
245
+ }
246
+
247
+ function handleCleanupActionEnd(
248
+ state: ParseState,
249
+ event: Extract<Event, { kind: "ActionEnd" }>
250
+ ): boolean {
251
+ if (!state.inCleanup) {
252
+ return false;
253
+ }
254
+
255
+ if (state.currentCleanup) {
256
+ state.currentCleanup.status = event.data.ok ? "success" : "failure";
257
+ }
258
+ state.currentCleanup = undefined;
259
+ return true;
260
+ }
261
+
262
+ function handleRetryEnd(
263
+ state: ParseState,
264
+ event: Extract<Event, { kind: "RetryEnd" }>
265
+ ): void {
266
+ if (state.inCleanup) {
267
+ return;
268
+ }
269
+ if (!state.currentAction) {
270
+ return;
271
+ }
272
+ state.currentAction.attempts = event.data.attempts;
273
+ }
274
+
275
+ function handleBDDEvent(state: ParseState, bdd: BDDSection): void {
276
+ const scenario = ensureScenario(state.currentScenario, state.report);
277
+ scenario.details.push({
278
+ type: "BDDSection",
279
+ ...bdd,
280
+ });
281
+ state.currentScenario = scenario;
282
+ state.currentBDDSection = bdd;
283
+ }
284
+
285
+ function createCommandFromRun(
286
+ run: Extract<Event, { kind: "CommandRun" }>
287
+ ): Command {
288
+ return {
289
+ cmd: run.data.cmd,
290
+ args: [...run.data.args],
291
+ ...(typeof run.data.stdin === "string"
292
+ ? {
293
+ stdin: {
294
+ text: run.data.stdin,
295
+ ...(run.data.stdinLanguage
296
+ ? { language: run.data.stdinLanguage }
297
+ : {}),
298
+ },
299
+ }
300
+ : {}),
301
+ };
302
+ }
303
+
304
+ function ensureScenario(
305
+ scenario: Scenario | undefined,
306
+ report: Report
307
+ ): Scenario {
308
+ if (scenario) {
309
+ return scenario;
310
+ }
311
+ const created: Scenario = {
312
+ name: "Scenario",
313
+ overview: [],
314
+ details: [],
315
+ cleanup: [],
316
+ };
317
+ report.scenarios.push(created);
318
+ return created;
319
+ }
320
+
321
+ function clearScenarioProgressState(state: ParseState): void {
322
+ state.currentBDDSection = undefined;
323
+ state.inCleanup = false;
324
+ clearCurrentActionState(state);
325
+ }
326
+
327
+ function clearCurrentActionState(state: ParseState): void {
328
+ state.currentAction = undefined;
329
+ state.currentOverviewIndex = undefined;
330
+ state.currentCleanup = undefined;
331
+ }
332
+
333
+ function bddFromEvent(event: Event): BDDSection | undefined {
334
+ if (!isBDDEvent(event)) {
335
+ return undefined;
336
+ }
337
+ const keyword = bddKeywordByKind[event.kind];
338
+ return { keyword, description: event.data.description, actions: [] };
339
+ }
340
+
341
+ function isDiffLike(message: string): boolean {
342
+ const lines = message.split(/\r?\n/);
343
+ let sawPlus = false;
344
+ let sawMinus = false;
345
+ for (const line of lines) {
346
+ if (line.startsWith("+") && !line.startsWith("+++")) {
347
+ sawPlus = true;
348
+ }
349
+ if (line.startsWith("-") && !line.startsWith("---")) {
350
+ sawMinus = true;
351
+ }
352
+ if (sawPlus && sawMinus) {
353
+ return true;
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+
359
+ function isBDDEvent(event: Event): event is BDDEvent {
360
+ return event.kind in bddKeywordByKind;
361
+ }