@expo/build-tools 21.0.1 → 21.0.3

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.
Files changed (37) hide show
  1. package/dist/builders/android.js +12 -1
  2. package/dist/builders/ios.js +13 -1
  3. package/dist/common/easBuildInternal.js +3 -0
  4. package/dist/common/hookableBuildPhase.d.ts +18 -0
  5. package/dist/common/hookableBuildPhase.js +72 -0
  6. package/dist/common/jobHooks.d.ts +16 -0
  7. package/dist/common/jobHooks.js +95 -0
  8. package/dist/common/setup.d.ts +14 -2
  9. package/dist/common/setup.js +40 -21
  10. package/dist/context.js +1 -31
  11. package/dist/index.d.ts +4 -3
  12. package/dist/index.js +5 -5
  13. package/dist/{gcs/LoggerStream.d.ts → logging/RemoteLoggerStream.d.ts} +6 -10
  14. package/dist/{gcs/LoggerStream.js → logging/RemoteLoggerStream.js} +13 -27
  15. package/dist/steps/functions/maestroResultParser.d.ts +1 -0
  16. package/dist/steps/functions/maestroResultParser.js +12 -0
  17. package/dist/steps/functions/maestroScreenshots.d.ts +1 -0
  18. package/dist/steps/functions/maestroScreenshots.js +167 -34
  19. package/dist/steps/functions/maestroTests.js +4 -0
  20. package/dist/steps/functions/startServeSimRemoteSession.js +2 -3
  21. package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.js +20 -1
  22. package/dist/steps/utils/IosSimulatorRecordingUtils.js +6 -2
  23. package/dist/steps/utils/remoteDeviceRunSession.d.ts +0 -1
  24. package/dist/steps/utils/remoteDeviceRunSession.js +7 -8
  25. package/dist/{gcs → storage}/retry.d.ts +5 -4
  26. package/dist/{gcs → storage}/retry.js +13 -9
  27. package/dist/storage/uploadWithSignedUrl.d.ts +12 -0
  28. package/dist/storage/uploadWithSignedUrl.js +68 -0
  29. package/package.json +7 -8
  30. package/resources/record-sim/Sources/RecordSim/RecordingModels.swift +7 -0
  31. package/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift +5 -1
  32. package/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift +13 -1
  33. package/resources/record-sim/Sources/record-sim/main.swift +20 -4
  34. package/dist/gcs/__unit__/gcs.test.d.ts +0 -1
  35. package/dist/gcs/__unit__/gcs.test.js +0 -335
  36. package/dist/gcs/client.d.ts +0 -58
  37. package/dist/gcs/client.js +0 -173
@@ -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
  }
