@expo/build-tools 20.1.0 → 20.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builders/custom.js +23 -0
- package/dist/common/installDependencies.d.ts +10 -1
- package/dist/common/installDependencies.js +95 -1
- package/dist/common/prebuild.js +2 -3
- package/dist/{ios → common}/xcpretty.d.ts +1 -0
- package/dist/{ios → common}/xcpretty.js +18 -3
- package/dist/datadog.js +7 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ios/fastlane.js +1 -1
- package/dist/ios/pod.js +1 -1
- package/dist/runtimeSettings.d.ts +12 -0
- package/dist/runtimeSettings.js +120 -0
- package/dist/steps/easFunctions.js +1 -1
- package/dist/steps/functions/downloadBuild.d.ts +5 -3
- package/dist/steps/functions/downloadBuild.js +34 -4
- package/dist/steps/functions/findAndUploadBuildArtifacts.js +2 -2
- package/dist/steps/functions/installMaestro.js +13 -2
- package/dist/steps/functions/maestroResultParser.d.ts +18 -0
- package/dist/steps/functions/maestroResultParser.js +132 -3
- package/dist/steps/functions/maestroTests.js +26 -13
- package/dist/steps/functions/repack.d.ts +3 -1
- package/dist/steps/functions/repack.js +15 -1
- package/dist/steps/functions/reportMaestroTestResults.js +39 -20
- package/dist/steps/functions/restoreBuildCache.js +9 -6
- package/dist/steps/functions/startAgentDeviceRemoteSession.d.ts +1 -1
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +101 -22
- package/dist/steps/functions/startArgentRemoteSession.d.ts +5 -0
- package/dist/steps/functions/startArgentRemoteSession.js +60 -19
- package/dist/steps/functions/startServeSimRemoteSession.js +1 -1
- package/dist/steps/functions/uploadArtifact.js +1 -1
- package/dist/steps/utils/ios/fastlane.js +1 -1
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +31 -2
- package/dist/steps/utils/remoteDeviceRunSession.js +82 -3
- package/package.json +3 -3
- package/dist/steps/utils/ios/xcpretty.d.ts +0 -15
- package/dist/steps/utils/ios/xcpretty.js +0 -92
|
@@ -4,10 +4,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.parseJUnitTestCases = parseJUnitTestCases;
|
|
7
|
+
exports.isFileAttrRun = isFileAttrRun;
|
|
8
|
+
exports.junitFileHasFileAttrs = junitFileHasFileAttrs;
|
|
9
|
+
exports.parseMaestroResultsFromFileAttrs = parseMaestroResultsFromFileAttrs;
|
|
10
|
+
exports.parseFailedFlowsFromFileAttrs = parseFailedFlowsFromFileAttrs;
|
|
7
11
|
exports.parseMaestroResults = parseMaestroResults;
|
|
8
12
|
exports.parseFailedFlowsFromJUnit = parseFailedFlowsFromJUnit;
|
|
9
13
|
exports.mergeJUnitReports = mergeJUnitReports;
|
|
10
14
|
exports.copyLatestAttemptXml = copyLatestAttemptXml;
|
|
15
|
+
const results_1 = require("@expo/results");
|
|
11
16
|
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
12
17
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
13
18
|
const path_1 = __importDefault(require("path"));
|
|
@@ -19,6 +24,11 @@ const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
|
19
24
|
// Ensure single-element arrays are always arrays
|
|
20
25
|
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
|
21
26
|
});
|
|
27
|
+
// A `file=` attribute counts as present only when it is a non-empty string.
|
|
28
|
+
function fileAttrOf(tc) {
|
|
29
|
+
const f = tc?.['@_file'];
|
|
30
|
+
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
|
31
|
+
}
|
|
22
32
|
function parseJUnitContent(content) {
|
|
23
33
|
const results = [];
|
|
24
34
|
try {
|
|
@@ -37,6 +47,7 @@ function parseJUnitContent(content) {
|
|
|
37
47
|
if (!name) {
|
|
38
48
|
continue;
|
|
39
49
|
}
|
|
50
|
+
const file = fileAttrOf(tc);
|
|
40
51
|
const timeStr = tc['@_time'];
|
|
41
52
|
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
|
42
53
|
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
|
@@ -70,7 +81,7 @@ function parseJUnitContent(content) {
|
|
|
70
81
|
.filter(Boolean)
|
|
71
82
|
: [];
|
|
72
83
|
delete properties['tags'];
|
|
73
|
-
results.push({ name, status, duration, errorMessage, tags, properties });
|
|
84
|
+
results.push({ name, file, status, duration, errorMessage, tags, properties });
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
}
|
|
@@ -103,6 +114,119 @@ async function parseJUnitTestCases(junitDirectory) {
|
|
|
103
114
|
const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
|
|
104
115
|
return perFile.flat();
|
|
105
116
|
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
|
|
119
|
+
// its flow's own path (all-or-none per run). Callers check the report first
|
|
120
|
+
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
|
|
121
|
+
// scan is then never consulted.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
function isFileAttrRun(testcases) {
|
|
124
|
+
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
|
|
125
|
+
}
|
|
126
|
+
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
|
|
127
|
+
// legacy path, whose own guards degrade safely.
|
|
128
|
+
async function junitFileHasFileAttrs(junitFile) {
|
|
129
|
+
return isFileAttrRun(await parseJUnitFile(junitFile));
|
|
130
|
+
}
|
|
131
|
+
async function fileExists(absPath) {
|
|
132
|
+
return (await (0, results_1.asyncResult)(promises_1.default.stat(absPath))).ok;
|
|
133
|
+
}
|
|
134
|
+
// Group by `file=` so two same-named flows in different files stay separate.
|
|
135
|
+
async function parseMaestroResultsFromFileAttrs(junitDirectory) {
|
|
136
|
+
let junitEntries;
|
|
137
|
+
try {
|
|
138
|
+
junitEntries = await promises_1.default.readdir(junitDirectory);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
|
144
|
+
const entries = [];
|
|
145
|
+
for (const xmlFile of xmlFiles) {
|
|
146
|
+
const fileResults = await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile));
|
|
147
|
+
for (const result of fileResults) {
|
|
148
|
+
entries.push({ result, sourceFile: xmlFile });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const byPath = new Map();
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
|
|
154
|
+
// skip (rather than emit an undefined path) if that contract is violated.
|
|
155
|
+
const flowPath = entry.result.file;
|
|
156
|
+
if (flowPath === undefined) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const group = byPath.get(flowPath) ?? [];
|
|
160
|
+
group.push(entry);
|
|
161
|
+
byPath.set(flowPath, group);
|
|
162
|
+
}
|
|
163
|
+
const results = [];
|
|
164
|
+
for (const [flowPath, group] of byPath) {
|
|
165
|
+
// retryCount is derived from each entry's source filename (`attempt-N`);
|
|
166
|
+
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
|
167
|
+
const sorted = group
|
|
168
|
+
.map(entry => {
|
|
169
|
+
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
|
170
|
+
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
171
|
+
return { ...entry, attemptIndex };
|
|
172
|
+
})
|
|
173
|
+
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
|
174
|
+
for (const { result, attemptIndex } of sorted) {
|
|
175
|
+
results.push({
|
|
176
|
+
name: result.name,
|
|
177
|
+
path: flowPath,
|
|
178
|
+
status: result.status,
|
|
179
|
+
errorMessage: result.errorMessage,
|
|
180
|
+
duration: result.duration,
|
|
181
|
+
retryCount: attemptIndex,
|
|
182
|
+
tags: result.tags,
|
|
183
|
+
properties: result.properties,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Returns the `file=` paths of the failing testcases in the given attempt's
|
|
191
|
+
* JUnit file, or `null` when the result cannot be trusted (caller then falls
|
|
192
|
+
* back to dumb retry — re-run everything; never the legacy scan).
|
|
193
|
+
*
|
|
194
|
+
* Callers verify the report carries `file=` attributes first
|
|
195
|
+
* (junitFileHasFileAttrs).
|
|
196
|
+
*/
|
|
197
|
+
async function parseFailedFlowsFromFileAttrs(args) {
|
|
198
|
+
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
|
|
199
|
+
// truncated XML could drop testcases, and trusting that partial failing set
|
|
200
|
+
// would skip retries for cut-off flows.
|
|
201
|
+
let content;
|
|
202
|
+
try {
|
|
203
|
+
content = await promises_1.default.readFile(args.junitFile, 'utf-8');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (fast_xml_parser_1.XMLValidator.validate(content) !== true) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const testcases = parseJUnitContent(content);
|
|
212
|
+
if (!isFileAttrRun(testcases)) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const failing = testcases.filter(tc => tc.status === 'failed');
|
|
216
|
+
if (failing.length === 0) {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
// Each failing testcase is self-identifying via `file=`, so duplicate flow
|
|
220
|
+
// names retry correctly. path.resolve (not join): `file=` may be absolute
|
|
221
|
+
// when the flow lives outside the working directory.
|
|
222
|
+
const paths = [...new Set(failing.map(tc => tc.file))];
|
|
223
|
+
for (const p of paths) {
|
|
224
|
+
if (!(await fileExists(path_1.default.resolve(args.workingDirectory, p)))) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return paths;
|
|
229
|
+
}
|
|
106
230
|
async function parseMaestroResults(junitDirectory, nameToPath) {
|
|
107
231
|
let junitEntries;
|
|
108
232
|
try {
|
|
@@ -251,6 +375,10 @@ async function mergeJUnitReports(args) {
|
|
|
251
375
|
}
|
|
252
376
|
const match = filename.match(ATTEMPT_PATTERN);
|
|
253
377
|
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
378
|
+
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
|
|
379
|
+
// different files merge separately. Legacy reports keep name keying.
|
|
380
|
+
const allCases = testsuites.flatMap((suite) => Array.isArray(suite?.testcase) ? suite.testcase : []);
|
|
381
|
+
const useFileKeys = allCases.length > 0 && allCases.every((tc) => fileAttrOf(tc) !== undefined);
|
|
254
382
|
const testcasesByName = new Map();
|
|
255
383
|
for (const suite of testsuites) {
|
|
256
384
|
const cases = suite?.testcase;
|
|
@@ -262,9 +390,10 @@ async function mergeJUnitReports(args) {
|
|
|
262
390
|
if (typeof name !== 'string') {
|
|
263
391
|
continue;
|
|
264
392
|
}
|
|
265
|
-
const
|
|
393
|
+
const key = useFileKeys ? fileAttrOf(tc) : name;
|
|
394
|
+
const group = testcasesByName.get(key) ?? [];
|
|
266
395
|
group.push(tc);
|
|
267
|
-
testcasesByName.set(
|
|
396
|
+
testcasesByName.set(key, group);
|
|
268
397
|
}
|
|
269
398
|
}
|
|
270
399
|
if (testcasesByName.size === 0) {
|
|
@@ -157,20 +157,17 @@ function createMaestroTestsBuildFunction() {
|
|
|
157
157
|
catch (err) {
|
|
158
158
|
throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
|
|
159
159
|
}
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
projectRoot: stepCtx.workingDirectory,
|
|
165
|
-
logger,
|
|
166
|
-
});
|
|
160
|
+
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
|
161
|
+
// flow scan is built lazily in the retry branch below and memoized so
|
|
162
|
+
// retries share one scan. Never runs when the report has `file=`.
|
|
163
|
+
let nameToPathPromise;
|
|
167
164
|
// Retry loop. spawn-async error shapes:
|
|
168
165
|
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
|
169
166
|
// numeric err.status → maestro exited non-zero → retry.
|
|
170
167
|
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
|
171
168
|
// downgraded to "tests failed".
|
|
172
169
|
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
|
173
|
-
// flows.
|
|
170
|
+
// flows. The failed-flow parsers return null when the JUnit cannot be
|
|
174
171
|
// trusted; we then fall through to dumb retry (re-run everything).
|
|
175
172
|
let flowsToRun = flowPaths;
|
|
176
173
|
let lastAttemptExitCode = null;
|
|
@@ -213,11 +210,27 @@ function createMaestroTestsBuildFunction() {
|
|
|
213
210
|
if (lastAttemptExitCode === 0 || attempt === retries) {
|
|
214
211
|
break;
|
|
215
212
|
}
|
|
216
|
-
if (retryFailedOnly && outputFormat === 'junit' && outputPath
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
213
|
+
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
|
214
|
+
let failed;
|
|
215
|
+
if (await (0, maestroResultParser_1.junitFileHasFileAttrs)(outputPath)) {
|
|
216
|
+
failed = await (0, maestroResultParser_1.parseFailedFlowsFromFileAttrs)({
|
|
217
|
+
junitFile: outputPath,
|
|
218
|
+
workingDirectory: stepCtx.workingDirectory,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
|
223
|
+
// paths via the flow-file scan. DELETE this arm once the fleet is
|
|
224
|
+
// on >= 2.6.0.
|
|
225
|
+
const nameToPath = await (nameToPathPromise ??= (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
|
|
226
|
+
inputFlowPaths: flowPaths,
|
|
227
|
+
projectRoot: stepCtx.workingDirectory,
|
|
228
|
+
logger,
|
|
229
|
+
}));
|
|
230
|
+
failed = nameToPath
|
|
231
|
+
? await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({ junitFile: outputPath, nameToPath })
|
|
232
|
+
: null;
|
|
233
|
+
}
|
|
221
234
|
if (failed !== null && failed.length > 0) {
|
|
222
235
|
flowsToRun = failed;
|
|
223
236
|
logger.info(`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`);
|
|
@@ -13,7 +13,9 @@ export declare function resolveAndroidSigningOptionsAsync({ job, tmpDir, }: {
|
|
|
13
13
|
/**
|
|
14
14
|
* Resolves iOS signing options from the job secrets.
|
|
15
15
|
*/
|
|
16
|
-
export declare function resolveIosSigningOptionsAsync({ job, logger, }: {
|
|
16
|
+
export declare function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements, entitlementsPath, }: {
|
|
17
17
|
job: Job;
|
|
18
18
|
logger: bunyan;
|
|
19
|
+
useAppEntitlements?: boolean;
|
|
20
|
+
entitlementsPath?: string;
|
|
19
21
|
}): Promise<IosSigningOptions | undefined>;
|
|
@@ -48,6 +48,16 @@ function createRepackBuildFunction() {
|
|
|
48
48
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
|
|
49
49
|
required: false,
|
|
50
50
|
}),
|
|
51
|
+
steps_1.BuildStepInput.createProvider({
|
|
52
|
+
id: 'ios_signing_use_source_app_entitlements',
|
|
53
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
|
|
54
|
+
required: false,
|
|
55
|
+
}),
|
|
56
|
+
steps_1.BuildStepInput.createProvider({
|
|
57
|
+
id: 'ios_signing_app_entitlements_path',
|
|
58
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
59
|
+
required: false,
|
|
60
|
+
}),
|
|
51
61
|
steps_1.BuildStepInput.createProvider({
|
|
52
62
|
id: 'repack_version',
|
|
53
63
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
@@ -116,6 +126,8 @@ function createRepackBuildFunction() {
|
|
|
116
126
|
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
|
117
127
|
job: stepsCtx.global.staticContext.job,
|
|
118
128
|
logger: stepsCtx.logger,
|
|
129
|
+
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value,
|
|
130
|
+
entitlementsPath: inputs.ios_signing_app_entitlements_path.value,
|
|
119
131
|
}),
|
|
120
132
|
logger: stepsCtx.logger,
|
|
121
133
|
spawnAsync: repackSpawnAsync,
|
|
@@ -219,7 +231,7 @@ async function resolveAndroidSigningOptionsAsync({ job, tmpDir, }) {
|
|
|
219
231
|
/**
|
|
220
232
|
* Resolves iOS signing options from the job secrets.
|
|
221
233
|
*/
|
|
222
|
-
async function resolveIosSigningOptionsAsync({ job, logger, }) {
|
|
234
|
+
async function resolveIosSigningOptionsAsync({ job, logger, useAppEntitlements, entitlementsPath, }) {
|
|
223
235
|
const iosJob = job;
|
|
224
236
|
const buildCredentials = iosJob.secrets?.buildCredentials;
|
|
225
237
|
if (iosJob.simulator || buildCredentials == null) {
|
|
@@ -235,5 +247,7 @@ async function resolveIosSigningOptionsAsync({ job, logger, }) {
|
|
|
235
247
|
provisioningProfile,
|
|
236
248
|
keychainPath: credentials.keychainPath,
|
|
237
249
|
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
|
|
250
|
+
useAppEntitlements,
|
|
251
|
+
entitlementsPath,
|
|
238
252
|
};
|
|
239
253
|
}
|
|
@@ -57,41 +57,60 @@ function createReportMaestroTestResultsFunction(ctx) {
|
|
|
57
57
|
logger.info('No JUnit directory provided, skipping test results report');
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
logger,
|
|
69
|
-
});
|
|
60
|
+
try {
|
|
61
|
+
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
|
|
62
|
+
// `file=` attribute; older reports need the legacy flow-file scan to
|
|
63
|
+
// map testcase names back to paths.
|
|
64
|
+
const usedFileAttrs = (0, maestroResultParser_1.isFileAttrRun)(await (0, maestroResultParser_1.parseJUnitTestCases)(junitDirectory));
|
|
65
|
+
let flowResults;
|
|
66
|
+
if (usedFileAttrs) {
|
|
67
|
+
flowResults = await (0, maestroResultParser_1.parseMaestroResultsFromFileAttrs)(junitDirectory);
|
|
70
68
|
}
|
|
71
69
|
else {
|
|
72
|
-
|
|
70
|
+
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
|
|
71
|
+
const flowPathRaw = inputs.flow_path.value;
|
|
72
|
+
let nameToPath = null;
|
|
73
|
+
if (flowPathRaw !== undefined) {
|
|
74
|
+
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
|
75
|
+
if (parsed.success) {
|
|
76
|
+
nameToPath = await (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
|
|
77
|
+
inputFlowPaths: parsed.data,
|
|
78
|
+
projectRoot: stepsCtx.workingDirectory,
|
|
79
|
+
logger,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
logger.warn('Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, nameToPath);
|
|
73
87
|
}
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
const flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, nameToPath);
|
|
77
88
|
if (flowResults.length === 0) {
|
|
78
89
|
logger.info('No maestro test results found, skipping report');
|
|
79
90
|
return;
|
|
80
91
|
}
|
|
81
|
-
// Detect truly conflicting results: same (
|
|
82
|
-
//
|
|
83
|
-
// Same
|
|
92
|
+
// Detect truly conflicting results: the same (path, retryCount) twice means two
|
|
93
|
+
// flow files resolved to the same path, which we can't disambiguate and the API
|
|
94
|
+
// rejects as a duplicate. Same path with different retryCount is expected
|
|
95
|
+
// (per-attempt results from retries).
|
|
84
96
|
const seen = new Set();
|
|
85
97
|
const conflicting = new Set();
|
|
86
98
|
for (const r of flowResults) {
|
|
87
|
-
const key = `${r.
|
|
99
|
+
const key = `${r.path}:${r.retryCount}`;
|
|
88
100
|
if (seen.has(key)) {
|
|
89
|
-
conflicting.add(r.
|
|
101
|
+
conflicting.add(r.path);
|
|
90
102
|
}
|
|
91
103
|
seen.add(key);
|
|
92
104
|
}
|
|
93
105
|
if (conflicting.size > 0) {
|
|
94
|
-
|
|
106
|
+
const conflictList = [...conflicting].join(', ');
|
|
107
|
+
logger.error(usedFileAttrs
|
|
108
|
+
? `The same flow file was reported more than once in a single attempt: ` +
|
|
109
|
+
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
|
|
110
|
+
`or leftover XML files in the JUnit report directory.`
|
|
111
|
+
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
|
|
112
|
+
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
|
|
113
|
+
`then identified by file path, so duplicate names are fine).`);
|
|
95
114
|
return;
|
|
96
115
|
}
|
|
97
116
|
const testCaseResults = flowResults.flatMap(f => {
|
|
@@ -159,12 +159,15 @@ async function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets,
|
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
// Enable build cache via user-level gradle.properties.
|
|
163
|
+
// This avoids mutating android/gradle.properties which affects fingerprinting.
|
|
164
|
+
const gradleUserHome = path_1.default.join(os_1.default.homedir(), '.gradle');
|
|
165
|
+
await fs_1.default.promises.mkdir(gradleUserHome, { recursive: true });
|
|
166
|
+
const userGradlePropertiesPath = path_1.default.join(gradleUserHome, 'gradle.properties');
|
|
167
|
+
await fs_1.default.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
|
|
168
|
+
// Configure cache cleanup via init script.
|
|
169
|
+
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
|
|
170
|
+
const initScriptDir = path_1.default.join(gradleUserHome, 'init.d');
|
|
168
171
|
await fs_1.default.promises.mkdir(initScriptDir, { recursive: true });
|
|
169
172
|
await fs_1.default.promises.writeFile(path_1.default.join(initScriptDir, 'eas-cache-cleanup.gradle'), [
|
|
170
173
|
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { BuildFunction } from '@expo/steps';
|
|
2
|
-
import { CustomBuildContext } from '../../customBuildContext';
|
|
2
|
+
import { type CustomBuildContext } from '../../customBuildContext';
|
|
3
3
|
export declare function createStartAgentDeviceRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
|
|
@@ -7,9 +7,12 @@ exports.createStartAgentDeviceRemoteSessionBuildFunction = createStartAgentDevic
|
|
|
7
7
|
const eas_build_job_1 = require("@expo/eas-build-job");
|
|
8
8
|
const steps_1 = require("@expo/steps");
|
|
9
9
|
const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
11
|
const node_os_1 = __importDefault(require("node:os"));
|
|
11
12
|
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const sentry_1 = require("../../sentry");
|
|
12
14
|
const remoteDeviceRunSession_1 = require("../utils/remoteDeviceRunSession");
|
|
15
|
+
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
|
|
13
16
|
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
|
14
17
|
const SRC_DIR = '/tmp/agent-device-src';
|
|
15
18
|
const DAEMON_JSON_PATH = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device', 'daemon.json');
|
|
@@ -43,29 +46,11 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
|
43
46
|
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
|
44
47
|
await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
|
45
48
|
}
|
|
46
|
-
logger.info(packageVersion
|
|
47
|
-
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
|
48
|
-
: `Cloning agent-device (latest) into ${SRC_DIR}.`);
|
|
49
|
-
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
|
50
|
-
logger.info('Installing agent-device dependencies.');
|
|
51
|
-
await (0, turtle_spawn_1.default)('bun', ['install', '--production'], {
|
|
52
|
-
cwd: SRC_DIR,
|
|
53
|
-
env,
|
|
54
|
-
logger,
|
|
55
|
-
});
|
|
56
49
|
logger.info('Launching agent-device daemon.');
|
|
57
|
-
(
|
|
58
|
-
command: 'bun',
|
|
59
|
-
args: ['run', 'src/daemon.ts'],
|
|
60
|
-
cwd: SRC_DIR,
|
|
61
|
-
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
|
62
|
-
});
|
|
50
|
+
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
|
|
63
51
|
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
|
64
|
-
const { port: daemonPort, token: daemonToken } = await (
|
|
65
|
-
|
|
66
|
-
timeoutMs: STARTUP_TIMEOUT_MS,
|
|
67
|
-
description: 'agent-device daemon credentials',
|
|
68
|
-
parse: parseDaemonInfo,
|
|
52
|
+
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
|
|
53
|
+
daemonProcess,
|
|
69
54
|
});
|
|
70
55
|
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
|
71
56
|
const agentDeviceRemoteSessionUrl = await (0, remoteDeviceRunSession_1.startNgrokTunnelAsync)({
|
|
@@ -80,7 +65,7 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
|
80
65
|
// on Darwin. Android sessions go without a preview URL.
|
|
81
66
|
let webPreviewUrl;
|
|
82
67
|
if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
|
|
83
|
-
const { previewUrl } = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)({
|
|
68
|
+
const { previewUrl } = await (0, remoteDeviceRunSession_1.startServeSimWithTunnelAsync)(ctx, {
|
|
84
69
|
baseDomain: ngrokTunnelDomain,
|
|
85
70
|
env,
|
|
86
71
|
logger,
|
|
@@ -106,6 +91,64 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
|
106
91
|
},
|
|
107
92
|
});
|
|
108
93
|
}
|
|
94
|
+
async function startAgentDeviceDaemonAsync({ packageVersion, env, logger, }) {
|
|
95
|
+
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
|
96
|
+
try {
|
|
97
|
+
logger.info(`Installing ${packageSpec} globally with Bun.`);
|
|
98
|
+
await (0, turtle_spawn_1.default)('bun', ['add', '--global', packageSpec], {
|
|
99
|
+
env,
|
|
100
|
+
logger,
|
|
101
|
+
});
|
|
102
|
+
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
|
|
103
|
+
if (!node_fs_1.default.existsSync(daemonPath)) {
|
|
104
|
+
throw new eas_build_job_1.SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
|
105
|
+
}
|
|
106
|
+
logger.info(`Launching daemon from ${daemonPath}.`);
|
|
107
|
+
return (0, remoteDeviceRunSession_1.spawnDetached)({
|
|
108
|
+
command: 'node',
|
|
109
|
+
args: [daemonPath],
|
|
110
|
+
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
115
|
+
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
|
116
|
+
sentry_1.Sentry.capture('Failed to start agent-device daemon from global Bun package; falling back to git clone', error, {
|
|
117
|
+
level: 'warning',
|
|
118
|
+
tags: {
|
|
119
|
+
phase: 'agent-device-daemon-start',
|
|
120
|
+
fallback: 'git-clone',
|
|
121
|
+
},
|
|
122
|
+
extras: {
|
|
123
|
+
packageSpec,
|
|
124
|
+
packageVersion: packageVersion ?? 'latest',
|
|
125
|
+
bunVersion,
|
|
126
|
+
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
logger.warn(`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`);
|
|
130
|
+
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, }) {
|
|
134
|
+
logger.info(packageVersion
|
|
135
|
+
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
|
136
|
+
: `Cloning agent-device (latest) into ${SRC_DIR}.`);
|
|
137
|
+
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
|
138
|
+
logger.info('Installing agent-device dependencies.');
|
|
139
|
+
await (0, turtle_spawn_1.default)('bun', ['install', '--production'], {
|
|
140
|
+
cwd: SRC_DIR,
|
|
141
|
+
env,
|
|
142
|
+
logger,
|
|
143
|
+
});
|
|
144
|
+
logger.info('Launching daemon from cloned agent-device source.');
|
|
145
|
+
return (0, remoteDeviceRunSession_1.spawnDetached)({
|
|
146
|
+
command: 'bun',
|
|
147
|
+
args: ['run', 'src/daemon.ts'],
|
|
148
|
+
cwd: SRC_DIR,
|
|
149
|
+
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
109
152
|
async function cloneAgentDeviceAsync({ packageVersion, env, logger, }) {
|
|
110
153
|
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
|
111
154
|
await (0, turtle_spawn_1.default)('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
|
@@ -113,6 +156,42 @@ async function cloneAgentDeviceAsync({ packageVersion, env, logger, }) {
|
|
|
113
156
|
logger,
|
|
114
157
|
});
|
|
115
158
|
}
|
|
159
|
+
async function waitForDaemonInfoAsync({ daemonProcess, }) {
|
|
160
|
+
try {
|
|
161
|
+
return await (0, remoteDeviceRunSession_1.waitForFileAsync)({
|
|
162
|
+
filePath: DAEMON_JSON_PATH,
|
|
163
|
+
timeoutMs: STARTUP_TIMEOUT_MS,
|
|
164
|
+
description: 'agent-device daemon credentials',
|
|
165
|
+
parse: parseDaemonInfo,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const output = daemonProcess.getOutput();
|
|
170
|
+
throw new eas_build_job_1.SystemError(`${err instanceof Error
|
|
171
|
+
? err.message
|
|
172
|
+
: `Timed out waiting for agent-device daemon credentials.`}${output ? `\nagent-device daemon output:\n${output}` : ''}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function getBunVersionForDiagnosticsAsync(env) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await (0, turtle_spawn_1.default)('bun', ['--version'], { stdio: 'pipe', env, cwd: node_os_1.default.tmpdir() });
|
|
178
|
+
return result.stdout.trim() || 'unknown';
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return 'unknown';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function createAgentDevicePackageSpec(packageVersion) {
|
|
185
|
+
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
|
|
186
|
+
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
|
187
|
+
}
|
|
188
|
+
function getGlobalAgentDeviceDaemonPath(env) {
|
|
189
|
+
return node_path_1.default.join(getBunInstallDirectory(env), 'install', 'global', 'node_modules', AGENT_DEVICE_PACKAGE_NAME, 'dist', 'src', 'internal', 'daemon.js');
|
|
190
|
+
}
|
|
191
|
+
function getBunInstallDirectory(env) {
|
|
192
|
+
const bunInstall = env.BUN_INSTALL?.trim();
|
|
193
|
+
return bunInstall ? bunInstall : node_path_1.default.join(node_os_1.default.homedir(), '.bun');
|
|
194
|
+
}
|
|
116
195
|
function parseDaemonInfo(raw) {
|
|
117
196
|
const parsed = JSON.parse(raw);
|
|
118
197
|
if (!parsed ||
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { type bunyan } from '@expo/logger';
|
|
1
2
|
import { BuildFunction } from '@expo/steps';
|
|
2
3
|
import { CustomBuildContext } from '../../customBuildContext';
|
|
3
4
|
export declare function createStartArgentRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
|
|
5
|
+
export declare function warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger, }: {
|
|
6
|
+
packageVersion: string | undefined;
|
|
7
|
+
logger: bunyan;
|
|
8
|
+
}): void;
|