@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.
- package/dist/builders/android.js +12 -1
- package/dist/builders/ios.js +13 -1
- package/dist/common/easBuildInternal.js +3 -0
- package/dist/common/hookableBuildPhase.d.ts +18 -0
- package/dist/common/hookableBuildPhase.js +72 -0
- package/dist/common/jobHooks.d.ts +16 -0
- package/dist/common/jobHooks.js +95 -0
- package/dist/common/setup.d.ts +14 -2
- package/dist/common/setup.js +40 -21
- package/dist/context.js +1 -31
- package/dist/index.d.ts +4 -3
- package/dist/index.js +5 -5
- package/dist/{gcs/LoggerStream.d.ts → logging/RemoteLoggerStream.d.ts} +6 -10
- package/dist/{gcs/LoggerStream.js → logging/RemoteLoggerStream.js} +13 -27
- package/dist/steps/functions/maestroResultParser.d.ts +1 -0
- package/dist/steps/functions/maestroResultParser.js +12 -0
- package/dist/steps/functions/maestroScreenshots.d.ts +1 -0
- package/dist/steps/functions/maestroScreenshots.js +167 -34
- package/dist/steps/functions/maestroTests.js +4 -0
- package/dist/steps/functions/startServeSimRemoteSession.js +2 -3
- package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.js +20 -1
- package/dist/steps/utils/IosSimulatorRecordingUtils.js +6 -2
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +0 -1
- package/dist/steps/utils/remoteDeviceRunSession.js +7 -8
- package/dist/{gcs → storage}/retry.d.ts +5 -4
- package/dist/{gcs → storage}/retry.js +13 -9
- package/dist/storage/uploadWithSignedUrl.d.ts +12 -0
- package/dist/storage/uploadWithSignedUrl.js +68 -0
- package/package.json +7 -8
- package/resources/record-sim/Sources/RecordSim/RecordingModels.swift +7 -0
- package/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift +5 -1
- package/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift +13 -1
- package/resources/record-sim/Sources/record-sim/main.swift +20 -4
- package/dist/gcs/__unit__/gcs.test.d.ts +0 -1
- package/dist/gcs/__unit__/gcs.test.js +0 -335
- package/dist/gcs/client.d.ts +0 -58
- 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
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const legacyShots = [];
|
|
60
|
+
const bundleShots = [];
|
|
61
|
+
for (const sessionEntry of sessionEntries) {
|
|
62
|
+
if (!sessionEntry.isDirectory()) {
|
|
50
63
|
continue;
|
|
51
64
|
}
|
|
52
|
-
const
|
|
53
|
-
let
|
|
65
|
+
const sessionDir = path_1.default.join(args.testsDirectory, sessionEntry.name);
|
|
66
|
+
let children;
|
|
54
67
|
try {
|
|
55
|
-
if ((await promises_1.default.stat(
|
|
68
|
+
if ((await promises_1.default.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
|
|
56
69
|
continue;
|
|
57
70
|
}
|
|
58
|
-
|
|
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 ${
|
|
74
|
+
args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
|
|
62
75
|
continue;
|
|
63
76
|
}
|
|
64
|
-
for (const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
|
306
|
-
const labelPattern = new RegExp(
|
|
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
|
-
|
|
2
|
+
type RetryOptions = {
|
|
3
3
|
retries: number;
|
|
4
4
|
retryIntervalMs: number;
|
|
5
|
-
shouldRetryOnError: (error:
|
|
5
|
+
shouldRetryOnError: (error: unknown) => boolean;
|
|
6
6
|
shouldRetryOnResponse: (response: Response) => boolean;
|
|
7
|
-
}
|
|
8
|
-
export declare function
|
|
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.
|
|
3
|
+
exports.retryOnUploadFailure = retryOnUploadFailure;
|
|
4
4
|
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
|
5
|
-
async function
|
|
5
|
+
async function retryOnUploadFailure(fn, { retries, retryIntervalMs, }) {
|
|
6
6
|
return await retry(fn, {
|
|
7
7
|
retries,
|
|
8
8
|
retryIntervalMs,
|
|
9
|
-
shouldRetryOnError:
|
|
10
|
-
return (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
-
"@expo/template-file": "21.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.
|
|
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": "
|
|
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,
|