@expo/build-tools 21.0.2 → 21.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.
@@ -9,10 +9,18 @@ exports.computePureFailureFlowNames = computePureFailureFlowNames;
9
9
  exports.selectFailureScreenshots = selectFailureScreenshots;
10
10
  const promises_1 = __importDefault(require("fs/promises"));
11
11
  const path_1 = __importDefault(require("path"));
12
- // Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
12
+ // pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
13
+ // `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
13
14
  // The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
14
- // `).png` suffix rather than scanning for parens.
15
+ // `).png` suffix rather than scanning for parens. Remove this path once the build fleet is
16
+ // entirely on Maestro >= 2.7.0.
15
17
  const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
18
+ // Maestro >= 2.7.0 bundle layout. Each flow gets its own dir under the session dir, with step
19
+ // screenshots under `screenshots/` named `step-<NNN>-<slug>.png` (NNN is a 1-based, zero-padded
20
+ // monotonic step sequence). Under the CLI (no `--analyze`) only failed/warned steps produce one.
21
+ const STEP_SCREENSHOTS_DIRNAME = 'screenshots';
22
+ const STEP_SCREENSHOT_PATTERN = /^step-(\d+)(?:-.*)?\.png$/;
23
+ // pre-v2.7.0: parses the legacy flat failure-screenshot filename.
16
24
  function parseFailureScreenshotFilename(filename) {
17
25
  const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
18
26
  if (!match) {
@@ -27,61 +35,186 @@ function parseFailureScreenshotFilename(filename) {
27
35
  capturedAtMs,
28
36
  };
29
37
  }
30
- // Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
31
- // the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
32
- // whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
33
- // and skipped so a screenshot harvest can't affect the maestro step outcome.
38
+ // Harvests one attempt's failure screenshots from the maestro debug output. Walks each session
39
+ // dir under testsDirectory whose mtime is recent enough (mtime-based to also cover the maestro
40
+ // <2.5.0 bug that splits one invocation across two dirs). Within a session dir, a child is either
41
+ // a flat pre-v2.7.0 screenshot file or a Maestro >= 2.7.0 per-flow bundle dir the single
42
+ // structural seam below. Never throws: an unreadable dir/file is logged and skipped so a
43
+ // screenshot harvest can't affect the maestro step outcome.
34
44
  async function harvestFailureScreenshotsAsync(args) {
35
45
  // Gate dirs by mtime floored to the second, so a dir created within the same second as the
36
- // attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
46
+ // attempt start isn't stat'd below the baseline. The exact capturedAtMs gate per file is what
37
47
  // prevents mis-attributing an earlier attempt's screenshot.
38
48
  const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
39
- let entries;
49
+ // Bundle dir names already have '/' -> '_'; normalize the JUnit names the same way to match.
50
+ const normalizedFailedFlowNames = new Set([...args.failedFlowNames].map(normalizeFlowName));
51
+ let sessionEntries;
40
52
  try {
41
- entries = await promises_1.default.readdir(args.testsDirectory, { withFileTypes: true });
53
+ sessionEntries = await promises_1.default.readdir(args.testsDirectory, { withFileTypes: true });
42
54
  }
43
55
  catch (err) {
44
56
  args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
45
57
  return [];
46
58
  }
47
- const results = [];
48
- for (const entry of entries) {
49
- if (!entry.isDirectory()) {
59
+ const legacyShots = [];
60
+ const bundleShots = [];
61
+ for (const sessionEntry of sessionEntries) {
62
+ if (!sessionEntry.isDirectory()) {
50
63
  continue;
51
64
  }
52
- const dirPath = path_1.default.join(args.testsDirectory, entry.name);
53
- let filenames;
65
+ const sessionDir = path_1.default.join(args.testsDirectory, sessionEntry.name);
66
+ let children;
54
67
  try {
55
- if ((await promises_1.default.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
68
+ if ((await promises_1.default.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
56
69
  continue;
57
70
  }
58
- filenames = await promises_1.default.readdir(dirPath);
71
+ children = await promises_1.default.readdir(sessionDir, { withFileTypes: true });
59
72
  }
60
73
  catch (err) {
61
- args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
74
+ args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
62
75
  continue;
63
76
  }
64
- for (const filename of filenames) {
65
- const parsed = parseFailureScreenshotFilename(filename);
66
- // Re-check capture time per file: a dir's mtime can advance after this attempt began (or
67
- // fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
68
- // earlier attempt's screenshot to this one.
69
- if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
70
- continue;
77
+ for (const child of children) {
78
+ if (child.isDirectory()) {
79
+ // Maestro >= 2.7.0: a per-flow bundle dir.
80
+ const shot = await harvestFromBundleAsync({
81
+ bundleDir: path_1.default.join(sessionDir, child.name),
82
+ bundleName: child.name,
83
+ normalizedFailedFlowNames,
84
+ capturedSinceMs: args.capturedSinceMs,
85
+ attemptIndex: args.attemptIndex,
86
+ logger: args.logger,
87
+ });
88
+ if (shot !== null) {
89
+ bundleShots.push(shot);
90
+ }
71
91
  }
72
- results.push({
73
- fileAbsPath: path_1.default.join(dirPath, filename),
74
- displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
75
- metadata: {
76
- kind: 'maestro-test-screenshot',
77
- flowName: parsed.flowName,
92
+ else {
93
+ // pre-v2.7.0: a flat failure screenshot file in the session dir.
94
+ const shot = harvestLegacyFlatShot({
95
+ fileName: child.name,
96
+ sessionDir,
97
+ capturedSinceMs: args.capturedSinceMs,
78
98
  attemptIndex: args.attemptIndex,
79
- capturedAtMs: parsed.capturedAtMs,
80
- },
81
- });
99
+ });
100
+ if (shot !== null) {
101
+ legacyShots.push(shot);
102
+ }
103
+ }
104
+ }
105
+ }
106
+ // The website shows one screenshot per (flow, attempt). Two v2.7.0 bundle dirs can resolve to
107
+ // the same flow — a collision dir (`<flow>-2`) or a duplicate flow name — so keep the latest.
108
+ // Legacy shots need no dedupe: a flat filename is emitted once per failed flow, so there is no
109
+ // `<flow>-2` collision to collapse (and deduping them would change historical behavior).
110
+ return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
111
+ }
112
+ // Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
113
+ // screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
114
+ // the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
115
+ // lower numbers. Best-effort limitation: without reading Maestro's per-command JSON we can't tell a
116
+ // failed-step frame from a warned-step frame, so if the failing command captured nothing (e.g. a
117
+ // non-visible command like runScript) but an earlier optional step warned, we return that warned
118
+ // frame instead. Returns null when the bundle isn't a failed flow, its name is ambiguous, or it has
119
+ // no usable step screenshot. Never throws.
120
+ async function harvestFromBundleAsync(args) {
121
+ const flowName = resolveBundleFlowName(args.bundleName, args.normalizedFailedFlowNames);
122
+ if (flowName === null) {
123
+ return null;
124
+ }
125
+ const screenshotsDir = path_1.default.join(args.bundleDir, STEP_SCREENSHOTS_DIRNAME);
126
+ let filenames;
127
+ try {
128
+ filenames = await promises_1.default.readdir(screenshotsDir);
129
+ }
130
+ catch {
131
+ // No screenshots/ dir — e.g. the flow failed on a non-visible command, which captures none.
132
+ return null;
133
+ }
134
+ let best = null;
135
+ for (const filename of filenames) {
136
+ const match = filename.match(STEP_SCREENSHOT_PATTERN);
137
+ if (match === null) {
138
+ continue; // final.png and any non-step file
139
+ }
140
+ const stepIndex = Number(match[1]);
141
+ const fileAbsPath = path_1.default.join(screenshotsDir, filename);
142
+ let capturedAtMs;
143
+ try {
144
+ capturedAtMs = Math.round((await promises_1.default.stat(fileAbsPath)).mtimeMs);
145
+ }
146
+ catch (err) {
147
+ args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
148
+ continue;
149
+ }
150
+ if (capturedAtMs < args.capturedSinceMs) {
151
+ continue; // a prior attempt's frame (bundle dir touched later than this attempt started)
152
+ }
153
+ if (best === null || stepIndex > best.stepIndex) {
154
+ best = { fileAbsPath, stepIndex, capturedAtMs };
155
+ }
156
+ }
157
+ if (best === null) {
158
+ return null;
159
+ }
160
+ return {
161
+ fileAbsPath: best.fileAbsPath,
162
+ displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
163
+ metadata: {
164
+ kind: 'maestro-test-screenshot',
165
+ flowName,
166
+ attemptIndex: args.attemptIndex,
167
+ capturedAtMs: best.capturedAtMs,
168
+ },
169
+ };
170
+ }
171
+ // Maestro names a bundle dir `<cleanFlow>` optionally + `-shard-<n>` (sharded) + `-<n>` (name
172
+ // collision), where cleanFlow is the flow name with '/' -> '_'. Those suffixes share a namespace
173
+ // with legitimate flow names, so we can't strip blindly (a flow may be named `x-shard-1` or `x-2`).
174
+ // Enumerate the ways the dir name could peel back to a flow name, keep those that match a failed
175
+ // flow, and use the result only if exactly one fits — otherwise skip rather than guess.
176
+ function resolveBundleFlowName(bundleName, normalizedFailedFlowNames) {
177
+ const withoutCollision = bundleName.replace(/-\d+$/, '');
178
+ const preimages = [
179
+ bundleName,
180
+ withoutCollision,
181
+ withoutCollision.replace(/-shard-\d+$/, ''),
182
+ bundleName.replace(/-shard-\d+$/, ''),
183
+ ];
184
+ const matches = new Set(preimages.filter(name => normalizedFailedFlowNames.has(name)));
185
+ return matches.size === 1 ? [...matches][0] : null;
186
+ }
187
+ // Keep one screenshot per flow name (attemptIndex is constant within a harvest call), preferring
188
+ // the latest capture when two bundles resolve to the same flow.
189
+ function dedupeBundleShotsByFlowName(shots) {
190
+ const byFlowName = new Map();
191
+ for (const shot of shots) {
192
+ const existing = byFlowName.get(shot.metadata.flowName);
193
+ if (existing === undefined || shot.metadata.capturedAtMs > existing.metadata.capturedAtMs) {
194
+ byFlowName.set(shot.metadata.flowName, shot);
82
195
  }
83
196
  }
84
- return results;
197
+ return [...byFlowName.values()];
198
+ }
199
+ // pre-v2.7.0 (Maestro <= 2.6.x) reader: a flat `screenshot-...❌...png` file whose name carries the
200
+ // flow name and capture epoch.
201
+ function harvestLegacyFlatShot(args) {
202
+ const parsed = parseFailureScreenshotFilename(args.fileName);
203
+ // Re-check capture time per file — a dir's mtime can advance after this attempt began, so the dir
204
+ // gate alone would re-attribute an earlier attempt's screenshot to this one.
205
+ if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
206
+ return null;
207
+ }
208
+ return {
209
+ fileAbsPath: path_1.default.join(args.sessionDir, args.fileName),
210
+ displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
211
+ metadata: {
212
+ kind: 'maestro-test-screenshot',
213
+ flowName: parsed.flowName,
214
+ attemptIndex: args.attemptIndex,
215
+ capturedAtMs: parsed.capturedAtMs,
216
+ },
217
+ };
85
218
  }
86
219
  // Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
87
220
  // is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
@@ -216,10 +216,14 @@ function createMaestroTestsBuildFunction(ctx) {
216
216
  // junit: test-case-result rows (and therefore the summary icons) only exist for junit
217
217
  // runs, so harvesting other formats would just create orphan artifacts the website hides.
218
218
  if (outputFormat === 'junit') {
219
+ const failedFlowNames = outputPath
220
+ ? await (0, maestroResultParser_1.parseFailedFlowNamesFromJUnitFile)(outputPath)
221
+ : new Set();
219
222
  harvested.push(...(await (0, maestroScreenshots_1.harvestFailureScreenshotsAsync)({
220
223
  testsDirectory,
221
224
  capturedSinceMs: attemptStartedAtMs,
222
225
  attemptIndex: attempt,
226
+ failedFlowNames,
223
227
  logger,
224
228
  })));
225
229
  }
@@ -1,3 +1,11 @@
1
+ import { type bunyan } from '@expo/logger';
1
2
  import { BuildFunction } from '@expo/steps';
2
3
  import { type CustomBuildContext } from '../../customBuildContext';
3
4
  export declare function createStartAgentDeviceRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
5
+ export declare function stopAgentDeviceEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger, }: {
6
+ eventCollection: {
7
+ stopAsync: () => Promise<void>;
8
+ };
9
+ deviceRunSessionId: string;
10
+ logger: bunyan;
11
+ }): Promise<void>;
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createStartAgentDeviceRemoteSessionBuildFunction = createStartAgentDeviceRemoteSessionBuildFunction;
7
+ exports.stopAgentDeviceEventCollectionSafelyAsync = stopAgentDeviceEventCollectionSafelyAsync;
7
8
  const eas_build_job_1 = require("@expo/eas-build-job");
8
9
  const steps_1 = require("@expo/steps");
9
10
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
@@ -12,11 +13,13 @@ const node_os_1 = __importDefault(require("node:os"));
12
13
  const node_path_1 = __importDefault(require("node:path"));
13
14
  const sentry_1 = require("../../sentry");
14
15
  const agentDeviceArtifacts_1 = require("../utils/agentDeviceArtifacts");
16
+ const deviceRunSessionEvents_1 = require("../utils/deviceRunSessionEvents");
15
17
  const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
16
18
  const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
17
19
  const AGENT_DEVICE_REPO_URL = 'https://github.com/callstack/agent-device.git';
18
20
  const SRC_DIR = '/tmp/agent-device-src';
19
- const DAEMON_JSON_PATH = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device', 'daemon.json');
21
+ const AGENT_DEVICE_STATE_DIR = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device');
22
+ const DAEMON_JSON_PATH = node_path_1.default.join(AGENT_DEVICE_STATE_DIR, 'daemon.json');
20
23
  const STARTUP_TIMEOUT_MS = 60_000;
21
24
  const AGENT_DEVICE_DAEMON_ENV = {
22
25
  AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
@@ -93,15 +96,44 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
93
96
  daemonToken,
94
97
  logger,
95
98
  });
96
- await (0, remoteDeviceRunSession_1.waitForDeviceRunSessionStoppedAsync)({
99
+ const eventCollection = await (0, deviceRunSessionEvents_1.startAgentDeviceEventCollectionAsync)({
97
100
  ctx,
98
101
  deviceRunSessionId,
102
+ stateDir: AGENT_DEVICE_STATE_DIR,
99
103
  logger,
100
- signal,
101
104
  });
105
+ try {
106
+ await (0, remoteDeviceRunSession_1.waitForDeviceRunSessionStoppedAsync)({
107
+ ctx,
108
+ deviceRunSessionId,
109
+ logger,
110
+ signal,
111
+ });
112
+ }
113
+ finally {
114
+ await stopAgentDeviceEventCollectionSafelyAsync({
115
+ eventCollection,
116
+ deviceRunSessionId,
117
+ logger,
118
+ });
119
+ }
102
120
  },
103
121
  });
104
122
  }
123
+ async function stopAgentDeviceEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger, }) {
124
+ try {
125
+ await eventCollection.stopAsync();
126
+ }
127
+ catch (err) {
128
+ const error = err instanceof Error ? err : new Error(String(err));
129
+ sentry_1.Sentry.capture('Could not finish agent-device session event collection', error, {
130
+ level: 'warning',
131
+ tags: { phase: 'agent-device-event-collection', operation: 'stop' },
132
+ extras: { deviceRunSessionId },
133
+ });
134
+ logger.warn({ err: error }, 'Could not finish agent-device session event collection.');
135
+ }
136
+ }
105
137
  async function startAgentDeviceDaemonAsync({ packageVersion, env, logger, }) {
106
138
  const packageSpec = createAgentDevicePackageSpec(packageVersion);
107
139
  try {
@@ -0,0 +1,23 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ import { type CustomBuildContext } from '../../customBuildContext';
3
+ export type DeviceRunSessionEvent = {
4
+ v: 1;
5
+ eventId: string;
6
+ ts: string;
7
+ producer: string;
8
+ type: string;
9
+ operationId?: string;
10
+ outcome?: 'success' | 'failure';
11
+ durationMs?: number;
12
+ summary: string;
13
+ data?: Record<string, unknown>;
14
+ };
15
+ export declare function startAgentDeviceEventCollectionAsync({ ctx, deviceRunSessionId, stateDir, logger, pollIntervalMs, }: {
16
+ ctx: CustomBuildContext;
17
+ deviceRunSessionId: string;
18
+ stateDir: string;
19
+ logger: bunyan;
20
+ pollIntervalMs?: number;
21
+ }): Promise<{
22
+ stopAsync: () => Promise<void>;
23
+ }>;