executable-stories-e2e 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +71 -2
- package/dist/index.cjs +542 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +76 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +504 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -3
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Reporter, Report } from 'e2e';
|
|
2
|
+
import { RawRun } from 'executable-stories-core/types/raw';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step markers for e2e tests.
|
|
6
|
+
*
|
|
7
|
+
* A marker records where and when the test called it. The reporter groups
|
|
8
|
+
* the e2e steps that ran after a marker under the marker's text:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* test('incrementing shows the new count', async ({ app, screen }) => {
|
|
12
|
+
* story.given('the counter page is open');
|
|
13
|
+
* await app.open('/');
|
|
14
|
+
* story.when('the user presses Increment twice');
|
|
15
|
+
* await screen.getByRole('button', { name: 'Increment' }).click();
|
|
16
|
+
* await screen.getByRole('button', { name: 'Increment' }).click();
|
|
17
|
+
* story.then('the count reads 2');
|
|
18
|
+
* await expect(screen.getByRole('status')).toHaveText('2');
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
type MarkerKeyword = 'Given' | 'When' | 'Then' | 'And' | 'But';
|
|
23
|
+
/** One line of a markers file. */
|
|
24
|
+
interface Marker {
|
|
25
|
+
keyword: MarkerKeyword;
|
|
26
|
+
text: string;
|
|
27
|
+
/** Absolute path of the test file that called the marker. */
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
/** Orders a marker before a step on its own line; absent reads as the line's start. */
|
|
31
|
+
column?: number;
|
|
32
|
+
/** Epoch milliseconds. */
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
/** Step markers. Call before the e2e steps the marker describes; they render under its text. */
|
|
36
|
+
declare const story: {
|
|
37
|
+
given: (text: string) => void;
|
|
38
|
+
when: (text: string) => void;
|
|
39
|
+
then: (text: string) => void;
|
|
40
|
+
and: (text: string) => void;
|
|
41
|
+
but: (text: string) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Step text for one non-agent step. */
|
|
45
|
+
declare function phraseStep(api: string, label: string): string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* executable-stories reporter for the `e2e` agentic test runner.
|
|
49
|
+
*
|
|
50
|
+
* The reporter reads the finished `report-1` document and writes the RawRun
|
|
51
|
+
* JSON every other adapter writes. `executable-stories format` takes it from
|
|
52
|
+
* there. Optional `story.given/when/then` markers in a test name the steps in
|
|
53
|
+
* the test's own words; without them, step text is phrased from e2e's record.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
interface StoryReporterOptions {
|
|
57
|
+
/** Where to write the RawRun JSON, relative to the project root. Default: `.executable-stories/raw-run.json`, where `executable-stories format` looks first. */
|
|
58
|
+
rawRunPath?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Map an e2e `report-1` document to a RawRun. Pure: artifact paths resolve
|
|
62
|
+
* against `artifactsRoot` and nothing is read from disk.
|
|
63
|
+
*/
|
|
64
|
+
declare function toRawRun(args: {
|
|
65
|
+
report: Report;
|
|
66
|
+
projectRoot: string;
|
|
67
|
+
artifactsRoot: string;
|
|
68
|
+
/** Set when the run was `--last-failed`: it then covered only part of the suite. */
|
|
69
|
+
lastRun?: Report;
|
|
70
|
+
/** Step markers the run's tests recorded with `story.given/when/then`. */
|
|
71
|
+
markers?: readonly Marker[];
|
|
72
|
+
}): RawRun;
|
|
73
|
+
/** An e2e reporter that writes the run as RawRun JSON for the executable-stories CLI. */
|
|
74
|
+
declare function storyReporter(options?: StoryReporterOptions): Reporter;
|
|
75
|
+
|
|
76
|
+
export { type Marker, type MarkerKeyword, type StoryReporterOptions, phraseStep, story, storyReporter, toRawRun };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Reporter, Report } from 'e2e';
|
|
2
|
+
import { RawRun } from 'executable-stories-core/types/raw';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step markers for e2e tests.
|
|
6
|
+
*
|
|
7
|
+
* A marker records where and when the test called it. The reporter groups
|
|
8
|
+
* the e2e steps that ran after a marker under the marker's text:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* test('incrementing shows the new count', async ({ app, screen }) => {
|
|
12
|
+
* story.given('the counter page is open');
|
|
13
|
+
* await app.open('/');
|
|
14
|
+
* story.when('the user presses Increment twice');
|
|
15
|
+
* await screen.getByRole('button', { name: 'Increment' }).click();
|
|
16
|
+
* await screen.getByRole('button', { name: 'Increment' }).click();
|
|
17
|
+
* story.then('the count reads 2');
|
|
18
|
+
* await expect(screen.getByRole('status')).toHaveText('2');
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
type MarkerKeyword = 'Given' | 'When' | 'Then' | 'And' | 'But';
|
|
23
|
+
/** One line of a markers file. */
|
|
24
|
+
interface Marker {
|
|
25
|
+
keyword: MarkerKeyword;
|
|
26
|
+
text: string;
|
|
27
|
+
/** Absolute path of the test file that called the marker. */
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
/** Orders a marker before a step on its own line; absent reads as the line's start. */
|
|
31
|
+
column?: number;
|
|
32
|
+
/** Epoch milliseconds. */
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
/** Step markers. Call before the e2e steps the marker describes; they render under its text. */
|
|
36
|
+
declare const story: {
|
|
37
|
+
given: (text: string) => void;
|
|
38
|
+
when: (text: string) => void;
|
|
39
|
+
then: (text: string) => void;
|
|
40
|
+
and: (text: string) => void;
|
|
41
|
+
but: (text: string) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Step text for one non-agent step. */
|
|
45
|
+
declare function phraseStep(api: string, label: string): string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* executable-stories reporter for the `e2e` agentic test runner.
|
|
49
|
+
*
|
|
50
|
+
* The reporter reads the finished `report-1` document and writes the RawRun
|
|
51
|
+
* JSON every other adapter writes. `executable-stories format` takes it from
|
|
52
|
+
* there. Optional `story.given/when/then` markers in a test name the steps in
|
|
53
|
+
* the test's own words; without them, step text is phrased from e2e's record.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
interface StoryReporterOptions {
|
|
57
|
+
/** Where to write the RawRun JSON, relative to the project root. Default: `.executable-stories/raw-run.json`, where `executable-stories format` looks first. */
|
|
58
|
+
rawRunPath?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Map an e2e `report-1` document to a RawRun. Pure: artifact paths resolve
|
|
62
|
+
* against `artifactsRoot` and nothing is read from disk.
|
|
63
|
+
*/
|
|
64
|
+
declare function toRawRun(args: {
|
|
65
|
+
report: Report;
|
|
66
|
+
projectRoot: string;
|
|
67
|
+
artifactsRoot: string;
|
|
68
|
+
/** Set when the run was `--last-failed`: it then covered only part of the suite. */
|
|
69
|
+
lastRun?: Report;
|
|
70
|
+
/** Step markers the run's tests recorded with `story.given/when/then`. */
|
|
71
|
+
markers?: readonly Marker[];
|
|
72
|
+
}): RawRun;
|
|
73
|
+
/** An e2e reporter that writes the run as RawRun JSON for the executable-stories CLI. */
|
|
74
|
+
declare function storyReporter(options?: StoryReporterOptions): Reporter;
|
|
75
|
+
|
|
76
|
+
export { type Marker, type MarkerKeyword, type StoryReporterOptions, phraseStep, story, storyReporter, toRawRun };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { rmSync } from "fs";
|
|
3
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
4
|
+
import * as path2 from "path";
|
|
5
|
+
|
|
6
|
+
// src/phrase.ts
|
|
7
|
+
var ROLE_NOUNS = {
|
|
8
|
+
textbox: "field",
|
|
9
|
+
searchbox: "search field",
|
|
10
|
+
combobox: "dropdown",
|
|
11
|
+
spinbutton: "number field",
|
|
12
|
+
img: "image"
|
|
13
|
+
};
|
|
14
|
+
var SIMPLE_QUERY = /^getBy(\w+)\(("(?:[^"\\]|\\.)*")(?:, name: ("(?:[^"\\]|\\.)*"))?\)$/;
|
|
15
|
+
function unquote(json) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(json);
|
|
18
|
+
} catch {
|
|
19
|
+
return json;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function describeTarget(label) {
|
|
23
|
+
const match = SIMPLE_QUERY.exec(label);
|
|
24
|
+
if (!match) return void 0;
|
|
25
|
+
const [, kind, rawValue, rawName] = match;
|
|
26
|
+
const value = unquote(rawValue);
|
|
27
|
+
const name = rawName === void 0 ? void 0 : unquote(rawName);
|
|
28
|
+
switch (kind) {
|
|
29
|
+
case "Role": {
|
|
30
|
+
const noun = ROLE_NOUNS[value] ?? value;
|
|
31
|
+
return name === void 0 ? `the ${noun}` : `the "${name}" ${noun}`;
|
|
32
|
+
}
|
|
33
|
+
case "Text":
|
|
34
|
+
return `the text "${value}"`;
|
|
35
|
+
case "Label":
|
|
36
|
+
case "Placeholder":
|
|
37
|
+
return `the "${value}" field`;
|
|
38
|
+
case "AltText":
|
|
39
|
+
return `the "${value}" image`;
|
|
40
|
+
case "TestId":
|
|
41
|
+
case "Title":
|
|
42
|
+
return `the "${value}" element`;
|
|
43
|
+
default:
|
|
44
|
+
return void 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
var ACTIONS = {
|
|
48
|
+
click: "click",
|
|
49
|
+
tap: "tap",
|
|
50
|
+
dblclick: "double-click",
|
|
51
|
+
hover: "hover over",
|
|
52
|
+
check: "check",
|
|
53
|
+
uncheck: "uncheck",
|
|
54
|
+
fill: "fill in",
|
|
55
|
+
clear: "clear",
|
|
56
|
+
press: "press a key on",
|
|
57
|
+
pressSequentially: "type into",
|
|
58
|
+
selectOption: "choose an option in",
|
|
59
|
+
longPress: "long-press",
|
|
60
|
+
swipe: "swipe",
|
|
61
|
+
focus: "focus"
|
|
62
|
+
};
|
|
63
|
+
var CLAIMS = {
|
|
64
|
+
toBeVisible: ["is visible", "is not visible"],
|
|
65
|
+
toBeHidden: ["is hidden", "is not hidden"],
|
|
66
|
+
toBeEnabled: ["is enabled", "is not enabled"],
|
|
67
|
+
toBeDisabled: ["is disabled", "is not disabled"],
|
|
68
|
+
toBeChecked: ["is checked", "is not checked"],
|
|
69
|
+
toBeFocused: ["is focused", "is not focused"],
|
|
70
|
+
toBeSelected: ["is selected", "is not selected"],
|
|
71
|
+
toBeExpanded: ["is expanded", "is not expanded"],
|
|
72
|
+
toBeAttached: ["is on the page", "is not on the page"],
|
|
73
|
+
toHaveText: ["shows the expected text", "does not show the given text"],
|
|
74
|
+
toContainText: [
|
|
75
|
+
"contains the expected text",
|
|
76
|
+
"does not contain the given text"
|
|
77
|
+
],
|
|
78
|
+
toHaveValue: ["has the expected value", "does not have the given value"],
|
|
79
|
+
toHaveCount: [
|
|
80
|
+
"appears the expected number of times",
|
|
81
|
+
"does not appear the given number of times"
|
|
82
|
+
],
|
|
83
|
+
toHaveAccessibleName: [
|
|
84
|
+
"has the expected accessible name",
|
|
85
|
+
"does not have the given accessible name"
|
|
86
|
+
],
|
|
87
|
+
toHaveAttribute: [
|
|
88
|
+
"has the expected attribute",
|
|
89
|
+
"does not have the given attribute"
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
var APP = {
|
|
93
|
+
"app.back": "go back",
|
|
94
|
+
"app.restart": "restart the app",
|
|
95
|
+
"app.clearState": "clear the app state",
|
|
96
|
+
"session.launch": "launch the app session",
|
|
97
|
+
"session.close": "close the app session"
|
|
98
|
+
};
|
|
99
|
+
function phraseStep(api, label) {
|
|
100
|
+
const raw = label ? `${api} ${label}` : api;
|
|
101
|
+
if (api === "app.open") return label ? `open ${label}` : "open the app";
|
|
102
|
+
if (api === "app.screenshot")
|
|
103
|
+
return label ? `take a screenshot: ${label}` : "take a screenshot";
|
|
104
|
+
if (api === "session.save")
|
|
105
|
+
return label ? `save the session as ${label}` : "save the session";
|
|
106
|
+
if (APP[api]) return APP[api];
|
|
107
|
+
const action = /^locator\.(\w+)$/.exec(api)?.[1];
|
|
108
|
+
if (action !== void 0) {
|
|
109
|
+
if (action === "waitFor") {
|
|
110
|
+
const [target, state] = label.split(" \u2192 ");
|
|
111
|
+
const noun2 = target === void 0 ? void 0 : describeTarget(target);
|
|
112
|
+
return noun2 && state ? `wait until ${noun2} is ${state}` : raw;
|
|
113
|
+
}
|
|
114
|
+
const verb = ACTIONS[action];
|
|
115
|
+
const noun = describeTarget(label);
|
|
116
|
+
return verb && noun ? `${verb} ${noun}` : raw;
|
|
117
|
+
}
|
|
118
|
+
const claim = /^expect\.(not\.)?(\w+)$/.exec(api);
|
|
119
|
+
if (claim) {
|
|
120
|
+
const words = CLAIMS[claim[2]];
|
|
121
|
+
const noun = describeTarget(label);
|
|
122
|
+
return words && noun ? `${noun} ${words[claim[1] ? 1 : 0]}` : raw;
|
|
123
|
+
}
|
|
124
|
+
return raw;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/story.ts
|
|
128
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
129
|
+
import * as path from "path";
|
|
130
|
+
import { fileURLToPath } from "url";
|
|
131
|
+
var MARKERS_DIR = ".executable-stories/e2e-markers";
|
|
132
|
+
var FRAME = /\(?((?:file:\/\/)?[^\s()]+?):(\d+):(\d+)\)?$/;
|
|
133
|
+
function callSite() {
|
|
134
|
+
const frames = (new Error().stack ?? "").split("\n").slice(1);
|
|
135
|
+
let own;
|
|
136
|
+
for (const frame of frames) {
|
|
137
|
+
const match = FRAME.exec(frame.trim());
|
|
138
|
+
if (!match) continue;
|
|
139
|
+
const file = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
|
|
140
|
+
own ??= file;
|
|
141
|
+
if (file !== own)
|
|
142
|
+
return { file, line: Number(match[2]), column: Number(match[3]) };
|
|
143
|
+
}
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
146
|
+
function mark(keyword, text) {
|
|
147
|
+
const site = callSite();
|
|
148
|
+
if (site === void 0) return;
|
|
149
|
+
const marker = { keyword, text, ...site, at: Date.now() };
|
|
150
|
+
const dir = path.resolve(MARKERS_DIR);
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
appendFileSync(
|
|
153
|
+
path.join(dir, `${process.pid}.jsonl`),
|
|
154
|
+
`${JSON.stringify(marker)}
|
|
155
|
+
`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
var story = {
|
|
159
|
+
given: (text) => mark("Given", text),
|
|
160
|
+
when: (text) => mark("When", text),
|
|
161
|
+
then: (text) => mark("Then", text),
|
|
162
|
+
and: (text) => mark("And", text),
|
|
163
|
+
but: (text) => mark("But", text)
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// src/index.ts
|
|
167
|
+
function unknownValue(_value, fallback) {
|
|
168
|
+
return fallback;
|
|
169
|
+
}
|
|
170
|
+
function resultStatus(status) {
|
|
171
|
+
switch (status) {
|
|
172
|
+
case "passed":
|
|
173
|
+
case "flaky":
|
|
174
|
+
return "pass";
|
|
175
|
+
case "failed":
|
|
176
|
+
return "fail";
|
|
177
|
+
case "skipped":
|
|
178
|
+
return "skip";
|
|
179
|
+
case "timed-out":
|
|
180
|
+
return "timeout";
|
|
181
|
+
case "interrupted":
|
|
182
|
+
return "interrupted";
|
|
183
|
+
default:
|
|
184
|
+
return unknownValue(status, "unknown");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function stepStatus(status) {
|
|
188
|
+
switch (status) {
|
|
189
|
+
case "passed":
|
|
190
|
+
return "pass";
|
|
191
|
+
// Blocked and timed-out steps stopped the scenario, so they read as fail;
|
|
192
|
+
// the error message keeps e2e's code.
|
|
193
|
+
case "failed":
|
|
194
|
+
case "blocked":
|
|
195
|
+
case "timed-out":
|
|
196
|
+
return "fail";
|
|
197
|
+
// A cancelled step did not finish, so it reads as not run.
|
|
198
|
+
case "cancelled":
|
|
199
|
+
return "skip";
|
|
200
|
+
default:
|
|
201
|
+
return unknownValue(status, "unknown");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function primaryKeyword(step) {
|
|
205
|
+
switch (step.kind) {
|
|
206
|
+
case "app":
|
|
207
|
+
case "session":
|
|
208
|
+
case "resource":
|
|
209
|
+
return "Given";
|
|
210
|
+
case "locator":
|
|
211
|
+
case "screen":
|
|
212
|
+
return "When";
|
|
213
|
+
case "assertion":
|
|
214
|
+
return "Then";
|
|
215
|
+
case "agent":
|
|
216
|
+
return step.api === "agent.assert" ? "Then" : "When";
|
|
217
|
+
default:
|
|
218
|
+
return unknownValue(step.kind, "When");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function stepText(step) {
|
|
222
|
+
if (step.kind === "agent" && step.label) return step.label;
|
|
223
|
+
return phraseStep(step.api, step.label);
|
|
224
|
+
}
|
|
225
|
+
function errorMessage(error) {
|
|
226
|
+
return `${error.code}: ${error.message}`;
|
|
227
|
+
}
|
|
228
|
+
function toGroups(steps, markers, projectRoot) {
|
|
229
|
+
const groups = [];
|
|
230
|
+
let pending = 0;
|
|
231
|
+
let current;
|
|
232
|
+
const before = (m, step) => {
|
|
233
|
+
if (step === void 0) return true;
|
|
234
|
+
const at = Date.parse(step.startedAt);
|
|
235
|
+
if (m.at !== at) return m.at < at;
|
|
236
|
+
if (m.file !== path2.resolve(projectRoot, step.source.file)) return true;
|
|
237
|
+
return m.line < step.source.line || m.line === step.source.line && (m.column ?? 0) < step.source.column;
|
|
238
|
+
};
|
|
239
|
+
const takeMarkersBefore = (step) => {
|
|
240
|
+
while (pending < markers.length && before(markers[pending], step)) {
|
|
241
|
+
const m = markers[pending++];
|
|
242
|
+
current = {
|
|
243
|
+
keyword: m.keyword,
|
|
244
|
+
text: m.text,
|
|
245
|
+
marker: true,
|
|
246
|
+
children: []
|
|
247
|
+
};
|
|
248
|
+
groups.push(current);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
for (const step of steps) {
|
|
252
|
+
takeMarkersBefore(step);
|
|
253
|
+
if (current) current.children.push(step);
|
|
254
|
+
else
|
|
255
|
+
groups.push({
|
|
256
|
+
keyword: primaryKeyword(step),
|
|
257
|
+
text: stepText(step),
|
|
258
|
+
marker: false,
|
|
259
|
+
children: [step]
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
takeMarkersBefore(void 0);
|
|
263
|
+
return groups;
|
|
264
|
+
}
|
|
265
|
+
function isObservedClaim(step) {
|
|
266
|
+
return primaryKeyword(step) === "Then" && (step.status === "passed" || step.status === "failed");
|
|
267
|
+
}
|
|
268
|
+
function groupStatus(group) {
|
|
269
|
+
const statuses = group.children.map((c) => stepStatus(c.status));
|
|
270
|
+
for (const worst of ["fail", "unknown", "skip"])
|
|
271
|
+
if (statuses.includes(worst)) return worst;
|
|
272
|
+
return "pass";
|
|
273
|
+
}
|
|
274
|
+
function agentExplanation(step) {
|
|
275
|
+
const why = step.explanation?.trim();
|
|
276
|
+
if (!why || step.error?.message.includes(why)) return [];
|
|
277
|
+
return [{ kind: "note", text: `Agent: ${why}`, phase: "runtime" }];
|
|
278
|
+
}
|
|
279
|
+
function toSteps(groups) {
|
|
280
|
+
const out = [];
|
|
281
|
+
groups.forEach((group, index) => {
|
|
282
|
+
const repeat = group.keyword !== "And" && group.keyword !== "But" && out.some((s) => s.keyword === group.keyword);
|
|
283
|
+
const keyword = repeat ? "And" : group.keyword;
|
|
284
|
+
const claims = group.children.filter(isObservedClaim).length;
|
|
285
|
+
const assertions = claims > 0 ? { assertions: claims } : {};
|
|
286
|
+
const docs = group.children.flatMap((c) => [
|
|
287
|
+
...group.marker ? [
|
|
288
|
+
{
|
|
289
|
+
kind: "note",
|
|
290
|
+
text: stepText(c),
|
|
291
|
+
phase: "runtime"
|
|
292
|
+
}
|
|
293
|
+
] : [],
|
|
294
|
+
...agentExplanation(c)
|
|
295
|
+
]);
|
|
296
|
+
out.push({
|
|
297
|
+
id: `step-${index}`,
|
|
298
|
+
keyword,
|
|
299
|
+
text: group.text,
|
|
300
|
+
durationMs: group.children.reduce((sum, c) => sum + c.durationMs, 0),
|
|
301
|
+
...assertions,
|
|
302
|
+
...docs.length > 0 ? { docs } : {}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function toMedia(attempt, groups, artifactsRoot) {
|
|
308
|
+
const attachments = [];
|
|
309
|
+
const docs = [];
|
|
310
|
+
for (const artifact of attempt.artifacts) {
|
|
311
|
+
if (artifact.path === void 0) {
|
|
312
|
+
if (artifact.kind === "video" && artifact.url !== void 0 && /^https?:\/\//.test(artifact.url)) {
|
|
313
|
+
docs.push({ kind: "video", path: artifact.url, phase: "runtime" });
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const stepIndex = groups.findIndex(
|
|
318
|
+
(group) => group.children.some(
|
|
319
|
+
(step) => step.artifacts.includes(artifact.id) || artifact.producer?.kind === "step" && artifact.producer.stepId === step.id
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
attachments.push({
|
|
323
|
+
name: artifact.kind,
|
|
324
|
+
mediaType: artifact.mediaType,
|
|
325
|
+
path: path2.join(artifactsRoot, artifact.path),
|
|
326
|
+
fileName: path2.basename(artifact.path),
|
|
327
|
+
...artifact.size !== void 0 ? { byteLength: artifact.size } : {},
|
|
328
|
+
...stepIndex >= 0 ? { stepIndex, stepId: `step-${stepIndex}` } : {}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { attachments, docs };
|
|
332
|
+
}
|
|
333
|
+
function finalAttempt(result, report) {
|
|
334
|
+
if (result.serialGroupId === void 0) {
|
|
335
|
+
return { attempt: result.attempts.at(-1), count: result.attempts.length };
|
|
336
|
+
}
|
|
337
|
+
const attempts = report.run.serialGroups.find((g) => g.id === result.serialGroupId)?.attempts ?? [];
|
|
338
|
+
const last = attempts.at(-1);
|
|
339
|
+
const member = last?.members.find((m) => m.testId === result.testId);
|
|
340
|
+
if (last === void 0) return { count: 0 };
|
|
341
|
+
return {
|
|
342
|
+
attempt: {
|
|
343
|
+
error: member?.error ?? last.error,
|
|
344
|
+
startedAt: member?.startedAt ?? last.startedAt,
|
|
345
|
+
durationMs: member?.durationMs ?? last.durationMs,
|
|
346
|
+
artifacts: last.artifacts,
|
|
347
|
+
steps: member?.steps ?? []
|
|
348
|
+
},
|
|
349
|
+
count: attempts.length
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function markersFor(result, attempt, report, markers, projectRoot) {
|
|
353
|
+
if (attempt === void 0 || markers.length === 0) return [];
|
|
354
|
+
const file = path2.resolve(projectRoot, result.file);
|
|
355
|
+
const from = result.source.line;
|
|
356
|
+
const to = Math.min(
|
|
357
|
+
...report.run.results.filter((r) => r.file === result.file && r.source.line > from).map((r) => r.source.line)
|
|
358
|
+
);
|
|
359
|
+
const start = Date.parse(attempt.startedAt);
|
|
360
|
+
const end = start + attempt.durationMs + 1e3;
|
|
361
|
+
return markers.filter(
|
|
362
|
+
(m) => m.file === file && m.line >= from && m.line < to && m.at >= start && m.at <= end
|
|
363
|
+
).sort((a, b) => a.at - b.at);
|
|
364
|
+
}
|
|
365
|
+
function toTestCase(result, report, artifactsRoot, markers, projectRoot) {
|
|
366
|
+
const scenario = result.titlePath.at(-1) ?? result.testId;
|
|
367
|
+
const suitePath = result.titlePath.slice(0, -1);
|
|
368
|
+
const { attempt, count } = finalAttempt(result, report);
|
|
369
|
+
const steps = attempt?.steps ?? [];
|
|
370
|
+
const groups = toGroups(
|
|
371
|
+
steps,
|
|
372
|
+
markersFor(result, attempt, report, markers, projectRoot),
|
|
373
|
+
projectRoot
|
|
374
|
+
);
|
|
375
|
+
const { attachments, docs } = toMedia(
|
|
376
|
+
{ artifacts: attempt?.artifacts ?? [], steps },
|
|
377
|
+
groups,
|
|
378
|
+
artifactsRoot
|
|
379
|
+
);
|
|
380
|
+
const error = attempt?.error;
|
|
381
|
+
const details = error?.details;
|
|
382
|
+
const stepEvents = groups.map((group, index) => {
|
|
383
|
+
const failed = group.children.find((c) => c.error !== void 0);
|
|
384
|
+
return {
|
|
385
|
+
index,
|
|
386
|
+
stepId: `step-${index}`,
|
|
387
|
+
title: group.text,
|
|
388
|
+
status: groupStatus(group),
|
|
389
|
+
durationMs: group.children.reduce((sum, c) => sum + c.durationMs, 0),
|
|
390
|
+
...failed?.error ? { errorMessage: errorMessage(failed.error) } : {}
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
externalId: result.id,
|
|
395
|
+
title: scenario,
|
|
396
|
+
titlePath: [...result.titlePath],
|
|
397
|
+
story: {
|
|
398
|
+
scenario,
|
|
399
|
+
steps: toSteps(groups),
|
|
400
|
+
...result.tags.length > 0 ? { tags: [...result.tags] } : {},
|
|
401
|
+
...suitePath.length > 0 ? { suitePath } : {},
|
|
402
|
+
...docs.length > 0 ? { docs } : {}
|
|
403
|
+
},
|
|
404
|
+
sourceFile: result.file,
|
|
405
|
+
sourceLine: Math.max(1, result.source.line),
|
|
406
|
+
status: resultStatus(result.status),
|
|
407
|
+
durationMs: attempt?.durationMs ?? 0,
|
|
408
|
+
...error ? {
|
|
409
|
+
error: {
|
|
410
|
+
message: errorMessage(error),
|
|
411
|
+
...details?.expected !== void 0 ? { expected: details.expected } : {},
|
|
412
|
+
...details?.observed !== void 0 ? { actual: details.observed } : {}
|
|
413
|
+
}
|
|
414
|
+
} : {},
|
|
415
|
+
...stepEvents.length > 0 ? { stepEvents } : {},
|
|
416
|
+
...attachments.length > 0 ? { attachments } : {},
|
|
417
|
+
retry: Math.max(0, count - 1),
|
|
418
|
+
projectName: result.targetId
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function toRawRun(args) {
|
|
422
|
+
const { report, projectRoot, artifactsRoot } = args;
|
|
423
|
+
const results = report.run.results.filter((r) => r.selected !== false);
|
|
424
|
+
const filtered = args.lastRun !== void 0 || results.length < report.run.results.length;
|
|
425
|
+
return {
|
|
426
|
+
testCases: results.map(
|
|
427
|
+
(r) => toTestCase(r, report, artifactsRoot, args.markers ?? [], projectRoot)
|
|
428
|
+
),
|
|
429
|
+
startedAtMs: Date.parse(report.run.startedAt),
|
|
430
|
+
finishedAtMs: Date.parse(report.run.finishedAt),
|
|
431
|
+
projectRoot,
|
|
432
|
+
// Only "filtered" is claimed: e2e can narrow a run in ways the report
|
|
433
|
+
// does not record, so "full" (which retires scenarios) is never guessed.
|
|
434
|
+
...filtered ? { runScope: "filtered" } : {},
|
|
435
|
+
coveredSourceFiles: [...new Set(results.map((r) => r.file))].sort(),
|
|
436
|
+
...report.run.vcs?.commit ? { gitSha: report.run.vcs.commit } : {}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
async function readMarkers(dir) {
|
|
440
|
+
const files = await readdir(dir).catch(() => []);
|
|
441
|
+
const markers = [];
|
|
442
|
+
for (const file of files.filter((f) => f.endsWith(".jsonl"))) {
|
|
443
|
+
for (const line of (await readFile(path2.join(dir, file), "utf8")).split(
|
|
444
|
+
"\n"
|
|
445
|
+
)) {
|
|
446
|
+
if (!line) continue;
|
|
447
|
+
try {
|
|
448
|
+
markers.push(JSON.parse(line));
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return markers;
|
|
454
|
+
}
|
|
455
|
+
function storyReporter(options = {}) {
|
|
456
|
+
return {
|
|
457
|
+
name: "executable-stories",
|
|
458
|
+
onEvent(event) {
|
|
459
|
+
if (event.type === "run-started")
|
|
460
|
+
rmSync(path2.resolve(event.projectRoot, MARKERS_DIR), {
|
|
461
|
+
recursive: true,
|
|
462
|
+
force: true
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
async onRunFinished(run) {
|
|
466
|
+
const markersDir = path2.resolve(run.projectRoot, MARKERS_DIR);
|
|
467
|
+
if (run.report.run.explore !== void 0) {
|
|
468
|
+
await rm(markersDir, { recursive: true, force: true });
|
|
469
|
+
return [{ label: "Stories", text: "skipped (explore run)" }];
|
|
470
|
+
}
|
|
471
|
+
const rawRun = toRawRun({
|
|
472
|
+
report: run.report,
|
|
473
|
+
projectRoot: run.projectRoot,
|
|
474
|
+
artifactsRoot: run.artifactsRoot,
|
|
475
|
+
markers: await readMarkers(markersDir),
|
|
476
|
+
...run.lastRun ? { lastRun: run.lastRun } : {}
|
|
477
|
+
});
|
|
478
|
+
await rm(markersDir, { recursive: true, force: true });
|
|
479
|
+
if (rawRun.testCases.length === 0) {
|
|
480
|
+
return [{ label: "Stories", text: "skipped (no tests ran)" }];
|
|
481
|
+
}
|
|
482
|
+
const target = path2.resolve(
|
|
483
|
+
run.projectRoot,
|
|
484
|
+
options.rawRunPath ?? ".executable-stories/raw-run.json"
|
|
485
|
+
);
|
|
486
|
+
await mkdir(path2.dirname(target), { recursive: true });
|
|
487
|
+
await writeFile(
|
|
488
|
+
target,
|
|
489
|
+
JSON.stringify({ schemaVersion: 1, ...rawRun }, null, 2),
|
|
490
|
+
"utf8"
|
|
491
|
+
);
|
|
492
|
+
return [
|
|
493
|
+
{ label: "Stories", text: path2.relative(run.projectRoot, target) }
|
|
494
|
+
];
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
export {
|
|
499
|
+
phraseStep,
|
|
500
|
+
story,
|
|
501
|
+
storyReporter,
|
|
502
|
+
toRawRun
|
|
503
|
+
};
|
|
504
|
+
//# sourceMappingURL=index.js.map
|