@expo/build-tools 22.0.0 → 22.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.
- package/dist/builders/android.js +2 -2
- package/dist/builders/ios.js +2 -2
- package/dist/steps/easFunctions.js +6 -0
- package/dist/steps/functions/downloadBuild.d.ts +9 -2
- package/dist/steps/functions/downloadBuild.js +83 -14
- package/dist/steps/functions/installBuild.d.ts +12 -0
- package/dist/steps/functions/installBuild.js +85 -0
- package/dist/steps/functions/installMaestro.d.ts +6 -1
- package/dist/steps/functions/installMaestro.js +107 -33
- package/dist/steps/functions/launchApplication.d.ts +10 -0
- package/dist/steps/functions/launchApplication.js +67 -0
- package/dist/steps/functions/maestroResultParser.d.ts +30 -0
- package/dist/steps/functions/maestroResultParser.js +78 -5
- package/dist/steps/functions/maestroScreenshots.d.ts +6 -0
- package/dist/steps/functions/maestroScreenshots.js +123 -0
- package/dist/steps/functions/maestroTests.js +187 -69
- package/dist/steps/functions/readIpaInfo.d.ts +1 -0
- package/dist/steps/functions/readIpaInfo.js +2 -0
- package/dist/steps/functions/repack.d.ts +5 -2
- package/dist/steps/functions/repack.js +50 -2
- package/dist/steps/functions/restoreBuildCache.d.ts +3 -3
- package/dist/steps/functions/restoreBuildCache.js +21 -7
- package/dist/steps/functions/saveBuildCache.d.ts +3 -3
- package/dist/steps/functions/saveBuildCache.js +21 -6
- package/dist/steps/functions/saveCache.js +3 -0
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +7 -0
- package/dist/steps/functions/startAppiumRemoteSession.d.ts +17 -0
- package/dist/steps/functions/startAppiumRemoteSession.js +244 -0
- package/dist/steps/functions/startArgentRemoteSession.js +7 -0
- package/dist/steps/functions/startServeSimRemoteSession.js +17 -1
- package/dist/steps/functions/uploadToAsc.js +11 -1
- package/dist/steps/utils/appiumEvents.d.ts +10 -0
- package/dist/steps/utils/appiumEvents.js +175 -0
- package/dist/steps/utils/ios/AscApiClient.d.ts +1 -0
- package/dist/steps/utils/ios/AscApiUtils.d.ts +11 -2
- package/dist/steps/utils/ios/AscApiUtils.js +36 -2
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +6 -3
- package/dist/steps/utils/remoteDeviceRunSession.js +81 -52
- package/dist/utils/AndroidEmulatorUtils.d.ts +1 -0
- package/dist/utils/AndroidEmulatorUtils.js +46 -4
- package/dist/utils/cacheKey.d.ts +8 -2
- package/dist/utils/cacheKey.js +12 -8
- package/package.json +6 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export interface MaestroFlowResult {
|
|
2
3
|
name: string;
|
|
3
4
|
path: string;
|
|
@@ -23,6 +24,34 @@ export declare function isFileAttrRun(testcases: JUnitTestCaseResult[]): testcas
|
|
|
23
24
|
file: string;
|
|
24
25
|
})[];
|
|
25
26
|
export declare function junitFileHasFileAttrs(junitFile: string): Promise<boolean>;
|
|
27
|
+
declare const MaestroRunnerReportSchema: z.ZodObject<{
|
|
28
|
+
flows: z.ZodPipe<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
29
|
+
name: z.ZodString;
|
|
30
|
+
sourceFile: z.ZodString;
|
|
31
|
+
status: z.ZodEnum<{
|
|
32
|
+
failed: "failed";
|
|
33
|
+
passed: "passed";
|
|
34
|
+
}>;
|
|
35
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
36
|
+
status: z.ZodLiteral<"skipped">;
|
|
37
|
+
}, z.core.$strip>], "status">>, z.ZodTransform<{
|
|
38
|
+
name: string;
|
|
39
|
+
sourceFile: string;
|
|
40
|
+
status: "failed" | "passed";
|
|
41
|
+
}[], ({
|
|
42
|
+
name: string;
|
|
43
|
+
sourceFile: string;
|
|
44
|
+
status: "failed" | "passed";
|
|
45
|
+
} | {
|
|
46
|
+
status: "skipped";
|
|
47
|
+
})[]>>;
|
|
48
|
+
}, z.core.$strip>;
|
|
49
|
+
type MaestroRunnerReport = z.infer<typeof MaestroRunnerReportSchema>;
|
|
50
|
+
export declare function parseFailedFlowsFromMaestroRunnerReport(args: {
|
|
51
|
+
reportDirectory: string;
|
|
52
|
+
workingDirectory: string;
|
|
53
|
+
}): Promise<string[] | null>;
|
|
54
|
+
export declare function parseMaestroRunnerReport(reportDirectory: string): Promise<MaestroRunnerReport | null>;
|
|
26
55
|
export declare function parseMaestroResultsFromFileAttrs(junitDirectory: string): Promise<MaestroFlowResult[]>;
|
|
27
56
|
/**
|
|
28
57
|
* Returns the `file=` paths of the failing testcases in the given attempt's
|
|
@@ -75,3 +104,4 @@ export declare function copyLatestAttemptXml(args: {
|
|
|
75
104
|
sourceDir: string;
|
|
76
105
|
outputPath: string;
|
|
77
106
|
}): Promise<void>;
|
|
107
|
+
export {};
|
|
@@ -7,6 +7,8 @@ exports.parseFailedFlowNamesFromJUnitFile = parseFailedFlowNamesFromJUnitFile;
|
|
|
7
7
|
exports.parseJUnitTestCases = parseJUnitTestCases;
|
|
8
8
|
exports.isFileAttrRun = isFileAttrRun;
|
|
9
9
|
exports.junitFileHasFileAttrs = junitFileHasFileAttrs;
|
|
10
|
+
exports.parseFailedFlowsFromMaestroRunnerReport = parseFailedFlowsFromMaestroRunnerReport;
|
|
11
|
+
exports.parseMaestroRunnerReport = parseMaestroRunnerReport;
|
|
10
12
|
exports.parseMaestroResultsFromFileAttrs = parseMaestroResultsFromFileAttrs;
|
|
11
13
|
exports.parseFailedFlowsFromFileAttrs = parseFailedFlowsFromFileAttrs;
|
|
12
14
|
exports.parseMaestroResults = parseMaestroResults;
|
|
@@ -17,6 +19,7 @@ const results_1 = require("@expo/results");
|
|
|
17
19
|
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
18
20
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
19
21
|
const path_1 = __importDefault(require("path"));
|
|
22
|
+
const zod_1 = require("zod");
|
|
20
23
|
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
|
21
24
|
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
|
22
25
|
const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
@@ -25,10 +28,16 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
|
25
28
|
// Ensure single-element arrays are always arrays
|
|
26
29
|
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
|
27
30
|
});
|
|
28
|
-
//
|
|
31
|
+
// Official Maestro writes the flow path as a `file=` testcase attribute. maestro-runner writes
|
|
32
|
+
// the same value as a `<property name="file" value="..."/>` child.
|
|
29
33
|
function fileAttrOf(tc) {
|
|
30
34
|
const f = tc?.['@_file'];
|
|
31
|
-
|
|
35
|
+
if (typeof f === 'string' && f.length > 0) {
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
const properties = tc?.properties?.property ?? [];
|
|
39
|
+
const fileProperty = properties.find(property => property['@_name'] === 'file')?.['@_value'];
|
|
40
|
+
return typeof fileProperty === 'string' && fileProperty.length > 0 ? fileProperty : undefined;
|
|
32
41
|
}
|
|
33
42
|
function parseJUnitContent(content) {
|
|
34
43
|
const results = [];
|
|
@@ -48,22 +57,40 @@ function parseJUnitContent(content) {
|
|
|
48
57
|
if (!name) {
|
|
49
58
|
continue;
|
|
50
59
|
}
|
|
60
|
+
// Standard JUnit marks skipped tests with a <skipped/> child (no failure/error). Exclude
|
|
61
|
+
// them so they aren't miscounted as passed, matching the report.json path which drops
|
|
62
|
+
// skipped flows.
|
|
63
|
+
if (tc.skipped != null) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
51
66
|
const file = fileAttrOf(tc);
|
|
52
67
|
const timeStr = tc['@_time'];
|
|
53
68
|
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
|
54
69
|
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
|
55
|
-
|
|
70
|
+
// maestro-runner puts the real error in the `message` attribute and the command
|
|
71
|
+
// label (e.g. `tapOn`) in the body; official Maestro only writes the body. Prefer
|
|
72
|
+
// `@_message` and fall back to `#text` so both stay correct.
|
|
56
73
|
const failureText = tc.failure != null
|
|
57
74
|
? typeof tc.failure === 'string'
|
|
58
75
|
? tc.failure
|
|
59
|
-
: (tc.failure?.['#text'] ?? null)
|
|
76
|
+
: (tc.failure?.['@_message'] ?? tc.failure?.['#text'] ?? null)
|
|
60
77
|
: null;
|
|
61
78
|
const errorText = tc.error != null
|
|
62
79
|
? typeof tc.error === 'string'
|
|
63
80
|
? tc.error
|
|
64
|
-
: (tc.error?.['#text'] ?? null)
|
|
81
|
+
: (tc.error?.['@_message'] ?? tc.error?.['#text'] ?? null)
|
|
65
82
|
: null;
|
|
66
83
|
const errorMessage = failureText ?? errorText ?? null;
|
|
84
|
+
// Official Maestro uses status="SUCCESS". maestro-runner uses standard JUnit semantics:
|
|
85
|
+
// a testcase passes when it has no failure or error child.
|
|
86
|
+
const statusAttribute = tc['@_status'];
|
|
87
|
+
const status = typeof statusAttribute === 'string'
|
|
88
|
+
? statusAttribute === 'SUCCESS'
|
|
89
|
+
? 'passed'
|
|
90
|
+
: 'failed'
|
|
91
|
+
: tc.failure == null && tc.error == null
|
|
92
|
+
? 'passed'
|
|
93
|
+
: 'failed';
|
|
67
94
|
const rawProperties = tc.properties?.property ?? [];
|
|
68
95
|
const properties = {};
|
|
69
96
|
for (const prop of rawProperties) {
|
|
@@ -143,6 +170,52 @@ async function junitFileHasFileAttrs(junitFile) {
|
|
|
143
170
|
async function fileExists(absPath) {
|
|
144
171
|
return (await (0, results_1.asyncResult)(promises_1.default.stat(absPath))).ok;
|
|
145
172
|
}
|
|
173
|
+
// maestro-runner writes report.json as the source of truth for a run. Use it for runner control
|
|
174
|
+
// flow because sourceFile preserves the exact flow path, while older runner JUnit reports flatten
|
|
175
|
+
// it to a basename. Keep only passed and failed flows so the result matches Maestro JUnit reports,
|
|
176
|
+
// which do not include skipped flows.
|
|
177
|
+
const MaestroRunnerRecordedFlowSchema = zod_1.z.object({
|
|
178
|
+
name: zod_1.z.string().min(1),
|
|
179
|
+
sourceFile: zod_1.z.string().min(1),
|
|
180
|
+
status: zod_1.z.enum(['passed', 'failed']),
|
|
181
|
+
});
|
|
182
|
+
const MaestroRunnerReportSchema = zod_1.z.object({
|
|
183
|
+
flows: zod_1.z
|
|
184
|
+
.array(zod_1.z.discriminatedUnion('status', [
|
|
185
|
+
MaestroRunnerRecordedFlowSchema,
|
|
186
|
+
zod_1.z.object({ status: zod_1.z.literal('skipped') }),
|
|
187
|
+
]))
|
|
188
|
+
.transform(flows => flows.filter((flow) => flow.status !== 'skipped')),
|
|
189
|
+
});
|
|
190
|
+
async function parseFailedFlowsFromMaestroRunnerReport(args) {
|
|
191
|
+
const report = await parseMaestroRunnerReport(args.reportDirectory);
|
|
192
|
+
if (report === null) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const failedPaths = [
|
|
196
|
+
...new Set(report.flows.filter(flow => flow.status === 'failed').map(flow => flow.sourceFile)),
|
|
197
|
+
];
|
|
198
|
+
if (failedPaths.length === 0) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
for (const flowPath of failedPaths) {
|
|
202
|
+
if (!(await fileExists(path_1.default.resolve(args.workingDirectory, flowPath)))) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return failedPaths;
|
|
207
|
+
}
|
|
208
|
+
async function parseMaestroRunnerReport(reportDirectory) {
|
|
209
|
+
let report;
|
|
210
|
+
try {
|
|
211
|
+
report = JSON.parse(await promises_1.default.readFile(path_1.default.join(reportDirectory, 'report.json'), 'utf8'));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const result = MaestroRunnerReportSchema.safeParse(report);
|
|
217
|
+
return result.success ? result.data : null;
|
|
218
|
+
}
|
|
146
219
|
// Group by `file=` so two same-named flows in different files stay separate.
|
|
147
220
|
async function parseMaestroResultsFromFileAttrs(junitDirectory) {
|
|
148
221
|
let junitEntries;
|
|
@@ -21,6 +21,12 @@ export declare function harvestFailureScreenshotsAsync(args: {
|
|
|
21
21
|
failedFlowNames: ReadonlySet<string>;
|
|
22
22
|
logger: bunyan;
|
|
23
23
|
}): Promise<HarvestedScreenshot[]>;
|
|
24
|
+
export declare function harvestMaestroRunnerFailureScreenshotsAsync(args: {
|
|
25
|
+
reportDirectory: string;
|
|
26
|
+
capturedSinceMs: number;
|
|
27
|
+
attemptIndex: number;
|
|
28
|
+
logger: bunyan;
|
|
29
|
+
}): Promise<HarvestedScreenshot[]>;
|
|
24
30
|
export declare function computePureFailureFlowNames(testCases: readonly {
|
|
25
31
|
name: string;
|
|
26
32
|
status: 'passed' | 'failed';
|
|
@@ -5,10 +5,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.parseFailureScreenshotFilename = parseFailureScreenshotFilename;
|
|
7
7
|
exports.harvestFailureScreenshotsAsync = harvestFailureScreenshotsAsync;
|
|
8
|
+
exports.harvestMaestroRunnerFailureScreenshotsAsync = harvestMaestroRunnerFailureScreenshotsAsync;
|
|
8
9
|
exports.computePureFailureFlowNames = computePureFailureFlowNames;
|
|
9
10
|
exports.selectFailureScreenshots = selectFailureScreenshots;
|
|
10
11
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
12
|
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const zod_1 = require("zod");
|
|
14
|
+
const sentry_1 = require("../../sentry");
|
|
12
15
|
// pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
|
|
13
16
|
// `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
|
|
14
17
|
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
|
|
@@ -109,6 +112,126 @@ async function harvestFailureScreenshotsAsync(args) {
|
|
|
109
112
|
// `<flow>-2` collision to collapse (and deduping them would change historical behavior).
|
|
110
113
|
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
|
111
114
|
}
|
|
115
|
+
const MaestroRunnerCommandSchema = zod_1.z.object({
|
|
116
|
+
status: zod_1.z.string().optional(),
|
|
117
|
+
artifacts: zod_1.z
|
|
118
|
+
.object({
|
|
119
|
+
screenshotBefore: zod_1.z.string().optional(),
|
|
120
|
+
screenshotAfter: zod_1.z.string().optional(),
|
|
121
|
+
})
|
|
122
|
+
.optional(),
|
|
123
|
+
get subCommands() {
|
|
124
|
+
return zod_1.z.array(MaestroRunnerCommandSchema).optional();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const MaestroRunnerFlowDetailSchema = zod_1.z.object({
|
|
128
|
+
commands: zod_1.z.array(MaestroRunnerCommandSchema).optional(),
|
|
129
|
+
});
|
|
130
|
+
const MaestroRunnerScreenshotReportSchema = zod_1.z.object({
|
|
131
|
+
flows: zod_1.z
|
|
132
|
+
.array(zod_1.z.object({
|
|
133
|
+
name: zod_1.z.string().optional(),
|
|
134
|
+
status: zod_1.z.string().optional(),
|
|
135
|
+
dataFile: zod_1.z.string().optional(),
|
|
136
|
+
}))
|
|
137
|
+
.optional(),
|
|
138
|
+
});
|
|
139
|
+
// maestro-runner records failure screenshot paths in its report JSON instead of using the
|
|
140
|
+
// official Maestro debug-directory layout. Never throws, so screenshots cannot change the test
|
|
141
|
+
// result.
|
|
142
|
+
async function harvestMaestroRunnerFailureScreenshotsAsync(args) {
|
|
143
|
+
let report;
|
|
144
|
+
try {
|
|
145
|
+
report = JSON.parse(await promises_1.default.readFile(path_1.default.join(args.reportDirectory, 'report.json'), 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
args.logger.info({ err }, `Skipping maestro-runner screenshot harvest: cannot read ${args.reportDirectory}.`);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const parsedReport = MaestroRunnerScreenshotReportSchema.safeParse(report);
|
|
152
|
+
if (!parsedReport.success) {
|
|
153
|
+
args.logger.warn({ err: parsedReport.error }, `Skipping maestro-runner screenshot harvest: unexpected report.json shape in ${args.reportDirectory}.`);
|
|
154
|
+
sentry_1.Sentry.capture('maestro-runner report.json failed schema validation', parsedReport.error);
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const flows = parsedReport.data.flows ?? [];
|
|
158
|
+
const shots = [];
|
|
159
|
+
for (const { name, status, dataFile } of flows) {
|
|
160
|
+
if (status !== 'failed' || !name || !dataFile) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const flowDataPath = resolvePathInsideDirectory(args.reportDirectory, dataFile);
|
|
164
|
+
if (!flowDataPath) {
|
|
165
|
+
args.logger.info(`Skipping maestro-runner flow data outside the report directory.`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let screenshotPath;
|
|
169
|
+
try {
|
|
170
|
+
const detail = MaestroRunnerFlowDetailSchema.safeParse(JSON.parse(await promises_1.default.readFile(flowDataPath, 'utf8')));
|
|
171
|
+
if (!detail.success) {
|
|
172
|
+
args.logger.warn({ err: detail.error }, `Skipping malformed maestro-runner flow data ${flowDataPath}.`);
|
|
173
|
+
sentry_1.Sentry.capture('maestro-runner flow data failed schema validation', detail.error);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
screenshotPath = findFailedMaestroRunnerScreenshot(detail.data.commands ?? []);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
args.logger.info({ err }, `Skipping unreadable maestro-runner flow data ${flowDataPath}.`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!screenshotPath) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const fileAbsPath = resolvePathInsideDirectory(args.reportDirectory, screenshotPath);
|
|
186
|
+
if (!fileAbsPath) {
|
|
187
|
+
args.logger.info(`Skipping maestro-runner screenshot outside the report directory.`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
let capturedAtMs;
|
|
191
|
+
try {
|
|
192
|
+
capturedAtMs = Math.round((await promises_1.default.stat(fileAbsPath)).mtimeMs);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (capturedAtMs < args.capturedSinceMs) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const flowName = normalizeFlowName(name);
|
|
202
|
+
shots.push({
|
|
203
|
+
fileAbsPath,
|
|
204
|
+
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
|
205
|
+
metadata: {
|
|
206
|
+
kind: 'maestro-test-screenshot',
|
|
207
|
+
flowName,
|
|
208
|
+
attemptIndex: args.attemptIndex,
|
|
209
|
+
capturedAtMs,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return shots;
|
|
214
|
+
}
|
|
215
|
+
function findFailedMaestroRunnerScreenshot(commands) {
|
|
216
|
+
for (const command of commands) {
|
|
217
|
+
if (command.status !== 'failed') {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const nested = findFailedMaestroRunnerScreenshot(command.subCommands ?? []);
|
|
221
|
+
const screenshot = nested ?? command.artifacts?.screenshotAfter ?? command.artifacts?.screenshotBefore;
|
|
222
|
+
if (screenshot) {
|
|
223
|
+
return screenshot;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
function resolvePathInsideDirectory(directory, relativePath) {
|
|
229
|
+
const candidate = path_1.default.resolve(directory, relativePath);
|
|
230
|
+
const relative = path_1.default.relative(directory, candidate);
|
|
231
|
+
return relative !== '..' && !relative.startsWith(`..${path_1.default.sep}`) && !path_1.default.isAbsolute(relative)
|
|
232
|
+
? candidate
|
|
233
|
+
: null;
|
|
234
|
+
}
|
|
112
235
|
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
|
113
236
|
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
|
114
237
|
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|