@expo/build-tools 18.10.0 → 18.12.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/common/easBuildInternal.js +32 -27
- package/dist/ios/pod.js +10 -15
- package/dist/steps/easFunctions.js +1 -1
- package/dist/steps/functions/maestroFlowDiscovery.d.ts +9 -0
- package/dist/steps/functions/maestroFlowDiscovery.js +163 -0
- package/dist/steps/functions/maestroResultParser.d.ts +26 -22
- package/dist/steps/functions/maestroResultParser.js +195 -138
- package/dist/steps/functions/maestroTests.js +85 -19
- package/dist/steps/functions/reportMaestroTestResults.js +24 -2
- package/dist/steps/functions/restoreBuildCache.js +19 -1
- package/dist/steps/functions/startAgentDeviceRemoteSession.d.ts +2 -1
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +85 -16
- package/dist/steps/utils/ios/xcactivitylog.js +8 -7
- package/package.json +4 -4
|
@@ -28,33 +28,38 @@ async function runEasBuildInternalAsync({ job, logger, env, cwd, projectRootOver
|
|
|
28
28
|
else if (githubTriggerOptions?.autoSubmit) {
|
|
29
29
|
autoSubmitArgs.push('--auto-submit');
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
31
|
+
try {
|
|
32
|
+
const result = await (0, turtle_spawn_1.default)(cmd, [
|
|
33
|
+
...args,
|
|
34
|
+
'build:internal',
|
|
35
|
+
'--platform',
|
|
36
|
+
job.platform,
|
|
37
|
+
'--profile',
|
|
38
|
+
buildProfile,
|
|
39
|
+
...autoSubmitArgs,
|
|
40
|
+
], {
|
|
41
|
+
cwd,
|
|
42
|
+
env: {
|
|
43
|
+
...env,
|
|
44
|
+
EXPO_TOKEN: (0, nullthrows_1.default)(job.secrets, 'Secrets must be defined for non-custom builds')
|
|
45
|
+
.robotAccessToken,
|
|
46
|
+
...extraEnv,
|
|
47
|
+
EAS_PROJECT_ROOT: projectRootOverride,
|
|
48
|
+
},
|
|
49
|
+
logger,
|
|
50
|
+
// This prevents printing stdout with job secrets and credentials to logs.
|
|
51
|
+
mode: logger_1.PipeMode.STDERR_ONLY_AS_STDOUT,
|
|
52
|
+
});
|
|
53
|
+
const stdout = result.stdout.toString();
|
|
54
|
+
const parsed = JSON.parse(stdout);
|
|
55
|
+
return validateEasBuildInternalResult({
|
|
56
|
+
result: parsed,
|
|
57
|
+
oldJob: job,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
throw new Error('Failed to run eas build:internal');
|
|
62
|
+
}
|
|
58
63
|
}
|
|
59
64
|
async function resolveEnvFromBuildProfileAsync(ctx, { cwd }) {
|
|
60
65
|
const { cmd, args, extraEnv } = await (0, easCli_1.resolveEasCommandPrefixAndEnvAsync)();
|
package/dist/ios/pod.js
CHANGED
|
@@ -8,8 +8,8 @@ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
|
8
8
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const semver_1 = __importDefault(require("semver"));
|
|
11
|
-
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.
|
|
12
|
-
|
|
11
|
+
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
|
|
12
|
+
const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
|
13
13
|
async function installPods(ctx, { infoCallbackFn }) {
|
|
14
14
|
const iosDir = path_1.default.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
|
15
15
|
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
|
@@ -58,10 +58,9 @@ async function resolvePrecompiledModulesPodInstallEnvAsync(ctx) {
|
|
|
58
58
|
ctx.logger.info(`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`);
|
|
59
59
|
return {};
|
|
60
60
|
}
|
|
61
|
-
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
|
|
62
61
|
const env = {
|
|
63
62
|
EXPO_USE_PRECOMPILED_MODULES: '1',
|
|
64
|
-
|
|
63
|
+
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
|
|
65
64
|
};
|
|
66
65
|
ctx.logger.info(`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(env)
|
|
67
66
|
.map(([key, value]) => `${key}=${value}`)
|
|
@@ -77,14 +76,10 @@ async function getInstalledExpoPackageVersionAsync(ctx) {
|
|
|
77
76
|
const expoPackageJsonPath = stdout.toString().trim();
|
|
78
77
|
return (await fs_extra_1.default.readJson(expoPackageJsonPath)).version;
|
|
79
78
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// `${parsedUrl.protocol}//${parsedUrl.host}`,
|
|
88
|
-
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
|
89
|
-
// );
|
|
90
|
-
// }
|
|
79
|
+
function getPrecompiledModulesBaseUrl(ctx) {
|
|
80
|
+
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
|
81
|
+
return PRECOMPILED_MODULES_BASE_URL;
|
|
82
|
+
}
|
|
83
|
+
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
|
84
|
+
return PRECOMPILED_MODULES_BASE_URL.replace(`${parsedUrl.protocol}//${parsedUrl.host}`, `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`);
|
|
85
|
+
}
|
|
@@ -75,7 +75,7 @@ function getEasFunctions(ctx) {
|
|
|
75
75
|
(0, generateGymfileFromTemplate_1.generateGymfileFromTemplateFunction)(),
|
|
76
76
|
(0, runFastlane_1.runFastlaneFunction)(),
|
|
77
77
|
(0, parseXcactivitylog_1.parseXcactivitylogFunction)(),
|
|
78
|
-
(0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(),
|
|
78
|
+
(0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(ctx),
|
|
79
79
|
(0, startAndroidEmulator_1.createStartAndroidEmulatorBuildFunction)(),
|
|
80
80
|
(0, startCuttlefishDevice_1.createStartCuttlefishDeviceBuildFunction)(),
|
|
81
81
|
(0, startIosSimulator_1.createStartIosSimulatorBuildFunction)(),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { bunyan } from '@expo/logger';
|
|
2
|
+
export interface BuildFlowNameToPathMapArgs {
|
|
3
|
+
inputFlowPaths: string[];
|
|
4
|
+
projectRoot: string;
|
|
5
|
+
logger: bunyan;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildFlowNameToPathMap(args: BuildFlowNameToPathMapArgs): Promise<Map<string, string> | null>;
|
|
8
|
+
export declare function walkDir(dir: string, visited: Set<string>, out: string[], logger: bunyan): Promise<void>;
|
|
9
|
+
export declare function readFlowName(absFile: string): Promise<string>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildFlowNameToPathMap = buildFlowNameToPathMap;
|
|
7
|
+
exports.walkDir = walkDir;
|
|
8
|
+
exports.readFlowName = readFlowName;
|
|
9
|
+
const results_1 = require("@expo/results");
|
|
10
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const yaml_1 = __importDefault(require("yaml"));
|
|
13
|
+
const YAML_EXT = /\.ya?ml$/i;
|
|
14
|
+
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
|
|
15
|
+
async function buildFlowNameToPathMap(args) {
|
|
16
|
+
try {
|
|
17
|
+
const flows = await discoverFlows(args);
|
|
18
|
+
return dedupAndDetectDuplicates(flows, args.logger);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function discoverFlows({ inputFlowPaths, projectRoot, logger, }) {
|
|
26
|
+
const realRootResult = await (0, results_1.asyncResult)(promises_1.default.realpath(projectRoot));
|
|
27
|
+
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
|
|
28
|
+
const fileLists = await Promise.all(inputFlowPaths.map(async (input) => {
|
|
29
|
+
const abs = path_1.default.resolve(projectRoot, input);
|
|
30
|
+
const statResult = await (0, results_1.asyncResult)(promises_1.default.stat(abs));
|
|
31
|
+
if (!statResult.ok) {
|
|
32
|
+
logger.warn(`flow_path entry "${input}" not found, skipping`);
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const stat = statResult.value;
|
|
36
|
+
if (stat.isFile() && YAML_EXT.test(abs)) {
|
|
37
|
+
return [abs];
|
|
38
|
+
}
|
|
39
|
+
if (stat.isDirectory()) {
|
|
40
|
+
const out = [];
|
|
41
|
+
await walkDir(abs, new Set(), out, logger);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}));
|
|
46
|
+
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
|
|
47
|
+
}
|
|
48
|
+
async function makeEntry(absFile, realRoot) {
|
|
49
|
+
const [name, realFileResult] = await Promise.all([
|
|
50
|
+
readFlowName(absFile),
|
|
51
|
+
(0, results_1.asyncResult)(promises_1.default.realpath(absFile)),
|
|
52
|
+
]);
|
|
53
|
+
const realFile = realFileResult.ok ? realFileResult.value : absFile;
|
|
54
|
+
const rel = path_1.default.relative(realRoot, realFile);
|
|
55
|
+
const value = rel.startsWith('..') || path_1.default.isAbsolute(rel) ? absFile : rel;
|
|
56
|
+
return { name, path: value, realpath: realFile };
|
|
57
|
+
}
|
|
58
|
+
function dedupAndDetectDuplicates(flows, logger) {
|
|
59
|
+
const byKey = new Map();
|
|
60
|
+
for (const f of flows) {
|
|
61
|
+
if (!byKey.has(f.realpath)) {
|
|
62
|
+
byKey.set(f.realpath, f);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const unique = [...byKey.values()];
|
|
66
|
+
const nameToPath = new Map();
|
|
67
|
+
const duplicates = new Map();
|
|
68
|
+
for (const f of unique) {
|
|
69
|
+
if (nameToPath.has(f.name)) {
|
|
70
|
+
const existing = nameToPath.get(f.name);
|
|
71
|
+
const list = duplicates.get(f.name) ?? [existing];
|
|
72
|
+
list.push(f.path);
|
|
73
|
+
duplicates.set(f.name, list);
|
|
74
|
+
}
|
|
75
|
+
nameToPath.set(f.name, f.path);
|
|
76
|
+
}
|
|
77
|
+
if (duplicates.size > 0) {
|
|
78
|
+
for (const [name, paths] of duplicates) {
|
|
79
|
+
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
|
|
80
|
+
}
|
|
81
|
+
logger.warn('Retry-failed-only is disabled for this run; will retry all flows on failure. ' +
|
|
82
|
+
'Give each Maestro flow a unique name (file basename or top-level name) to enable retry-failed-only.');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return nameToPath;
|
|
86
|
+
}
|
|
87
|
+
async function walkDir(dir, visited, out, logger) {
|
|
88
|
+
// Cycle protection: dedup on realpath (fall back to the original path when
|
|
89
|
+
// realpath fails so we still make progress on missing or restricted dirs).
|
|
90
|
+
const realResult = await (0, results_1.asyncResult)(promises_1.default.realpath(dir));
|
|
91
|
+
const real = realResult.ok ? realResult.value : dir;
|
|
92
|
+
if (visited.has(real)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
visited.add(real);
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = await promises_1.default.readdir(dir, { withFileTypes: true });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const full = path_1.default.join(dir, entry.name);
|
|
107
|
+
if (entry.isSymbolicLink()) {
|
|
108
|
+
const tgtResult = await (0, results_1.asyncResult)(promises_1.default.stat(full));
|
|
109
|
+
if (!tgtResult.ok) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const tgt = tgtResult.value;
|
|
113
|
+
if (tgt.isDirectory()) {
|
|
114
|
+
await walkDir(full, visited, out, logger);
|
|
115
|
+
}
|
|
116
|
+
else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
|
117
|
+
out.push(full);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (entry.isDirectory()) {
|
|
121
|
+
await walkDir(full, visited, out, logger);
|
|
122
|
+
}
|
|
123
|
+
else if (entry.isFile()) {
|
|
124
|
+
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
|
125
|
+
out.push(full);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function readFlowName(absFile) {
|
|
131
|
+
const ext = path_1.default.extname(absFile);
|
|
132
|
+
const fallback = path_1.default.basename(absFile, ext);
|
|
133
|
+
let content;
|
|
134
|
+
try {
|
|
135
|
+
content = await promises_1.default.readFile(absFile, 'utf-8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return fallback;
|
|
139
|
+
}
|
|
140
|
+
let firstDoc;
|
|
141
|
+
try {
|
|
142
|
+
const docs = yaml_1.default.parseAllDocuments(content);
|
|
143
|
+
firstDoc = docs[0];
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return fallback;
|
|
147
|
+
}
|
|
148
|
+
if (!firstDoc || firstDoc.errors.length > 0) {
|
|
149
|
+
return fallback;
|
|
150
|
+
}
|
|
151
|
+
const parsed = firstDoc.toJS();
|
|
152
|
+
const name = parsed?.name;
|
|
153
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
154
|
+
return name;
|
|
155
|
+
}
|
|
156
|
+
return fallback;
|
|
157
|
+
}
|
|
158
|
+
function isYamlFile(name) {
|
|
159
|
+
return YAML_EXT.test(name);
|
|
160
|
+
}
|
|
161
|
+
function isWorkspaceConfig(name) {
|
|
162
|
+
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
|
|
163
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
1
|
export interface MaestroFlowResult {
|
|
3
2
|
name: string;
|
|
4
3
|
path: string;
|
|
@@ -9,7 +8,6 @@ export interface MaestroFlowResult {
|
|
|
9
8
|
tags: string[];
|
|
10
9
|
properties: Record<string, string>;
|
|
11
10
|
}
|
|
12
|
-
export declare function extractFlowKey(filename: string, prefix: string): string | null;
|
|
13
11
|
export interface JUnitTestCaseResult {
|
|
14
12
|
name: string;
|
|
15
13
|
status: 'passed' | 'failed';
|
|
@@ -19,31 +17,38 @@ export interface JUnitTestCaseResult {
|
|
|
19
17
|
properties: Record<string, string>;
|
|
20
18
|
}
|
|
21
19
|
export declare function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]>;
|
|
22
|
-
declare
|
|
23
|
-
flow_name: z.ZodString;
|
|
24
|
-
flow_file_path: z.ZodString;
|
|
25
|
-
}, z.core.$strip>;
|
|
26
|
-
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
|
|
20
|
+
export declare function parseMaestroResults(junitDirectory: string, nameToPath: Map<string, string> | null): Promise<MaestroFlowResult[]>;
|
|
27
21
|
/**
|
|
28
|
-
*
|
|
22
|
+
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
|
23
|
+
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
|
24
|
+
* then falls back to dumb retry — re-run everything).
|
|
29
25
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
* Caller passes a precomputed `nameToPath` map built from the user's
|
|
27
|
+
* `inputFlowPaths`. We translate failed testcase names to flow paths via
|
|
28
|
+
* that map; unknown names → null.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseFailedFlowsFromJUnit(args: {
|
|
31
|
+
junitFile: string;
|
|
32
|
+
nameToPath: Map<string, string>;
|
|
33
|
+
}): Promise<string[] | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
|
36
|
+
* attempt's <testcase> per unique flow name (latest determined from the
|
|
37
|
+
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
|
38
|
+
* and writes a merged document to outputPath.
|
|
36
39
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
40
|
+
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
|
41
|
+
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
|
42
|
+
* failure rows around and produce a misleading merged report.
|
|
39
43
|
*/
|
|
40
|
-
export declare function
|
|
41
|
-
|
|
44
|
+
export declare function mergeJUnitReports(args: {
|
|
45
|
+
sourceDir: string;
|
|
46
|
+
outputPath: string;
|
|
47
|
+
}): Promise<void>;
|
|
42
48
|
/**
|
|
43
49
|
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* attempt" semantics.
|
|
50
|
+
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
|
51
|
+
* step still needs to produce final_report_path.
|
|
47
52
|
*
|
|
48
53
|
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
|
49
54
|
*/
|
|
@@ -51,4 +56,3 @@ export declare function copyLatestAttemptXml(args: {
|
|
|
51
56
|
sourceDir: string;
|
|
52
57
|
outputPath: string;
|
|
53
58
|
}): Promise<void>;
|
|
54
|
-
export {};
|
|
@@ -3,34 +3,25 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.extractFlowKey = extractFlowKey;
|
|
7
6
|
exports.parseJUnitTestCases = parseJUnitTestCases;
|
|
8
|
-
exports.parseFlowMetadata = parseFlowMetadata;
|
|
9
7
|
exports.parseMaestroResults = parseMaestroResults;
|
|
8
|
+
exports.parseFailedFlowsFromJUnit = parseFailedFlowsFromJUnit;
|
|
9
|
+
exports.mergeJUnitReports = mergeJUnitReports;
|
|
10
10
|
exports.copyLatestAttemptXml = copyLatestAttemptXml;
|
|
11
11
|
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
12
12
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
13
13
|
const path_1 = __importDefault(require("path"));
|
|
14
|
-
const zod_1 = require("zod");
|
|
15
|
-
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
|
16
|
-
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
|
17
14
|
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
|
18
15
|
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
|
19
|
-
function extractFlowKey(filename, prefix) {
|
|
20
|
-
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
|
21
|
-
return match?.[1] ?? null;
|
|
22
|
-
}
|
|
23
16
|
const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
24
17
|
ignoreAttributes: false,
|
|
25
18
|
attributeNamePrefix: '@_',
|
|
26
19
|
// Ensure single-element arrays are always arrays
|
|
27
20
|
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
|
28
21
|
});
|
|
29
|
-
|
|
30
|
-
async function parseJUnitFile(filePath) {
|
|
22
|
+
function parseJUnitContent(content) {
|
|
31
23
|
const results = [];
|
|
32
24
|
try {
|
|
33
|
-
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
34
25
|
const parsed = xmlParser.parse(content);
|
|
35
26
|
const testsuites = parsed?.testsuites?.testsuite;
|
|
36
27
|
if (!Array.isArray(testsuites)) {
|
|
@@ -84,10 +75,19 @@ async function parseJUnitFile(filePath) {
|
|
|
84
75
|
}
|
|
85
76
|
}
|
|
86
77
|
catch {
|
|
87
|
-
//
|
|
78
|
+
// Malformed XML — return whatever we collected before the parser bailed.
|
|
88
79
|
}
|
|
89
80
|
return results;
|
|
90
81
|
}
|
|
82
|
+
async function parseJUnitFile(filePath) {
|
|
83
|
+
try {
|
|
84
|
+
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
85
|
+
return parseJUnitContent(content);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
91
|
async function parseJUnitTestCases(junitDirectory) {
|
|
92
92
|
let entries;
|
|
93
93
|
try {
|
|
@@ -100,41 +100,10 @@ async function parseJUnitTestCases(junitDirectory) {
|
|
|
100
100
|
if (xmlFiles.length === 0) {
|
|
101
101
|
return [];
|
|
102
102
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
results.push(...(await parseJUnitFile(path_1.default.join(junitDirectory, xmlFile))));
|
|
106
|
-
}
|
|
107
|
-
return results;
|
|
103
|
+
const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
|
|
104
|
+
return perFile.flat();
|
|
108
105
|
}
|
|
109
|
-
|
|
110
|
-
flow_name: zod_1.z.string(),
|
|
111
|
-
flow_file_path: zod_1.z.string(),
|
|
112
|
-
});
|
|
113
|
-
/**
|
|
114
|
-
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
|
115
|
-
*
|
|
116
|
-
* The file contains:
|
|
117
|
-
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
|
|
118
|
-
* the flow filename without extension.
|
|
119
|
-
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
|
|
120
|
-
* - `flow_file_path`: absolute path to the original flow YAML file.
|
|
121
|
-
* - `outputs`: screenshot defect data (unused here).
|
|
122
|
-
*
|
|
123
|
-
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
|
|
124
|
-
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
|
|
125
|
-
*/
|
|
126
|
-
async function parseFlowMetadata(filePath) {
|
|
127
|
-
try {
|
|
128
|
-
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
129
|
-
const data = JSON.parse(content);
|
|
130
|
-
return FlowMetadataFileSchema.parse(data);
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot) {
|
|
137
|
-
// 1. Parse JUnit XML files, tracking which file each result came from
|
|
106
|
+
async function parseMaestroResults(junitDirectory, nameToPath) {
|
|
138
107
|
let junitEntries;
|
|
139
108
|
try {
|
|
140
109
|
junitEntries = await promises_1.default.readdir(junitDirectory);
|
|
@@ -156,44 +125,7 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
156
125
|
if (junitResultsWithSource.length === 0) {
|
|
157
126
|
return [];
|
|
158
127
|
}
|
|
159
|
-
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
|
|
160
|
-
const flowPathMap = new Map(); // flowName → flowFilePath
|
|
161
|
-
const flowOccurrences = new Map(); // flowName → count
|
|
162
|
-
let entries;
|
|
163
|
-
try {
|
|
164
|
-
entries = await promises_1.default.readdir(testsDirectory);
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
entries = [];
|
|
168
|
-
}
|
|
169
|
-
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
|
170
|
-
for (const dir of timestampDirs) {
|
|
171
|
-
const dirPath = path_1.default.join(testsDirectory, dir);
|
|
172
|
-
let files;
|
|
173
|
-
try {
|
|
174
|
-
files = await promises_1.default.readdir(dirPath);
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
for (const file of files) {
|
|
180
|
-
const flowKey = extractFlowKey(file, 'ai');
|
|
181
|
-
if (!flowKey) {
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
const metadata = await parseFlowMetadata(path_1.default.join(dirPath, file));
|
|
185
|
-
if (!metadata) {
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
// Track latest path (last timestamp dir wins)
|
|
189
|
-
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
|
|
190
|
-
// Count occurrences for retryCount
|
|
191
|
-
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// 3. Merge: JUnit results + ai-*.json metadata
|
|
195
128
|
const results = [];
|
|
196
|
-
// Group results by flow name
|
|
197
129
|
const resultsByName = new Map();
|
|
198
130
|
for (const entry of junitResultsWithSource) {
|
|
199
131
|
const group = resultsByName.get(entry.result.name) ?? [];
|
|
@@ -201,78 +133,202 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
201
133
|
resultsByName.set(entry.result.name, group);
|
|
202
134
|
}
|
|
203
135
|
for (const [flowName, flowEntries] of resultsByName) {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
136
|
+
const flowPath = nameToPath?.get(flowName) ?? flowName;
|
|
137
|
+
// retryCount is derived from each entry's source filename (`attempt-N`);
|
|
138
|
+
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
|
139
|
+
const sorted = flowEntries
|
|
140
|
+
.map(entry => {
|
|
141
|
+
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
|
142
|
+
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
143
|
+
return { ...entry, attemptIndex };
|
|
144
|
+
})
|
|
145
|
+
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
|
146
|
+
for (const { result, attemptIndex } of sorted) {
|
|
214
147
|
results.push({
|
|
215
148
|
name: flowName,
|
|
216
|
-
path:
|
|
149
|
+
path: flowPath,
|
|
217
150
|
status: result.status,
|
|
218
151
|
errorMessage: result.errorMessage,
|
|
219
152
|
duration: result.duration,
|
|
220
|
-
retryCount,
|
|
153
|
+
retryCount: attemptIndex,
|
|
221
154
|
tags: result.tags,
|
|
222
155
|
properties: result.properties,
|
|
223
156
|
});
|
|
224
157
|
}
|
|
225
|
-
else {
|
|
226
|
-
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
|
|
227
|
-
const sorted = flowEntries
|
|
228
|
-
.map(entry => {
|
|
229
|
-
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
|
230
|
-
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
231
|
-
return { ...entry, attemptIndex };
|
|
232
|
-
})
|
|
233
|
-
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
|
234
|
-
for (const { result, attemptIndex } of sorted) {
|
|
235
|
-
results.push({
|
|
236
|
-
name: flowName,
|
|
237
|
-
path: relativePath,
|
|
238
|
-
status: result.status,
|
|
239
|
-
errorMessage: result.errorMessage,
|
|
240
|
-
duration: result.duration,
|
|
241
|
-
retryCount: attemptIndex,
|
|
242
|
-
tags: result.tags,
|
|
243
|
-
properties: result.properties,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
158
|
}
|
|
248
159
|
return results;
|
|
249
160
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
|
163
|
+
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
|
164
|
+
* then falls back to dumb retry — re-run everything).
|
|
165
|
+
*
|
|
166
|
+
* Caller passes a precomputed `nameToPath` map built from the user's
|
|
167
|
+
* `inputFlowPaths`. We translate failed testcase names to flow paths via
|
|
168
|
+
* that map; unknown names → null.
|
|
169
|
+
*/
|
|
170
|
+
async function parseFailedFlowsFromJUnit(args) {
|
|
171
|
+
// fast-xml-parser is lenient — truncated XML can produce a partial parse
|
|
172
|
+
// with some testcases dropped. Trusting that subset for retry-failed-only would
|
|
173
|
+
// skip retries for cut-off flows; reject any malformed XML and fall back
|
|
174
|
+
// to dumb retry.
|
|
175
|
+
let content;
|
|
257
176
|
try {
|
|
258
|
-
|
|
177
|
+
content = await promises_1.default.readFile(args.junitFile, 'utf-8');
|
|
259
178
|
}
|
|
260
|
-
catch {
|
|
261
|
-
|
|
262
|
-
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (fast_xml_parser_1.XMLValidator.validate(content) !== true) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const testcases = parseJUnitContent(content);
|
|
186
|
+
if (testcases.length === 0) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const failing = testcases.filter(tc => tc.status === 'failed');
|
|
190
|
+
if (failing.length === 0) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
// Two testcases with the same name (pass+fail or fail+fail) make it
|
|
194
|
+
// impossible to map back to a single input flow_path. Signal "unknown".
|
|
195
|
+
// Complementary to the map-build duplicate check (which catches the case
|
|
196
|
+
// where tag filter hides duplication from JUnit).
|
|
197
|
+
const allNameCounts = new Map();
|
|
198
|
+
for (const tc of testcases) {
|
|
199
|
+
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
for (const count of allNameCounts.values()) {
|
|
202
|
+
if (count > 1) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
263
205
|
}
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
206
|
+
const matched = [];
|
|
207
|
+
for (const tc of failing) {
|
|
208
|
+
const p = args.nameToPath.get(tc.name);
|
|
209
|
+
if (p === undefined) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
matched.push(p);
|
|
213
|
+
}
|
|
214
|
+
return matched;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
|
218
|
+
* attempt's <testcase> per unique flow name (latest determined from the
|
|
219
|
+
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
|
220
|
+
* and writes a merged document to outputPath.
|
|
221
|
+
*
|
|
222
|
+
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
|
223
|
+
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
|
224
|
+
* failure rows around and produce a misleading merged report.
|
|
225
|
+
*/
|
|
226
|
+
async function mergeJUnitReports(args) {
|
|
227
|
+
const entries = await promises_1.default.readdir(args.sourceDir);
|
|
228
|
+
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
|
229
|
+
if (xmlFiles.length === 0) {
|
|
230
|
+
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
|
|
231
|
+
}
|
|
232
|
+
const contents = await Promise.all(xmlFiles.map(async (f) => ({
|
|
233
|
+
filename: f,
|
|
234
|
+
content: await promises_1.default.readFile(path_1.default.join(args.sourceDir, f), 'utf-8'),
|
|
235
|
+
})));
|
|
236
|
+
const fileGroups = [];
|
|
237
|
+
for (const { filename, content } of contents) {
|
|
238
|
+
if (fast_xml_parser_1.XMLValidator.validate(content) !== true) {
|
|
239
|
+
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
|
|
240
|
+
}
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = xmlParser.parse(content);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
|
|
247
|
+
}
|
|
248
|
+
const testsuites = parsed?.testsuites?.testsuite;
|
|
249
|
+
if (!Array.isArray(testsuites)) {
|
|
250
|
+
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
|
|
251
|
+
}
|
|
252
|
+
const match = filename.match(ATTEMPT_PATTERN);
|
|
253
|
+
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
254
|
+
const testcasesByName = new Map();
|
|
255
|
+
for (const suite of testsuites) {
|
|
256
|
+
const cases = suite?.testcase;
|
|
257
|
+
if (!Array.isArray(cases)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
for (const tc of cases) {
|
|
261
|
+
const name = tc?.['@_name'];
|
|
262
|
+
if (typeof name !== 'string') {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const group = testcasesByName.get(name) ?? [];
|
|
266
|
+
group.push(tc);
|
|
267
|
+
testcasesByName.set(name, group);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (testcasesByName.size === 0) {
|
|
271
|
+
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
|
|
272
|
+
}
|
|
273
|
+
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
|
|
274
|
+
}
|
|
275
|
+
// Single attempt: copy the original XML so suite-level metadata (testsuite
|
|
276
|
+
// attributes, <system-out>, etc.) survives. The rebuild path below would
|
|
277
|
+
// collapse those to a single attribute-less <testsuite>.
|
|
278
|
+
if (fileGroups.length === 1) {
|
|
279
|
+
await promises_1.default.writeFile(args.outputPath, fileGroups[0].content);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// For each unique name, pick the file with the highest attempt index that
|
|
283
|
+
// contains it (ties broken by sorted filename — later wins). Preserve every
|
|
284
|
+
// <testcase> element from the winning file for that name, so same-attempt
|
|
285
|
+
// duplicates survive.
|
|
286
|
+
const nameToWinningFile = new Map();
|
|
287
|
+
for (const group of fileGroups) {
|
|
288
|
+
for (const name of group.testcasesByName.keys()) {
|
|
289
|
+
const current = nameToWinningFile.get(name);
|
|
290
|
+
if (!current || group.attemptIndex >= current.attemptIndex) {
|
|
291
|
+
nameToWinningFile.set(name, group);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Emit in first-seen order (iteration over `fileGroups` yields stable order
|
|
296
|
+
// matching the sorted filename list).
|
|
297
|
+
const testcases = [];
|
|
298
|
+
const emitted = new Set();
|
|
299
|
+
for (const group of fileGroups) {
|
|
300
|
+
for (const [name, cases] of group.testcasesByName) {
|
|
301
|
+
if (emitted.has(name)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const winner = nameToWinningFile.get(name);
|
|
305
|
+
if (winner === group) {
|
|
306
|
+
for (const tc of cases) {
|
|
307
|
+
testcases.push(tc);
|
|
308
|
+
}
|
|
309
|
+
emitted.add(name);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
268
312
|
}
|
|
269
|
-
|
|
313
|
+
const builder = new fast_xml_parser_1.XMLBuilder({
|
|
314
|
+
ignoreAttributes: false,
|
|
315
|
+
attributeNamePrefix: '@_',
|
|
316
|
+
format: true,
|
|
317
|
+
suppressEmptyNode: true,
|
|
318
|
+
});
|
|
319
|
+
const xml = builder.build({
|
|
320
|
+
testsuites: {
|
|
321
|
+
testsuite: {
|
|
322
|
+
testcase: testcases,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
await promises_1.default.writeFile(args.outputPath, xml);
|
|
270
327
|
}
|
|
271
328
|
/**
|
|
272
329
|
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* attempt" semantics.
|
|
330
|
+
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
|
331
|
+
* step still needs to produce final_report_path.
|
|
276
332
|
*
|
|
277
333
|
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
|
278
334
|
*/
|
|
@@ -283,7 +339,8 @@ async function copyLatestAttemptXml(args) {
|
|
|
283
339
|
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
|
284
340
|
}
|
|
285
341
|
// Pick the file with the highest attempt index. Files without the marker are
|
|
286
|
-
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
|
342
|
+
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
|
343
|
+
// (same rule as mergeJUnitReports).
|
|
287
344
|
let winner = xmlFiles[0];
|
|
288
345
|
let winnerAttempt = (() => {
|
|
289
346
|
const m = winner.match(ATTEMPT_PATTERN);
|
|
@@ -10,6 +10,7 @@ const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
|
10
10
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const zod_1 = require("zod");
|
|
13
|
+
const maestroFlowDiscovery_1 = require("./maestroFlowDiscovery");
|
|
13
14
|
const maestroResultParser_1 = require("./maestroResultParser");
|
|
14
15
|
const retry_1 = require("../../utils/retry");
|
|
15
16
|
const FlowPathSchema = zod_1.z.array(zod_1.z.string().min(1)).min(1);
|
|
@@ -22,6 +23,16 @@ function parseInput(schema, value, message) {
|
|
|
22
23
|
}
|
|
23
24
|
return result.data;
|
|
24
25
|
}
|
|
26
|
+
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
|
|
27
|
+
// fault, so the post-loop merge should fall through to copy-latest instead
|
|
28
|
+
// of throwing.
|
|
29
|
+
function isFilesystemError(err) {
|
|
30
|
+
if (!err || typeof err !== 'object') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const code = err.code;
|
|
34
|
+
return (code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM');
|
|
35
|
+
}
|
|
25
36
|
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
|
26
37
|
// other declared formats pass an explicit path so downstream upload steps
|
|
27
38
|
// know where to find the result.
|
|
@@ -63,6 +74,12 @@ function createMaestroTestsBuildFunction() {
|
|
|
63
74
|
defaultValue: 0,
|
|
64
75
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
|
|
65
76
|
}),
|
|
77
|
+
steps_1.BuildStepInput.createProvider({
|
|
78
|
+
id: 'retry_failed_only',
|
|
79
|
+
required: false,
|
|
80
|
+
defaultValue: true,
|
|
81
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.BOOLEAN,
|
|
82
|
+
}),
|
|
66
83
|
steps_1.BuildStepInput.createProvider({
|
|
67
84
|
id: 'shards',
|
|
68
85
|
required: false,
|
|
@@ -133,33 +150,53 @@ function createMaestroTestsBuildFunction() {
|
|
|
133
150
|
const flowPaths = parseInput(FlowPathSchema, inputs.flow_path.value, 'flow_path must be a non-empty array of non-empty strings.');
|
|
134
151
|
const retries = parseInput(RetriesSchema, inputs.retries.value, 'retries must be a non-negative integer.');
|
|
135
152
|
const shards = parseInput(ShardsSchema, inputs.shards.value, 'shards must be a positive integer.');
|
|
153
|
+
const retryFailedOnly = inputs.retry_failed_only.value;
|
|
136
154
|
try {
|
|
137
155
|
await promises_1.default.mkdir(junitReportDirectory, { recursive: true });
|
|
138
156
|
}
|
|
139
157
|
catch (err) {
|
|
140
158
|
throw new eas_build_job_1.SystemError('Failed to create JUnit report directory', { cause: err });
|
|
141
159
|
}
|
|
160
|
+
// null → duplicate flow names; retry-failed-only disabled, fall through to
|
|
161
|
+
// dumb retry (re-run everything) on failure.
|
|
162
|
+
const nameToPath = await (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
|
|
163
|
+
inputFlowPaths: flowPaths,
|
|
164
|
+
projectRoot: stepCtx.workingDirectory,
|
|
165
|
+
logger,
|
|
166
|
+
});
|
|
142
167
|
// Retry loop. spawn-async error shapes:
|
|
143
168
|
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
|
144
169
|
// numeric err.status → maestro exited non-zero → retry.
|
|
145
170
|
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
|
146
171
|
// downgraded to "tests failed".
|
|
172
|
+
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
|
173
|
+
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
|
174
|
+
// trusted; we then fall through to dumb retry (re-run everything).
|
|
175
|
+
let flowsToRun = flowPaths;
|
|
147
176
|
let lastAttemptExitCode = null;
|
|
177
|
+
const totalAttempts = retries + 1;
|
|
148
178
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
149
179
|
const outputPath = outputFormat === 'junit'
|
|
150
180
|
? path_1.default.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
|
151
181
|
: outputFormat
|
|
152
182
|
? path_1.default.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
|
153
183
|
: null;
|
|
184
|
+
const maestroArgs = buildMaestroArgs({
|
|
185
|
+
flow_path: flowsToRun,
|
|
186
|
+
outputPath,
|
|
187
|
+
output_format: outputFormat,
|
|
188
|
+
shards,
|
|
189
|
+
include_tags: includeTags,
|
|
190
|
+
exclude_tags: excludeTags,
|
|
191
|
+
});
|
|
192
|
+
logger.info(`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`);
|
|
154
193
|
try {
|
|
155
|
-
await (0, turtle_spawn_1.default)('maestro',
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
exclude_tags: excludeTags,
|
|
162
|
-
}), { cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal });
|
|
194
|
+
await (0, turtle_spawn_1.default)('maestro', maestroArgs, {
|
|
195
|
+
cwd: stepCtx.workingDirectory,
|
|
196
|
+
env: spawnEnv,
|
|
197
|
+
logger,
|
|
198
|
+
signal,
|
|
199
|
+
});
|
|
163
200
|
lastAttemptExitCode = 0;
|
|
164
201
|
}
|
|
165
202
|
catch (err) {
|
|
@@ -176,31 +213,60 @@ function createMaestroTestsBuildFunction() {
|
|
|
176
213
|
if (lastAttemptExitCode === 0 || attempt === retries) {
|
|
177
214
|
break;
|
|
178
215
|
}
|
|
179
|
-
|
|
216
|
+
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
|
|
217
|
+
const failed = await (0, maestroResultParser_1.parseFailedFlowsFromJUnit)({
|
|
218
|
+
junitFile: outputPath,
|
|
219
|
+
nameToPath,
|
|
220
|
+
});
|
|
221
|
+
if (failed !== null && failed.length > 0) {
|
|
222
|
+
flowsToRun = failed;
|
|
223
|
+
logger.info(`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
flowsToRun = flowPaths;
|
|
227
|
+
logger.info('Test failed; could not determine failed subset, retrying all flows');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
flowsToRun = flowPaths;
|
|
232
|
+
logger.info('Test failed, retrying all flows');
|
|
233
|
+
}
|
|
180
234
|
await (0, retry_1.sleepAsync)(2000);
|
|
181
235
|
}
|
|
182
|
-
//
|
|
183
|
-
//
|
|
236
|
+
// Smart merge first; on data errors (bad XML, missing input) fall back
|
|
237
|
+
// to copy-latest so the caller still gets a single JUnit file.
|
|
238
|
+
// Filesystem errors short-circuit straight to SystemError.
|
|
184
239
|
if (finalReportPath !== undefined) {
|
|
185
240
|
try {
|
|
186
|
-
await (0, maestroResultParser_1.
|
|
241
|
+
await (0, maestroResultParser_1.mergeJUnitReports)({
|
|
187
242
|
sourceDir: junitReportDirectory,
|
|
188
243
|
outputPath: finalReportPath,
|
|
189
244
|
});
|
|
190
245
|
}
|
|
191
|
-
catch (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
246
|
+
catch (mergeErr) {
|
|
247
|
+
if (isFilesystemError(mergeErr)) {
|
|
248
|
+
throw new eas_build_job_1.SystemError('Failed to write final_report_path', { cause: mergeErr });
|
|
249
|
+
}
|
|
250
|
+
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
|
|
251
|
+
try {
|
|
252
|
+
await (0, maestroResultParser_1.copyLatestAttemptXml)({
|
|
253
|
+
sourceDir: junitReportDirectory,
|
|
254
|
+
outputPath: finalReportPath,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (copyErr) {
|
|
258
|
+
// Swallow: a copy failure here usually means maestro itself failed
|
|
259
|
+
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
|
|
260
|
+
// the real reason and cancel billing for a user-side failure — let
|
|
261
|
+
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
|
262
|
+
logger.warn(`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`);
|
|
263
|
+
}
|
|
197
264
|
}
|
|
198
265
|
}
|
|
199
266
|
// The retry loop exits via success (0), numeric status (retryable),
|
|
200
267
|
// or throw (infra). A non-null non-zero status means the user's tests
|
|
201
268
|
// failed every attempt.
|
|
202
269
|
if (lastAttemptExitCode !== 0) {
|
|
203
|
-
const totalAttempts = retries + 1;
|
|
204
270
|
throw new eas_build_job_1.UserError('ERR_MAESTRO_TESTS_FAILED', `Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`);
|
|
205
271
|
}
|
|
206
272
|
},
|
|
@@ -3,7 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createReportMaestroTestResultsFunction = createReportMaestroTestResultsFunction;
|
|
4
4
|
const steps_1 = require("@expo/steps");
|
|
5
5
|
const gql_tada_1 = require("gql.tada");
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
const maestroFlowDiscovery_1 = require("./maestroFlowDiscovery");
|
|
6
8
|
const maestroResultParser_1 = require("./maestroResultParser");
|
|
9
|
+
const FlowPathSchema = zod_1.z.array(zod_1.z.string().min(1)).min(1);
|
|
7
10
|
const CREATE_MUTATION = (0, gql_tada_1.graphql)(`
|
|
8
11
|
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
|
|
9
12
|
workflowDeviceTestCaseResult {
|
|
@@ -36,6 +39,11 @@ function createReportMaestroTestResultsFunction(ctx) {
|
|
|
36
39
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
37
40
|
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
|
38
41
|
}),
|
|
42
|
+
steps_1.BuildStepInput.createProvider({
|
|
43
|
+
id: 'flow_path',
|
|
44
|
+
required: false,
|
|
45
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.JSON,
|
|
46
|
+
}),
|
|
39
47
|
],
|
|
40
48
|
fn: async (stepsCtx, { inputs }) => {
|
|
41
49
|
const { logger } = stepsCtx;
|
|
@@ -49,9 +57,23 @@ function createReportMaestroTestResultsFunction(ctx) {
|
|
|
49
57
|
logger.info('No JUnit directory provided, skipping test results report');
|
|
50
58
|
return;
|
|
51
59
|
}
|
|
52
|
-
const
|
|
60
|
+
const flowPathRaw = inputs.flow_path.value;
|
|
61
|
+
let nameToPath = null;
|
|
62
|
+
if (flowPathRaw !== undefined) {
|
|
63
|
+
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
|
64
|
+
if (parsed.success) {
|
|
65
|
+
nameToPath = await (0, maestroFlowDiscovery_1.buildFlowNameToPathMap)({
|
|
66
|
+
inputFlowPaths: parsed.data,
|
|
67
|
+
projectRoot: stepsCtx.workingDirectory,
|
|
68
|
+
logger,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
logger.warn('Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
53
75
|
try {
|
|
54
|
-
const flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory,
|
|
76
|
+
const flowResults = await (0, maestroResultParser_1.parseMaestroResults)(junitDirectory, nameToPath);
|
|
55
77
|
if (flowResults.length === 0) {
|
|
56
78
|
logger.info('No maestro test results found, skipping report');
|
|
57
79
|
return;
|
|
@@ -214,7 +214,25 @@ async function restoreGradleCacheAsync({ logger, workingDirectory, env, secrets,
|
|
|
214
214
|
verbose: env.EXPO_DEBUG === '1',
|
|
215
215
|
logger,
|
|
216
216
|
});
|
|
217
|
-
|
|
217
|
+
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
|
|
218
|
+
logger.info(`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`);
|
|
219
|
+
try {
|
|
220
|
+
await (0, turtleFetch_1.turtleFetch)(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
|
|
221
|
+
json: {
|
|
222
|
+
buildId: jobId,
|
|
223
|
+
message: `Gradle cache restored (${hitType})`,
|
|
224
|
+
tags: {
|
|
225
|
+
event: 'gradle_cache_restored',
|
|
226
|
+
cache_hit_type: hitType,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
headers: {
|
|
230
|
+
Authorization: `Bearer ${robotAccessToken}`,
|
|
231
|
+
},
|
|
232
|
+
shouldThrowOnNotOk: false,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch { }
|
|
218
236
|
}
|
|
219
237
|
catch (err) {
|
|
220
238
|
if (err instanceof turtleFetch_1.TurtleFetchError && err.response?.status === 404) {
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { BuildFunction } from '@expo/steps';
|
|
2
|
-
|
|
2
|
+
import { CustomBuildContext } from '../../customBuildContext';
|
|
3
|
+
export declare function createStartAgentDeviceRemoteSessionBuildFunction(ctx: CustomBuildContext): BuildFunction;
|
|
@@ -6,11 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.createStartAgentDeviceRemoteSessionBuildFunction = createStartAgentDeviceRemoteSessionBuildFunction;
|
|
7
7
|
const steps_1 = require("@expo/steps");
|
|
8
8
|
const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
|
|
9
|
+
const gql_tada_1 = require("gql.tada");
|
|
9
10
|
const node_child_process_1 = __importDefault(require("node:child_process"));
|
|
10
11
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
12
|
const node_os_1 = __importDefault(require("node:os"));
|
|
12
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
13
14
|
const retry_1 = require("../../utils/retry");
|
|
15
|
+
const eas_build_job_1 = require("@expo/eas-build-job");
|
|
14
16
|
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
|
15
17
|
const SRC_DIR = '/tmp/agent-device-src';
|
|
16
18
|
const RUN_DIR = '/tmp/agent-device';
|
|
@@ -18,8 +20,22 @@ const DAEMON_LOG = node_path_1.default.join(RUN_DIR, 'daemon.log');
|
|
|
18
20
|
const TUNNEL_LOG = node_path_1.default.join(RUN_DIR, 'cloudflared.log');
|
|
19
21
|
const DAEMON_JSON_PATH = node_path_1.default.join(node_os_1.default.homedir(), '.agent-device', 'daemon.json');
|
|
20
22
|
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
|
23
|
+
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
|
|
21
24
|
const STARTUP_TIMEOUT_MS = 60_000;
|
|
22
|
-
|
|
25
|
+
const START_DEVICE_RUN_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
|
|
26
|
+
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
|
27
|
+
deviceRunSession {
|
|
28
|
+
startDeviceRunSession(
|
|
29
|
+
deviceRunSessionId: $deviceRunSessionId
|
|
30
|
+
remoteConfig: $remoteConfig
|
|
31
|
+
) {
|
|
32
|
+
id
|
|
33
|
+
status
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`);
|
|
38
|
+
function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
23
39
|
return new steps_1.BuildFunction({
|
|
24
40
|
namespace: 'eas',
|
|
25
41
|
id: 'start_agent_device_remote_session',
|
|
@@ -32,17 +48,31 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
|
|
|
32
48
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
33
49
|
}),
|
|
34
50
|
],
|
|
35
|
-
fn: async ({ logger }, { inputs, env }) => {
|
|
51
|
+
fn: async ({ logger, global }, { inputs, env }) => {
|
|
52
|
+
// Fail fast before any expensive setup if the orchestrator-injected
|
|
53
|
+
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
|
|
54
|
+
// report the remote config back to the API server.
|
|
55
|
+
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
|
|
56
|
+
if (!deviceRunSessionId) {
|
|
57
|
+
throw new eas_build_job_1.SystemError('DEVICE_RUN_SESSION_ID is not set. ' +
|
|
58
|
+
'This step must run as part of a device run session created by the API server, ' +
|
|
59
|
+
'which injects DEVICE_RUN_SESSION_ID into the job environment.');
|
|
60
|
+
}
|
|
36
61
|
const packageVersion = inputs.package_version.value;
|
|
37
|
-
|
|
62
|
+
const { runtimePlatform } = global;
|
|
63
|
+
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`);
|
|
38
64
|
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
|
|
39
65
|
await node_fs_1.default.promises.mkdir(RUN_DIR, { recursive: true });
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
|
|
67
|
+
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
|
68
|
+
await (0, turtle_spawn_1.default)('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
|
69
|
+
}
|
|
42
70
|
logger.info('Ensuring cloudflared is installed.');
|
|
43
|
-
await ensureCloudflaredInstalledAsync({
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
|
|
72
|
+
runtimePlatform,
|
|
73
|
+
env,
|
|
74
|
+
logger,
|
|
75
|
+
});
|
|
46
76
|
logger.info(packageVersion
|
|
47
77
|
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
|
48
78
|
: `Cloning agent-device (latest) into ${SRC_DIR}.`);
|
|
@@ -71,7 +101,7 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
|
|
|
71
101
|
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
|
72
102
|
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort} (log file: ${TUNNEL_LOG}).`);
|
|
73
103
|
await spawnDetachedAsync({
|
|
74
|
-
command:
|
|
104
|
+
command: `${cloudflaredCommand} tunnel --url "http://localhost:${daemonPort}"`,
|
|
75
105
|
logFile: TUNNEL_LOG,
|
|
76
106
|
env,
|
|
77
107
|
logger,
|
|
@@ -84,9 +114,16 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
|
|
|
84
114
|
description: 'cloudflared tunnel',
|
|
85
115
|
});
|
|
86
116
|
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
|
|
87
|
-
logger.info(
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
logger.info(`Reporting agent-device remote config to the API server (device run session: ${deviceRunSessionId}).`);
|
|
118
|
+
const result = await ctx.graphqlClient
|
|
119
|
+
.mutation(START_DEVICE_RUN_SESSION_MUTATION, {
|
|
120
|
+
deviceRunSessionId,
|
|
121
|
+
remoteConfig: { url: tunnelUrl, token: daemonToken },
|
|
122
|
+
})
|
|
123
|
+
.toPromise();
|
|
124
|
+
if (result.error) {
|
|
125
|
+
throw new eas_build_job_1.SystemError(`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`);
|
|
126
|
+
}
|
|
90
127
|
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
|
91
128
|
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
|
92
129
|
// until stopDeviceRunSession cancels the run.
|
|
@@ -94,15 +131,47 @@ function createStartAgentDeviceRemoteSessionBuildFunction() {
|
|
|
94
131
|
},
|
|
95
132
|
});
|
|
96
133
|
}
|
|
97
|
-
async function ensureCloudflaredInstalledAsync({ env, logger, }) {
|
|
98
|
-
|
|
134
|
+
async function ensureCloudflaredInstalledAsync({ runtimePlatform, env, logger, }) {
|
|
135
|
+
if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
|
|
136
|
+
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
|
137
|
+
return 'cloudflared';
|
|
138
|
+
}
|
|
139
|
+
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
|
|
140
|
+
return 'cloudflared';
|
|
141
|
+
}
|
|
142
|
+
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(node_os_1.default.arch());
|
|
143
|
+
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
|
|
144
|
+
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
|
|
145
|
+
await (0, turtle_spawn_1.default)('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
|
|
146
|
+
env,
|
|
147
|
+
logger,
|
|
148
|
+
});
|
|
149
|
+
await (0, turtle_spawn_1.default)('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
|
|
150
|
+
// Return the absolute install path so the tunnel command works even when
|
|
151
|
+
// /usr/local/bin is not on the step's PATH.
|
|
152
|
+
return CLOUDFLARED_LINUX_INSTALL_PATH;
|
|
99
153
|
}
|
|
100
|
-
|
|
101
|
-
|
|
154
|
+
function cloudflaredLinuxArchForNodeArch(arch) {
|
|
155
|
+
if (arch === 'x64') {
|
|
156
|
+
return 'amd64';
|
|
157
|
+
}
|
|
158
|
+
if (arch === 'arm64') {
|
|
159
|
+
return 'arm64';
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`);
|
|
102
162
|
}
|
|
103
163
|
async function ensureBrewPackageInstalledAsync({ name, env, logger, }) {
|
|
104
164
|
await (0, turtle_spawn_1.default)('bash', ['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`], { env, logger });
|
|
105
165
|
}
|
|
166
|
+
async function isCommandAvailableAsync({ command, env, }) {
|
|
167
|
+
try {
|
|
168
|
+
await (0, turtle_spawn_1.default)('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
106
175
|
async function cloneAgentDeviceAsync({ packageVersion, env, logger, }) {
|
|
107
176
|
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
|
108
177
|
await (0, turtle_spawn_1.default)('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
|
@@ -15,7 +15,7 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
|
15
15
|
const os_1 = __importDefault(require("os"));
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
17
17
|
const zod_1 = require("zod");
|
|
18
|
-
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.
|
|
18
|
+
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
|
19
19
|
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
|
20
20
|
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
|
|
21
21
|
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
|
|
@@ -228,18 +228,19 @@ function formatReport(data) {
|
|
|
228
228
|
lines.push('');
|
|
229
229
|
lines.push('Xcode Build — Compile Metrics by Module');
|
|
230
230
|
lines.push(`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`);
|
|
231
|
-
lines.push('
|
|
232
|
-
lines.push('
|
|
231
|
+
lines.push('Sum = sum of compile-step wall durations within the target; overlapping steps are counted separately');
|
|
232
|
+
lines.push('% Sum = share of total Sum');
|
|
233
|
+
lines.push('Active = merged compile-step wall time, excluding idle gaps between compile steps');
|
|
233
234
|
lines.push('');
|
|
234
235
|
lines.push(header);
|
|
235
236
|
lines.push('│ ' +
|
|
236
237
|
'Module'.padEnd(nameWidth) +
|
|
237
238
|
' │ ' +
|
|
238
|
-
'
|
|
239
|
+
'Sum'.padStart(taskWidth) +
|
|
239
240
|
' │ ' +
|
|
240
|
-
'%
|
|
241
|
+
'% Sum'.padStart(pctWidth) +
|
|
241
242
|
' │ ' +
|
|
242
|
-
'
|
|
243
|
+
'Active'.padStart(wallWidth) +
|
|
243
244
|
' │ ' +
|
|
244
245
|
' '.repeat(barMaxWidth) +
|
|
245
246
|
' │');
|
|
@@ -255,7 +256,7 @@ function formatReport(data) {
|
|
|
255
256
|
' │ ' +
|
|
256
257
|
`${pct.toFixed(1)}%`.padStart(pctWidth) +
|
|
257
258
|
' │ ' +
|
|
258
|
-
formatSeconds(result.
|
|
259
|
+
formatSeconds(result.activeWallTime).padStart(wallWidth) +
|
|
259
260
|
' │ ' +
|
|
260
261
|
bar +
|
|
261
262
|
' │');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/build-tools",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.12.0",
|
|
4
4
|
"bugs": "https://github.com/expo/eas-cli/issues",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Expo <support@expo.io>",
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
"@expo/config": "55.0.10",
|
|
39
39
|
"@expo/config-plugins": "55.0.7",
|
|
40
40
|
"@expo/downloader": "18.5.0",
|
|
41
|
-
"@expo/eas-build-job": "18.
|
|
41
|
+
"@expo/eas-build-job": "18.12.0",
|
|
42
42
|
"@expo/env": "^0.4.0",
|
|
43
43
|
"@expo/logger": "18.5.0",
|
|
44
44
|
"@expo/package-manager": "1.9.10",
|
|
45
45
|
"@expo/plist": "^0.2.0",
|
|
46
46
|
"@expo/results": "^1.0.0",
|
|
47
47
|
"@expo/spawn-async": "1.7.2",
|
|
48
|
-
"@expo/steps": "18.
|
|
48
|
+
"@expo/steps": "18.12.0",
|
|
49
49
|
"@expo/template-file": "18.5.0",
|
|
50
50
|
"@expo/turtle-spawn": "18.5.0",
|
|
51
51
|
"@expo/xcpretty": "^4.3.1",
|
|
@@ -98,5 +98,5 @@
|
|
|
98
98
|
"typescript": "^5.5.4",
|
|
99
99
|
"uuid": "^9.0.1"
|
|
100
100
|
},
|
|
101
|
-
"gitHead": "
|
|
101
|
+
"gitHead": "53d294330de9c63eb792f646c0603224def9a1b0"
|
|
102
102
|
}
|