@@ -16,18 +16,17 @@ function createStartServeSimRemoteSessionBuildFunction(ctx) {
16
16
  const ngrokTunnelDomain = (0, remoteDeviceRunSession_1.getNgrokTunnelDomainOrThrow)(env);
17
17
  logger.info('Starting serve-sim remote session.');
18
18
  await (0, remoteDeviceRunSession_1.selectXcodeDeveloperDirectoryAsync)({ env, logger });
19
- const { previewUrl, streamUrl } = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)(ctx, {
19
+ const { previewUrl } = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)(ctx, {
20
20
  baseDomain: ngrokTunnelDomain,
21
21
  env,
22
22
  logger,
23
23
  timeoutMs: STARTUP_TIMEOUT_MS,
24
24
  });
25
25
  logger.info(`Preview URL: ${previewUrl}`);
26
- logger.info(`Stream URL: ${streamUrl}`);
27
26
  await (0, remoteDeviceRunSession_1.uploadRemoteSessionConfigAsync)({
28
27
  ctx,
29
28
  deviceRunSessionId,
30
- remoteConfig: { previewUrl, streamUrl },
29
+ remoteConfig: { previewUrl },
31
30
  logger,
32
31
  });
33
32
  await (0, remoteDeviceRunSession_1.waitForDeviceRunSessionStoppedAsync)({
@@ -25,8 +25,22 @@ const RecordingManifestSchema = zod_1.z.object({
25
25
  firstFrameWallClock: zod_1.z.object({
26
26
  iso8601: zod_1.z.string(),
27
27
  }),
28
+ width: zod_1.z.number().int().positive(),
29
+ height: zod_1.z.number().int().positive(),
28
30
  recording: zod_1.z.string(),
29
31
  });
32
+ const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
33
+ year: 'numeric',
34
+ month: 'short',
35
+ day: 'numeric',
36
+ hour: '2-digit',
37
+ minute: '2-digit',
38
+ second: '2-digit',
39
+ fractionalSecondDigits: 3,
40
+ hourCycle: 'h23',
41
+ timeZone: 'UTC',
42
+ timeZoneName: 'short',
43
+ });
30
44
  function createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx) {
31
45
  return new steps_1.BuildFunction({
32
46
  namespace: 'eas',
@@ -56,9 +70,11 @@ function createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx) {
56
70
  const deviceRunSessionId = (0, remoteDeviceRunSession_1.getDeviceRunSessionIdOrThrow)(env);
57
71
  const limit = (0, promise_limit_1.default)(5);
58
72
  await Promise.all(recordings.map(recording => limit(async () => {
59
- const displayName = `${recording.deviceName} screen recording`;
60
73
  try {
61
74
  const metadata = RecordingManifestSchema.parse(JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(recording.directory, 'session.json'), 'utf-8')));
75
+ const startedAt = recordingStartTimeFormatter.format(new Date(metadata.firstFrameWallClock.iso8601));
76
+ const shortUdid = `${recording.udid.slice(0, 8)}-…`;
77
+ const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
62
78
  const recordingPath = node_path_1.default.join(recording.directory, metadata.recording);
63
79
  const { size } = await (0, promises_1.stat)(recordingPath);
64
80
  const recordingId = node_path_1.default.basename(recording.directory);
@@ -70,11 +86,14 @@ function createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx) {
70
86
  filename: `${recordingId}.mp4`,
71
87
  kind: 'screen-recording',
72
88
  metadata: {
89
+ __eas_type: 'screen-recording',
73
90
  __eas_screen_recording: '1',
74
91
  udid: recording.udid,
75
92
  deviceName: recording.deviceName,
76
93
  runtimeDisplayName: recording.runtimeDisplayName,
77
94
  firstFrameAt: metadata.firstFrameWallClock.iso8601,
95
+ width: metadata.width,
96
+ height: metadata.height,
78
97
  },
79
98
  size,
80
99
  stream: (0, node_fs_1.createReadStream)(recordingPath),
@@ -13,7 +13,7 @@ const promises_2 = require("node:timers/promises");
13
13
  const sentry_1 = require("../../sentry");
14
14
  const IosSimulatorUtils_1 = require("../../utils/IosSimulatorUtils");
15
15
  const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
16
- const RECORD_SIM_FINISH_TIMEOUT_MS = 30_000;
16
+ const RECORD_SIM_FINISH_TIMEOUT_MS = 70_000;
17
17
  const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
18
18
  const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
19
19
  const RECORD_SIM_COMMAND = 'record-sim';
@@ -70,7 +70,11 @@ var IosSimulatorRecordingUtils;
70
70
  if (finished) {
71
71
  return;
72
72
  }
73
- logger.warn(`Forcing iOS Simulator recording process for ${recording.deviceName} to stop.`);
73
+ const recordSimOutput = recording.getOutput().trim();
74
+ const finishTimeoutSeconds = Math.round(RECORD_SIM_FINISH_TIMEOUT_MS / 1_000);
75
+ logger.warn({ recordSimOutput }, `Screen recording for ${recording.deviceName} did not finish within ${finishTimeoutSeconds} seconds and will be stopped.${recordSimOutput
76
+ ? `\nRecent recorder messages:\n${recordSimOutput}`
77
+ : '\nNo recorder messages were captured.'}`);
74
78
  recording.recordingProcess.kill('SIGKILL');
75
79
  const killed = await Promise.race([
76
80
  recording.completionPromise.then(() => true),
@@ -65,7 +65,6 @@ export declare function startServeSimWithTunnelAsync(ctx: CustomBuildContext, {
65
65
  timeoutMs: number;
66
66
  }): Promise<{
67
67
  previewUrl: string;
68
- streamUrl: string;
69
68
  }>;
70
69
  export declare function startNgrokTunnelAsync({ port, subdomainPrefix, baseDomain, authtoken, rewriteHostHeader, logger, }: {
71
70
  port: number;
@@ -289,21 +289,20 @@ async function startServeSimWithTunnelAsync(ctx, { baseDomain, env, logger, time
289
289
  ],
290
290
  env,
291
291
  });
292
- logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
292
+ logger.info('Waiting for serve-sim to report tunnel URL.');
293
293
  const deadline = Date.now() + timeoutMs;
294
294
  while (Date.now() < deadline) {
295
295
  const output = serveSim.getOutput();
296
- const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
297
- const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
298
- if (previewUrl && streamUrl) {
299
- return { previewUrl, streamUrl };
296
+ const previewUrl = matchTunnelUrl({ output, baseDomain });
297
+ if (previewUrl) {
298
+ return { previewUrl };
300
299
  }
301
300
  await (0, retry_1.sleepAsync)(1_000);
302
301
  }
303
- throw new eas_build_job_1.SystemError(`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`);
302
+ throw new eas_build_job_1.SystemError(`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`);
304
303
  }
305
- function matchLabeledUrl({ output, label, baseDomain, }) {
306
- const labelPattern = new RegExp(`${label}:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`);
304
+ function matchTunnelUrl({ output, baseDomain, }) {
305
+ const labelPattern = new RegExp(`Tunnel:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`);
307
306
  const match = labelPattern.exec(output);
308
307
  return match ? match[1] : null;
309
308
  }
@@ -1,11 +1,12 @@
1
1
  import { Response } from 'node-fetch';
2
- export interface RetryOptions {
2
+ type RetryOptions = {
3
3
  retries: number;
4
4
  retryIntervalMs: number;
5
- shouldRetryOnError: (error: any) => boolean;
5
+ shouldRetryOnError: (error: unknown) => boolean;
6
6
  shouldRetryOnResponse: (response: Response) => boolean;
7
- }
8
- export declare function retryOnGCSUploadFailure(fn: (attemptCount: number) => Promise<Response>, { retries, retryIntervalMs, }: {
7
+ };
8
+ export declare function retryOnUploadFailure(fn: (attemptCount: number) => Promise<Response>, { retries, retryIntervalMs, }: {
9
9
  retries: RetryOptions['retries'];
10
10
  retryIntervalMs: RetryOptions['retryIntervalMs'];
11
11
  }): Promise<Response>;
12
+ export {};
@@ -1,25 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.retryOnGCSUploadFailure = retryOnGCSUploadFailure;
3
+ exports.retryOnUploadFailure = retryOnUploadFailure;
4
4
  // based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
5
- async function retryOnGCSUploadFailure(fn, { retries, retryIntervalMs, }) {
5
+ async function retryOnUploadFailure(fn, { retries, retryIntervalMs, }) {
6
6
  return await retry(fn, {
7
7
  retries,
8
8
  retryIntervalMs,
9
- shouldRetryOnError: e => {
10
- return (e.code === 'ENOTFOUND' ||
11
- e.code === 'EAI_AGAIN' ||
12
- e.code === 'ECONNRESET' ||
13
- e.code === 'ETIMEDOUT' ||
14
- e.code === 'EPIPE');
9
+ shouldRetryOnError: err => {
10
+ return (isErrorWithCode(err) &&
11
+ (err.code === 'ENOTFOUND' ||
12
+ err.code === 'EAI_AGAIN' ||
13
+ err.code === 'ECONNRESET' ||
14
+ err.code === 'ETIMEDOUT' ||
15
+ err.code === 'EPIPE'));
15
16
  },
16
17
  shouldRetryOnResponse: resp => {
17
18
  return [408, 429, 500, 502, 503, 504].includes(resp.status);
18
19
  },
19
20
  });
20
21
  }
22
+ function isErrorWithCode(error) {
23
+ return typeof error === 'object' && error !== null && 'code' in error;
24
+ }
21
25
  /**
22
- * Wrapper used to execute an inner function and possibly retry it if it throws and error
26
+ * Wrapper used to execute an inner function and possibly retry it if it throws an error
23
27
  * @param fn Function to be executed and retried in case of error
24
28
  * @param retries How many times at most should the function be retried
25
29
  * @param retryIntervalMs Time interval between the retries
@@ -0,0 +1,12 @@
1
+ import { Readable } from 'stream';
2
+ export type SignedUrl = {
3
+ url: string;
4
+ headers: Record<string, string>;
5
+ };
6
+ export type UploadWithSignedUrlParams = {
7
+ signedUrl: SignedUrl;
8
+ srcGeneratorAsync: () => Promise<Readable>;
9
+ retryIntervalMs?: number;
10
+ retries?: number;
11
+ };
12
+ export declare function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries, retryIntervalMs, }: UploadWithSignedUrlParams): Promise<string>;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.uploadWithSignedUrl = uploadWithSignedUrl;
37
+ const node_fetch_1 = __importStar(require("node-fetch"));
38
+ const url_1 = require("url");
39
+ const retry_1 = require("./retry");
40
+ async function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries = 2, retryIntervalMs = 30_000, }) {
41
+ let resp;
42
+ try {
43
+ resp = await (0, retry_1.retryOnUploadFailure)(async () => {
44
+ const src = await srcGeneratorAsync();
45
+ return await (0, node_fetch_1.default)(signedUrl.url, {
46
+ method: 'PUT',
47
+ headers: signedUrl.headers,
48
+ body: src,
49
+ });
50
+ }, { retries, retryIntervalMs });
51
+ }
52
+ catch (err) {
53
+ if (err instanceof node_fetch_1.FetchError) {
54
+ throw new Error(`Failed to upload the file, reason: ${err.code}`);
55
+ }
56
+ throw err;
57
+ }
58
+ if (!resp.ok) {
59
+ let body;
60
+ try {
61
+ body = await resp.text();
62
+ }
63
+ catch { }
64
+ throw new Error(`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`);
65
+ }
66
+ const url = new url_1.URL(signedUrl.url);
67
+ return `${url.protocol}//${url.host}${url.pathname}`;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "21.0.1",
3
+ "version": "21.0.3",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -39,18 +39,17 @@
39
39
  "@expo/config": "55.0.10",
40
40
  "@expo/config-plugins": "55.0.7",
41
41
  "@expo/downloader": "21.0.0",
42
- "@expo/eas-build-job": "21.0.1",
42
+ "@expo/eas-build-job": "21.0.3",
43
43
  "@expo/env": "^0.4.0",
44
44
  "@expo/logger": "21.0.0",
45
45
  "@expo/package-manager": "1.9.10",
46
- "@expo/plist": "^0.2.0",
46
+ "@expo/plist": "^0.3.5",
47
47
  "@expo/results": "^1.0.0",
48
48
  "@expo/spawn-async": "1.7.2",
49
- "@expo/steps": "21.0.1",
50
- "@expo/template-file": "21.0.0",
49
+ "@expo/steps": "21.0.3",
50
+ "@expo/template-file": "21.0.2",
51
51
  "@expo/turtle-spawn": "21.0.0",
52
52
  "@expo/xcpretty": "^4.3.1",
53
- "@google-cloud/storage": "^7.11.2",
54
53
  "@ngrok/ngrok": "1.7.0",
55
54
  "@sentry/node": "7.77.0",
56
55
  "@urql/core": "^6.0.1",
@@ -61,7 +60,7 @@
61
60
  "gql.tada": "^1.8.13",
62
61
  "joi": "^17.13.1",
63
62
  "jose": "5.10.0",
64
- "lodash": "^4.17.21",
63
+ "lodash": "^4.18.1",
65
64
  "node-fetch": "^2.7.0",
66
65
  "node-forge": "^1.3.1",
67
66
  "node-stream-zip": "1.15.0",
@@ -101,5 +100,5 @@
101
100
  "typescript": "^5.5.4",
102
101
  "uuid": "^9.0.1"
103
102
  },
104
- "gitHead": "a62289b9888b71fe90e3022840643b182b732048"
103
+ "gitHead": "62d46fece55ab5b27c2a572fc91158214b9ae96b"
105
104
  }
@@ -59,8 +59,15 @@ public struct FirstFrameWallClock: Codable, Sendable {
59
59
  public let iso8601: String
60
60
  }
61
61
 
62
+ public enum RecordingFinalizationStage: Sendable {
63
+ case captureStopped
64
+ case videoSaved
65
+ }
66
+
62
67
  public struct RecordingManifest: Codable, Sendable {
63
68
  public let firstFrameWallClock: FirstFrameWallClock
69
+ public let width: Int
70
+ public let height: Int
64
71
  public let hlsVersion: Int?
65
72
  public let hlsTargetDurationSeconds: Int?
66
73
  public let hlsMediaSequence: Int?
@@ -100,7 +100,9 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
100
100
  @discardableResult
101
101
  func writeManifest(
102
102
  configuration: SimulatorRecordingConfiguration,
103
- firstFrameWallClock: Date
103
+ firstFrameWallClock: Date,
104
+ width: Int,
105
+ height: Int
104
106
  ) throws -> RecordingManifest {
105
107
  try stateQueue.sync {
106
108
  if let firstError {
@@ -121,6 +123,8 @@ final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
121
123
  unixMs: unixMs(firstFrameWallClock),
122
124
  iso8601: iso8601(firstFrameWallClock)
123
125
  ),
126
+ width: width,
127
+ height: height,
124
128
  hlsVersion: segmented ? 7 : nil,
125
129
  hlsTargetDurationSeconds: targetDuration,
126
130
  hlsMediaSequence: segmented ? 0 : nil,