@expo/build-tools 18.11.0 → 18.12.1
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 +3 -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 +5 -29
- package/dist/steps/functions/maestroResultParser.js +24 -183
- package/dist/steps/functions/maestroTests.js +43 -15
- 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 +76 -72
- package/dist/steps/functions/startServeSimRemoteSession.d.ts +3 -0
- package/dist/steps/functions/startServeSimRemoteSession.js +72 -0
- package/dist/steps/utils/ios/xcactivitylog.js +8 -7
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +24 -0
- package/dist/steps/utils/remoteDeviceRunSession.js +65 -0
- 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
|
+
}
|
|
@@ -41,6 +41,7 @@ const startAgentDeviceRemoteSession_1 = require("./functions/startAgentDeviceRem
|
|
|
41
41
|
const startAndroidEmulator_1 = require("./functions/startAndroidEmulator");
|
|
42
42
|
const startCuttlefishDevice_1 = require("./functions/startCuttlefishDevice");
|
|
43
43
|
const startIosSimulator_1 = require("./functions/startIosSimulator");
|
|
44
|
+
const startServeSimRemoteSession_1 = require("./functions/startServeSimRemoteSession");
|
|
44
45
|
const uploadArtifact_1 = require("./functions/uploadArtifact");
|
|
45
46
|
const uploadToAsc_1 = require("./functions/uploadToAsc");
|
|
46
47
|
const useNpmToken_1 = require("./functions/useNpmToken");
|
|
@@ -75,10 +76,11 @@ function getEasFunctions(ctx) {
|
|
|
75
76
|
(0, generateGymfileFromTemplate_1.generateGymfileFromTemplateFunction)(),
|
|
76
77
|
(0, runFastlane_1.runFastlaneFunction)(),
|
|
77
78
|
(0, parseXcactivitylog_1.parseXcactivitylogFunction)(),
|
|
78
|
-
(0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(),
|
|
79
|
+
(0, startAgentDeviceRemoteSession_1.createStartAgentDeviceRemoteSessionBuildFunction)(ctx),
|
|
79
80
|
(0, startAndroidEmulator_1.createStartAndroidEmulatorBuildFunction)(),
|
|
80
81
|
(0, startCuttlefishDevice_1.createStartCuttlefishDeviceBuildFunction)(),
|
|
81
82
|
(0, startIosSimulator_1.createStartIosSimulatorBuildFunction)(),
|
|
83
|
+
(0, startServeSimRemoteSession_1.createStartServeSimRemoteSessionBuildFunction)(ctx),
|
|
82
84
|
(0, installMaestro_1.createInstallMaestroBuildFunction)(),
|
|
83
85
|
(0, installPods_1.createInstallPodsBuildFunction)(),
|
|
84
86
|
(0, sendSlackMessage_1.createSendSlackMessageFunction)(),
|
|
@@ -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,40 +17,19 @@ 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>;
|
|
27
|
-
/**
|
|
28
|
-
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
|
29
|
-
*
|
|
30
|
-
* The file contains:
|
|
31
|
-
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
|
|
32
|
-
* the flow filename without extension.
|
|
33
|
-
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
|
|
34
|
-
* - `flow_file_path`: absolute path to the original flow YAML file.
|
|
35
|
-
* - `outputs`: screenshot defect data (unused here).
|
|
36
|
-
*
|
|
37
|
-
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
|
|
38
|
-
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
|
|
39
|
-
*/
|
|
40
|
-
export declare function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null>;
|
|
41
|
-
export declare function parseMaestroResults(junitDirectory: string, testsDirectory: string, projectRoot: string): Promise<MaestroFlowResult[]>;
|
|
20
|
+
export declare function parseMaestroResults(junitDirectory: string, nameToPath: Map<string, string> | null): Promise<MaestroFlowResult[]>;
|
|
42
21
|
/**
|
|
43
22
|
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
|
44
23
|
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
|
45
24
|
* then falls back to dumb retry — re-run everything).
|
|
46
25
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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.
|
|
50
29
|
*/
|
|
51
30
|
export declare function parseFailedFlowsFromJUnit(args: {
|
|
52
31
|
junitFile: string;
|
|
53
|
-
|
|
54
|
-
inputFlowPaths: string[];
|
|
55
|
-
projectRoot: string;
|
|
32
|
+
nameToPath: Map<string, string>;
|
|
56
33
|
}): Promise<string[] | null>;
|
|
57
34
|
/**
|
|
58
35
|
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
|
@@ -79,4 +56,3 @@ export declare function copyLatestAttemptXml(args: {
|
|
|
79
56
|
sourceDir: string;
|
|
80
57
|
outputPath: string;
|
|
81
58
|
}): Promise<void>;
|
|
82
|
-
export {};
|
|
@@ -3,9 +3,7 @@ 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;
|
|
10
8
|
exports.parseFailedFlowsFromJUnit = parseFailedFlowsFromJUnit;
|
|
11
9
|
exports.mergeJUnitReports = mergeJUnitReports;
|
|
@@ -13,15 +11,8 @@ exports.copyLatestAttemptXml = copyLatestAttemptXml;
|
|
|
13
11
|
const fast_xml_parser_1 = require("fast-xml-parser");
|
|
14
12
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
15
13
|
const path_1 = __importDefault(require("path"));
|
|
16
|
-
const zod_1 = require("zod");
|
|
17
|
-
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
|
18
|
-
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
|
19
14
|
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
|
20
15
|
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
|
21
|
-
function extractFlowKey(filename, prefix) {
|
|
22
|
-
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
|
23
|
-
return match?.[1] ?? null;
|
|
24
|
-
}
|
|
25
16
|
const xmlParser = new fast_xml_parser_1.XMLParser({
|
|
26
17
|
ignoreAttributes: false,
|
|
27
18
|
attributeNamePrefix: '@_',
|
|
@@ -112,35 +103,7 @@ async function parseJUnitTestCases(junitDirectory) {
|
|
|
112
103
|
const perFile = await Promise.all(xmlFiles.map(f => parseJUnitFile(path_1.default.join(junitDirectory, f))));
|
|
113
104
|
return perFile.flat();
|
|
114
105
|
}
|
|
115
|
-
|
|
116
|
-
flow_name: zod_1.z.string(),
|
|
117
|
-
flow_file_path: zod_1.z.string(),
|
|
118
|
-
});
|
|
119
|
-
/**
|
|
120
|
-
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
|
121
|
-
*
|
|
122
|
-
* The file contains:
|
|
123
|
-
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
|
|
124
|
-
* the flow filename without extension.
|
|
125
|
-
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
|
|
126
|
-
* - `flow_file_path`: absolute path to the original flow YAML file.
|
|
127
|
-
* - `outputs`: screenshot defect data (unused here).
|
|
128
|
-
*
|
|
129
|
-
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
|
|
130
|
-
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
|
|
131
|
-
*/
|
|
132
|
-
async function parseFlowMetadata(filePath) {
|
|
133
|
-
try {
|
|
134
|
-
const content = await promises_1.default.readFile(filePath, 'utf-8');
|
|
135
|
-
const data = JSON.parse(content);
|
|
136
|
-
return FlowMetadataFileSchema.parse(data);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot) {
|
|
143
|
-
// 1. Parse JUnit XML files, tracking which file each result came from
|
|
106
|
+
async function parseMaestroResults(junitDirectory, nameToPath) {
|
|
144
107
|
let junitEntries;
|
|
145
108
|
try {
|
|
146
109
|
junitEntries = await promises_1.default.readdir(junitDirectory);
|
|
@@ -162,44 +125,7 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
162
125
|
if (junitResultsWithSource.length === 0) {
|
|
163
126
|
return [];
|
|
164
127
|
}
|
|
165
|
-
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
|
|
166
|
-
const flowPathMap = new Map(); // flowName → flowFilePath
|
|
167
|
-
const flowOccurrences = new Map(); // flowName → count
|
|
168
|
-
let entries;
|
|
169
|
-
try {
|
|
170
|
-
entries = await promises_1.default.readdir(testsDirectory);
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
entries = [];
|
|
174
|
-
}
|
|
175
|
-
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
|
176
|
-
for (const dir of timestampDirs) {
|
|
177
|
-
const dirPath = path_1.default.join(testsDirectory, dir);
|
|
178
|
-
let files;
|
|
179
|
-
try {
|
|
180
|
-
files = await promises_1.default.readdir(dirPath);
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
for (const file of files) {
|
|
186
|
-
const flowKey = extractFlowKey(file, 'ai');
|
|
187
|
-
if (!flowKey) {
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
const metadata = await parseFlowMetadata(path_1.default.join(dirPath, file));
|
|
191
|
-
if (!metadata) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
// Track latest path (last timestamp dir wins)
|
|
195
|
-
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
|
|
196
|
-
// Count occurrences for retryCount
|
|
197
|
-
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
// 3. Merge: JUnit results + ai-*.json metadata
|
|
201
128
|
const results = [];
|
|
202
|
-
// Group results by flow name
|
|
203
129
|
const resultsByName = new Map();
|
|
204
130
|
for (const entry of junitResultsWithSource) {
|
|
205
131
|
const group = resultsByName.get(entry.result.name) ?? [];
|
|
@@ -207,49 +133,28 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
207
133
|
resultsByName.set(entry.result.name, group);
|
|
208
134
|
}
|
|
209
135
|
for (const [flowName, flowEntries] of resultsByName) {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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) {
|
|
220
147
|
results.push({
|
|
221
148
|
name: flowName,
|
|
222
|
-
path:
|
|
149
|
+
path: flowPath,
|
|
223
150
|
status: result.status,
|
|
224
151
|
errorMessage: result.errorMessage,
|
|
225
152
|
duration: result.duration,
|
|
226
|
-
retryCount,
|
|
153
|
+
retryCount: attemptIndex,
|
|
227
154
|
tags: result.tags,
|
|
228
155
|
properties: result.properties,
|
|
229
156
|
});
|
|
230
157
|
}
|
|
231
|
-
else {
|
|
232
|
-
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
|
|
233
|
-
const sorted = flowEntries
|
|
234
|
-
.map(entry => {
|
|
235
|
-
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
|
236
|
-
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
|
237
|
-
return { ...entry, attemptIndex };
|
|
238
|
-
})
|
|
239
|
-
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
|
240
|
-
for (const { result, attemptIndex } of sorted) {
|
|
241
|
-
results.push({
|
|
242
|
-
name: flowName,
|
|
243
|
-
path: relativePath,
|
|
244
|
-
status: result.status,
|
|
245
|
-
errorMessage: result.errorMessage,
|
|
246
|
-
duration: result.duration,
|
|
247
|
-
retryCount: attemptIndex,
|
|
248
|
-
tags: result.tags,
|
|
249
|
-
properties: result.properties,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
158
|
}
|
|
254
159
|
return results;
|
|
255
160
|
}
|
|
@@ -258,13 +163,13 @@ async function parseMaestroResults(junitDirectory, testsDirectory, projectRoot)
|
|
|
258
163
|
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
|
259
164
|
* then falls back to dumb retry — re-run everything).
|
|
260
165
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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.
|
|
264
169
|
*/
|
|
265
170
|
async function parseFailedFlowsFromJUnit(args) {
|
|
266
171
|
// fast-xml-parser is lenient — truncated XML can produce a partial parse
|
|
267
|
-
// with some testcases dropped. Trusting that subset for
|
|
172
|
+
// with some testcases dropped. Trusting that subset for retry-failed-only would
|
|
268
173
|
// skip retries for cut-off flows; reject any malformed XML and fall back
|
|
269
174
|
// to dumb retry.
|
|
270
175
|
let content;
|
|
@@ -286,8 +191,9 @@ async function parseFailedFlowsFromJUnit(args) {
|
|
|
286
191
|
return [];
|
|
287
192
|
}
|
|
288
193
|
// Two testcases with the same name (pass+fail or fail+fail) make it
|
|
289
|
-
// impossible to map back to a single input flow_path
|
|
290
|
-
//
|
|
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).
|
|
291
197
|
const allNameCounts = new Map();
|
|
292
198
|
for (const tc of testcases) {
|
|
293
199
|
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
|
|
@@ -297,60 +203,16 @@ async function parseFailedFlowsFromJUnit(args) {
|
|
|
297
203
|
return null;
|
|
298
204
|
}
|
|
299
205
|
}
|
|
300
|
-
// Build flow_name → flow_file_path map from ai-*.json across timestamped
|
|
301
|
-
// subdirectories (same traversal as parseMaestroResults).
|
|
302
|
-
const nameToPath = new Map();
|
|
303
|
-
let entries;
|
|
304
|
-
try {
|
|
305
|
-
entries = await promises_1.default.readdir(args.testsDirectory);
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
entries = [];
|
|
309
|
-
}
|
|
310
|
-
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
|
311
|
-
for (const dir of timestampDirs) {
|
|
312
|
-
const dirPath = path_1.default.join(args.testsDirectory, dir);
|
|
313
|
-
let files;
|
|
314
|
-
try {
|
|
315
|
-
files = await promises_1.default.readdir(dirPath);
|
|
316
|
-
}
|
|
317
|
-
catch {
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
for (const file of files) {
|
|
321
|
-
const flowKey = extractFlowKey(file, 'ai');
|
|
322
|
-
if (!flowKey) {
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
const metadata = await parseFlowMetadata(path_1.default.join(dirPath, file));
|
|
326
|
-
if (!metadata) {
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
// Latest timestamp dir wins if the same flow appears in multiple attempts.
|
|
330
|
-
nameToPath.set(metadata.flow_name, metadata.flow_file_path);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
206
|
const matched = [];
|
|
334
207
|
for (const tc of failing) {
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
return null; // unknown mapping; safer to fall back
|
|
338
|
-
}
|
|
339
|
-
const relative = await relativizePathAsync(abs, args.projectRoot);
|
|
340
|
-
// Accept exact matches and flow files discovered under an input directory
|
|
341
|
-
// (documented usage: `flow_path: ./maestro/flows` discovers nested .yml).
|
|
342
|
-
// Anything outside every input is treated as out-of-scope → dumb retry.
|
|
343
|
-
if (!args.inputFlowPaths.some(input => isPathWithinOrEqual(relative, input))) {
|
|
208
|
+
const p = args.nameToPath.get(tc.name);
|
|
209
|
+
if (p === undefined) {
|
|
344
210
|
return null;
|
|
345
211
|
}
|
|
346
|
-
matched.push(
|
|
212
|
+
matched.push(p);
|
|
347
213
|
}
|
|
348
214
|
return matched;
|
|
349
215
|
}
|
|
350
|
-
function isPathWithinOrEqual(child, parent) {
|
|
351
|
-
const rel = path_1.default.relative(parent, child);
|
|
352
|
-
return rel === '' || (!rel.startsWith('..') && !path_1.default.isAbsolute(rel));
|
|
353
|
-
}
|
|
354
216
|
/**
|
|
355
217
|
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
|
356
218
|
* attempt's <testcase> per unique flow name (latest determined from the
|
|
@@ -495,24 +357,3 @@ async function copyLatestAttemptXml(args) {
|
|
|
495
357
|
}
|
|
496
358
|
await promises_1.default.copyFile(path_1.default.join(args.sourceDir, winner), args.outputPath);
|
|
497
359
|
}
|
|
498
|
-
async function relativizePathAsync(flowFilePath, projectRoot) {
|
|
499
|
-
if (!path_1.default.isAbsolute(flowFilePath)) {
|
|
500
|
-
return flowFilePath;
|
|
501
|
-
}
|
|
502
|
-
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
|
|
503
|
-
let resolvedRoot = projectRoot;
|
|
504
|
-
let resolvedFlow = flowFilePath;
|
|
505
|
-
try {
|
|
506
|
-
resolvedRoot = await promises_1.default.realpath(projectRoot);
|
|
507
|
-
}
|
|
508
|
-
catch { }
|
|
509
|
-
try {
|
|
510
|
-
resolvedFlow = await promises_1.default.realpath(flowFilePath);
|
|
511
|
-
}
|
|
512
|
-
catch { }
|
|
513
|
-
const relative = path_1.default.relative(resolvedRoot, resolvedFlow);
|
|
514
|
-
if (relative.startsWith('..')) {
|
|
515
|
-
return flowFilePath;
|
|
516
|
-
}
|
|
517
|
-
return relative;
|
|
518
|
-
}
|