@expo/build-tools 22.0.0 → 22.2.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.
@@ -43,6 +43,7 @@ const sendSlackMessage_1 = require("./functions/sendSlackMessage");
43
43
  const startAgentDeviceRemoteSession_1 = require("./functions/startAgentDeviceRemoteSession");
44
44
  const startAndroidEmulator_1 = require("./functions/startAndroidEmulator");
45
45
  const startArgentRemoteSession_1 = require("./functions/startArgentRemoteSession");
46
+ const startAppiumRemoteSession_1 = require("./functions/startAppiumRemoteSession");
46
47
  const startCuttlefishDevice_1 = require("./functions/startCuttlefishDevice");
47
48
  const startIosSimulator_1 = require("./functions/startIosSimulator");
48
49
  const startIosSimulatorRecordings_1 = require("./functions/startIosSimulatorRecordings");
@@ -90,6 +91,7 @@ function getEasFunctions(ctx) {
90
91
  (0, parseXcactivitylog_1.parseXcactivitylogFunction)(),
91
92
  (0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(ctx),
92
93
  (0, startArgentRemoteSession_1.createStartArgentRemoteSessionBuildFunction)(ctx),
94
+ (0, startAppiumRemoteSession_1.createStartAppiumRemoteSessionBuildFunction)(ctx),
93
95
  (0, startAndroidEmulator_1.createStartAndroidEmulatorBuildFunction)(),
94
96
  (0, startCuttlefishDevice_1.createStartCuttlefishDeviceBuildFunction)(),
95
97
  (0, startIosSimulator_1.createStartIosSimulatorBuildFunction)(),
@@ -1,2 +1,7 @@
1
- import { BuildFunction } from '@expo/steps';
1
+ import { bunyan } from '@expo/logger';
2
+ import { BuildFunction, BuildStepEnv } from '@expo/steps';
2
3
  export declare function createInstallMaestroBuildFunction(): BuildFunction;
4
+ export declare function installIdbFromBrew({ logger, env, }: {
5
+ logger: bunyan;
6
+ env: BuildStepEnv;
7
+ }): Promise<void>;
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createInstallMaestroBuildFunction = createInstallMaestroBuildFunction;
7
+ exports.installIdbFromBrew = installIdbFromBrew;
8
+ const eas_build_job_1 = require("@expo/eas-build-job");
7
9
  const results_1 = require("@expo/results");
8
10
  const steps_1 = require("@expo/steps");
9
11
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
@@ -11,6 +13,7 @@ const assert_1 = __importDefault(require("assert"));
11
13
  const fs_1 = __importDefault(require("fs"));
12
14
  const os_1 = __importDefault(require("os"));
13
15
  const path_1 = __importDefault(require("path"));
16
+ const semver_1 = __importDefault(require("semver"));
14
17
  const maestroBackend_1 = require("./maestroBackend");
15
18
  const datadog_1 = require("../../datadog");
16
19
  function createInstallMaestroBuildFunction() {
@@ -94,17 +97,49 @@ function createInstallMaestroBuildFunction() {
94
97
  logger.info('Installing IDB');
95
98
  await installIdbFromBrew({ logger, env });
96
99
  }
97
- // Skip installing if the input sets a specific Maestro version to install
98
- // and it is already installed which happens when developing on a local computer.
99
- if (!currentMaestroVersion ||
100
- (requestedVersion && requestedVersion !== currentMaestroVersion)) {
101
- switch (backend) {
102
- case 'maestro':
100
+ switch (backend) {
101
+ case 'maestro':
102
+ // Skip installing if the input sets a specific Maestro version to install
103
+ // and it is already installed, either on a build image or a local computer.
104
+ if (!currentMaestroVersion ||
105
+ (requestedVersion && requestedVersion !== currentMaestroVersion)) {
103
106
  await installMaestro({ version: requestedVersion, global, logger, env });
104
- break;
105
- case 'maestro-runner':
106
- await installMaestroRunner({ version: requestedVersion, global, logger, env });
107
- break;
107
+ }
108
+ break;
109
+ case 'maestro-runner': {
110
+ let maestroRunnerVersionToInstall = requestedVersion;
111
+ const currentMaestroRunnerVersion = semver_1.default.coerce(currentMaestroVersion)?.version;
112
+ const requestedMaestroRunnerVersion = requestedVersion && requestedVersion !== 'latest'
113
+ ? semver_1.default.valid(requestedVersion)
114
+ : null;
115
+ if (global.runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN &&
116
+ ((requestedMaestroRunnerVersion &&
117
+ semver_1.default.gte(requestedMaestroRunnerVersion, '1.1.16')) ||
118
+ requestedVersion === 'latest' ||
119
+ (requestedVersion === undefined &&
120
+ (!currentMaestroRunnerVersion || semver_1.default.gt(currentMaestroRunnerVersion, '1.1.15'))))) {
121
+ const xcodeVersion = await getXcodeVersion({ env });
122
+ // maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
123
+ // below 26 reject that option, so use the last compatible maestro-runner version.
124
+ if (semver_1.default.lt(xcodeVersion, '26.0.0')) {
125
+ if (requestedMaestroRunnerVersion) {
126
+ throw new eas_build_job_1.UserError('ERR_MAESTRO_INVALID_INPUT', `maestro-runner ${requestedVersion} is not compatible with Xcode ${xcodeVersion}. Use maestro-runner 1.1.15 or an Xcode 26+ image.`);
127
+ }
128
+ maestroRunnerVersionToInstall = '1.1.15';
129
+ logger.info(`Xcode ${xcodeVersion} requires maestro-runner ${maestroRunnerVersionToInstall}.`);
130
+ }
131
+ }
132
+ if (!currentMaestroVersion ||
133
+ (maestroRunnerVersionToInstall &&
134
+ maestroRunnerVersionToInstall !== currentMaestroVersion)) {
135
+ await installMaestroRunner({
136
+ version: maestroRunnerVersionToInstall,
137
+ global,
138
+ logger,
139
+ env,
140
+ });
141
+ }
142
+ break;
108
143
  }
109
144
  }
110
145
  const maestroVersionResult = await (0, results_1.asyncResult)(getMaestroVersion({ env, backend }));
@@ -121,6 +156,20 @@ function createInstallMaestroBuildFunction() {
121
156
  },
122
157
  });
123
158
  }
159
+ async function getXcodeVersion({ env }) {
160
+ let stdout;
161
+ try {
162
+ ({ stdout } = await (0, turtle_spawn_1.default)('xcodebuild', ['-version'], { stdio: 'pipe', env }));
163
+ }
164
+ catch (error) {
165
+ throw new eas_build_job_1.SystemError('Failed to get Xcode version', { cause: error });
166
+ }
167
+ const xcodeVersion = semver_1.default.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
168
+ if (!xcodeVersion) {
169
+ throw new eas_build_job_1.SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
170
+ }
171
+ return xcodeVersion;
172
+ }
124
173
  async function getMaestroVersion({ env, backend, }) {
125
174
  switch (backend) {
126
175
  case 'maestro': {
@@ -156,10 +205,10 @@ async function installMaestroRunner({ global, version, logger, env, }) {
156
205
  const binDir = path_1.default.join(env.HOME, '.maestro-runner', 'bin');
157
206
  global.updateEnv({
158
207
  ...global.env,
159
- PATH: `${global.env.PATH}:${binDir}`,
208
+ PATH: `${binDir}:${global.env.PATH}`,
160
209
  });
161
- env.PATH = `${env.PATH}:${binDir}`;
162
- process.env.PATH = `${process.env.PATH}:${binDir}`;
210
+ env.PATH = `${binDir}:${env.PATH}`;
211
+ process.env.PATH = `${binDir}:${process.env.PATH}`;
163
212
  }
164
213
  finally {
165
214
  await fs_1.default.promises.rm(tempDirectory, { force: true, recursive: true });
@@ -193,10 +242,10 @@ async function installMaestro({ global, version, logger, env, }) {
193
242
  const maestroBinDir = path_1.default.join(maestroDir, 'bin');
194
243
  global.updateEnv({
195
244
  ...global.env,
196
- PATH: `${global.env.PATH}:${maestroBinDir}`,
245
+ PATH: `${maestroBinDir}:${global.env.PATH}`,
197
246
  });
198
- env.PATH = `${env.PATH}:${maestroBinDir}`;
199
- process.env.PATH = `${process.env.PATH}:${maestroBinDir}`;
247
+ env.PATH = `${maestroBinDir}:${env.PATH}`;
248
+ process.env.PATH = `${maestroBinDir}:${process.env.PATH}`;
200
249
  }
201
250
  finally {
202
251
  await fs_1.default.promises.rm(tempDirectory, { force: true, recursive: true });
@@ -204,7 +253,7 @@ async function installMaestro({ global, version, logger, env, }) {
204
253
  }
205
254
  async function isIdbInstalled({ env }) {
206
255
  try {
207
- await (0, turtle_spawn_1.default)('idb', ['-h'], { ignoreStdio: true, env });
256
+ await (0, turtle_spawn_1.default)('idb_companion', ['--version'], { ignoreStdio: true, env });
208
257
  return true;
209
258
  }
210
259
  catch {
@@ -212,22 +261,47 @@ async function isIdbInstalled({ env }) {
212
261
  }
213
262
  }
214
263
  async function installIdbFromBrew({ logger, env, }) {
215
- // Unfortunately our Mac images sometimes have two Homebrew
216
- // installations. We should use the ARM64 one, located in /opt/homebrew.
217
- const brewPath = '/opt/homebrew/bin/brew';
218
- const localEnv = {
219
- ...env,
220
- HOMEBREW_NO_AUTO_UPDATE: '1',
221
- HOMEBREW_NO_INSTALL_CLEANUP: '1',
222
- };
223
- await (0, turtle_spawn_1.default)(brewPath, ['tap', 'facebook/fb'], {
224
- env: localEnv,
225
- logger,
226
- });
227
- await (0, turtle_spawn_1.default)(brewPath, ['install', 'idb-companion'], {
228
- env: localEnv,
229
- logger,
230
- });
264
+ try {
265
+ // Unfortunately our Mac images sometimes have two Homebrew
266
+ // installations. We should use the ARM64 one, located in /opt/homebrew.
267
+ const brewPath = '/opt/homebrew/bin/brew';
268
+ const localEnv = {
269
+ ...env,
270
+ HOMEBREW_NO_AUTO_UPDATE: '1',
271
+ HOMEBREW_NO_INSTALL_CLEANUP: '1',
272
+ };
273
+ logger.info('Tapping facebook/fb...');
274
+ await (0, turtle_spawn_1.default)(brewPath, ['tap', 'facebook/fb'], {
275
+ env: localEnv,
276
+ logger,
277
+ });
278
+ const brewRepo = await (0, turtle_spawn_1.default)(brewPath, ['--repo', 'facebook/fb'], {
279
+ env: localEnv,
280
+ });
281
+ const tapPath = brewRepo.stdout.trim();
282
+ // c0386793f59da10c619787f2aa18d938ef1d69c9 is hash for 1.1.8 release,
283
+ // last known compatible version + post_install fix.
284
+ const gitSha = 'c0386793f59da10c619787f2aa18d938ef1d69c9';
285
+ logger.info('Checking out facebook/fb at idb_companion@1.1.8...');
286
+ await (0, turtle_spawn_1.default)('git', ['fetch', 'origin', gitSha], {
287
+ cwd: tapPath,
288
+ logger,
289
+ });
290
+ await (0, turtle_spawn_1.default)('git', ['checkout', gitSha], {
291
+ cwd: tapPath,
292
+ logger,
293
+ });
294
+ logger.info('Installing idb_companion v1.1.8...');
295
+ await (0, turtle_spawn_1.default)(brewPath, ['install', 'facebook/fb/idb-companion'], {
296
+ env: localEnv,
297
+ logger,
298
+ });
299
+ }
300
+ catch (err) {
301
+ throw new eas_build_job_1.SystemError('Failed to install idb-companion required for Maestro to run.', {
302
+ cause: err,
303
+ });
304
+ }
231
305
  }
232
306
  async function isJavaInstalled({ env }) {
233
307
  try {
@@ -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
- // A `file=` attribute counts as present only when it is a non-empty string.
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
- return typeof f === 'string' && f.length > 0 ? f : undefined;
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
- const status = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
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