@expo/build-tools 21.0.2 → 21.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builders/android.js +12 -1
- package/dist/builders/ios.js +13 -1
- package/dist/common/easBuildInternal.js +3 -0
- package/dist/common/hookableBuildPhase.d.ts +18 -0
- package/dist/common/hookableBuildPhase.js +72 -0
- package/dist/common/jobHooks.d.ts +16 -0
- package/dist/common/jobHooks.js +95 -0
- package/dist/common/setup.d.ts +14 -2
- package/dist/common/setup.js +40 -21
- package/dist/index.d.ts +4 -3
- package/dist/index.js +5 -5
- package/dist/{gcs/LoggerStream.d.ts → logging/RemoteLoggerStream.d.ts} +6 -6
- package/dist/{gcs/LoggerStream.js → logging/RemoteLoggerStream.js} +10 -10
- package/dist/steps/functions/maestroResultParser.d.ts +1 -0
- package/dist/steps/functions/maestroResultParser.js +12 -0
- package/dist/steps/functions/maestroScreenshots.d.ts +1 -0
- package/dist/steps/functions/maestroScreenshots.js +167 -34
- package/dist/steps/functions/maestroTests.js +4 -0
- package/dist/{gcs → storage}/retry.d.ts +1 -1
- package/dist/{gcs → storage}/retry.js +2 -2
- package/dist/storage/uploadWithSignedUrl.d.ts +12 -0
- package/dist/{gcs/client.js → storage/uploadWithSignedUrl.js} +27 -31
- package/package.json +4 -4
- package/dist/gcs/client.d.ts +0 -15
package/dist/builders/android.js
CHANGED
|
@@ -41,7 +41,18 @@ async function androidBuilder(ctx) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
async function buildAsync(ctx) {
|
|
44
|
-
|
|
44
|
+
const jobHooksRef = { current: null };
|
|
45
|
+
try {
|
|
46
|
+
await buildInnerAsync(ctx, jobHooksRef);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
// Native builds have no other drain path for the hook context's queued
|
|
50
|
+
// step-metric uploads.
|
|
51
|
+
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function buildInnerAsync(ctx, jobHooksRef) {
|
|
55
|
+
await (0, setup_1.setupAsync)(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
|
|
45
56
|
const evictUsedBefore = new Date();
|
|
46
57
|
const workingDirectory = ctx.getReactNativeProjectDirectory();
|
|
47
58
|
const hasNativeCode = ctx.job.type === eas_build_job_1.Workflow.GENERIC;
|
package/dist/builders/ios.js
CHANGED
|
@@ -50,7 +50,19 @@ async function iosBuilder(ctx) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
async function buildAsync(ctx) {
|
|
53
|
-
|
|
53
|
+
const jobHooksRef = { current: null };
|
|
54
|
+
try {
|
|
55
|
+
await buildInnerAsync(ctx, jobHooksRef);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
// Native builds have no other drain path for the hook context's queued
|
|
59
|
+
// step-metric uploads. Must wrap the WHOLE body — iOS's existing inner
|
|
60
|
+
// try/finally only starts after setupAsync returns.
|
|
61
|
+
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function buildInnerAsync(ctx, jobHooksRef) {
|
|
65
|
+
await (0, setup_1.setupAsync)(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
|
|
54
66
|
const hasNativeCode = ctx.job.type === eas_build_job_1.Workflow.GENERIC;
|
|
55
67
|
const evictUsedBefore = new Date();
|
|
56
68
|
const credentialsManager = new manager_1.default(ctx);
|
|
@@ -111,6 +111,9 @@ function validateEasBuildInternalResult({ oldJob, result, }) {
|
|
|
111
111
|
// We want to retain values that we have set on the job.
|
|
112
112
|
appId: oldJob.appId,
|
|
113
113
|
initiatingUserId: oldJob.initiatingUserId,
|
|
114
|
+
// Hooks are resolved server-side at submit; the mid-build eas.json
|
|
115
|
+
// regeneration must not add, drop, or change them (the original wins).
|
|
116
|
+
hooks: oldJob.hooks,
|
|
114
117
|
...(oldJob.platform === eas_build_job_1.Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
|
|
115
118
|
? { refreshAdHocProvisioningProfile: true }
|
|
116
119
|
: null),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BuildJob, BuildPhase, HookAnchorId } from '@expo/eas-build-job';
|
|
2
|
+
import { BuildContext } from '../context';
|
|
3
|
+
import { ParsedJobHooks } from './jobHooks';
|
|
4
|
+
/**
|
|
5
|
+
* Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
|
|
6
|
+
* wrapped around it: before-entries -> phase (in a try) -> after-entries, with
|
|
7
|
+
* after-on-failure guaranteed. A failing before sequence skips the phase AND the
|
|
8
|
+
* after-hooks (steps-engine parity). The phase error always wins over an
|
|
9
|
+
* after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
|
|
10
|
+
* phase exactly once with zero overhead.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({ ctx, hooks, buildPhase, anchor, fn, }: {
|
|
13
|
+
ctx: BuildContext<TJob>;
|
|
14
|
+
hooks: ParsedJobHooks | null;
|
|
15
|
+
buildPhase: BuildPhase;
|
|
16
|
+
anchor: HookAnchorId;
|
|
17
|
+
fn: () => Promise<T>;
|
|
18
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runHookableBuildPhaseAsync = runHookableBuildPhaseAsync;
|
|
4
|
+
const eas_build_job_1 = require("@expo/eas-build-job");
|
|
5
|
+
const steps_1 = require("@expo/steps");
|
|
6
|
+
/**
|
|
7
|
+
* Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
|
|
8
|
+
* wrapped around it: before-entries -> phase (in a try) -> after-entries, with
|
|
9
|
+
* after-on-failure guaranteed. A failing before sequence skips the phase AND the
|
|
10
|
+
* after-hooks (steps-engine parity). The phase error always wins over an
|
|
11
|
+
* after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
|
|
12
|
+
* phase exactly once with zero overhead.
|
|
13
|
+
*/
|
|
14
|
+
async function runHookableBuildPhaseAsync({ ctx, hooks, buildPhase, anchor, fn, }) {
|
|
15
|
+
// runHookEntriesAsync no-ops when the key has no entries.
|
|
16
|
+
if (hooks) {
|
|
17
|
+
await runHookEntriesAsync({ ctx, hooks, timing: 'before', anchor });
|
|
18
|
+
}
|
|
19
|
+
let result;
|
|
20
|
+
try {
|
|
21
|
+
result = await ctx.runBuildPhase(buildPhase, fn);
|
|
22
|
+
}
|
|
23
|
+
catch (phaseError) {
|
|
24
|
+
if (hooks) {
|
|
25
|
+
try {
|
|
26
|
+
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
|
27
|
+
}
|
|
28
|
+
catch (hookError) {
|
|
29
|
+
// The phase error wins; still surface the after-hook failure in the log.
|
|
30
|
+
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw phaseError;
|
|
34
|
+
}
|
|
35
|
+
if (hooks) {
|
|
36
|
+
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: false });
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
async function runHookEntriesAsync({ ctx, hooks, timing, anchor, anchorFailed = false, }) {
|
|
41
|
+
const hookKey = `${timing}_${anchor}`;
|
|
42
|
+
const entries = hooks.hookEntriesByKey[hookKey];
|
|
43
|
+
if (!entries?.length) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Refresh the hook context from the live BuildContext before running: env
|
|
47
|
+
// moves as the build runs (set-env written back below; profile env), and
|
|
48
|
+
// job/metadata are replaced by eas build:internal.
|
|
49
|
+
hooks.customBuildContext.updateEnv(ctx.env);
|
|
50
|
+
// Overwrite the job/metadata snapshot; NOT updateJobInformation, whose
|
|
51
|
+
// delta-merge would duplicate environmentSecrets on each refresh.
|
|
52
|
+
hooks.customBuildContext.job = ctx.job;
|
|
53
|
+
hooks.customBuildContext.metadata = ctx.metadata;
|
|
54
|
+
if (timing === 'after' && anchorFailed) {
|
|
55
|
+
// Mirror the engine: the anchor's failure is marked on the shared context so
|
|
56
|
+
// `failure()` / `success()` on after-hook steps read the phase outcome.
|
|
57
|
+
hooks.globalContext.markAsFailed();
|
|
58
|
+
}
|
|
59
|
+
const { failedLocally, firstError } = await (0, steps_1.executeHookStepsAsync)(hooks.globalContext, entries, {
|
|
60
|
+
anchor,
|
|
61
|
+
timing,
|
|
62
|
+
...(timing === 'after' ? { anchorResult: anchorFailed ? 'failed' : 'success' } : null),
|
|
63
|
+
});
|
|
64
|
+
// Reverse write-back BEFORE propagating a failure: env set by the entries that
|
|
65
|
+
// did run must reach the build context even when a later entry failed, so the
|
|
66
|
+
// native phases (and the outer lifecycle hooks) see it. Hook-carrying builds
|
|
67
|
+
// are git-based, so updateEnv's gate does not fire here.
|
|
68
|
+
ctx.updateEnv(hooks.globalContext.env);
|
|
69
|
+
if (failedLocally) {
|
|
70
|
+
throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `Hook "${hookKey}" failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Check the failing hook step's logs above.`, { cause: firstError });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BuildJob, HookAnchorId, HookKey } from '@expo/eas-build-job';
|
|
2
|
+
import { BuildStepGlobalContext, HookEntry } from '@expo/steps';
|
|
3
|
+
import { BuildContext } from '../context';
|
|
4
|
+
import { CustomBuildContext } from '../customBuildContext';
|
|
5
|
+
export interface ParsedJobHooks {
|
|
6
|
+
globalContext: BuildStepGlobalContext;
|
|
7
|
+
customBuildContext: CustomBuildContext;
|
|
8
|
+
hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parses the native job's `hooks` into engine-ready entries, once, at setup
|
|
12
|
+
* time. Shape-validates every registered key (reachable or not); constructs
|
|
13
|
+
* entries only for the anchors this build actually wraps. Returns null when the
|
|
14
|
+
* job declares no hooks.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseJobHooksAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>, wrappedAnchors: readonly HookAnchorId[]): Promise<ParsedJobHooks | null>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseJobHooksAsync = parseJobHooksAsync;
|
|
4
|
+
const eas_build_job_1 = require("@expo/eas-build-job");
|
|
5
|
+
const steps_1 = require("@expo/steps");
|
|
6
|
+
const customBuildContext_1 = require("../customBuildContext");
|
|
7
|
+
const easFunctionGroups_1 = require("../steps/easFunctionGroups");
|
|
8
|
+
const easFunctions_1 = require("../steps/easFunctions");
|
|
9
|
+
/**
|
|
10
|
+
* Parses the native job's `hooks` into engine-ready entries, once, at setup
|
|
11
|
+
* time. Shape-validates every registered key (reachable or not); constructs
|
|
12
|
+
* entries only for the anchors this build actually wraps. Returns null when the
|
|
13
|
+
* job declares no hooks.
|
|
14
|
+
*/
|
|
15
|
+
async function parseJobHooksAsync(ctx, wrappedAnchors) {
|
|
16
|
+
const hooks = ctx.job.hooks;
|
|
17
|
+
if (!hooks || Object.keys(hooks).length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const customBuildContext = new customBuildContext_1.CustomBuildContext(ctx);
|
|
21
|
+
const globalContext = new steps_1.BuildStepGlobalContext(customBuildContext, false);
|
|
22
|
+
// Hook steps run in the checked-out project directory, not the pre-checkout
|
|
23
|
+
// target directory the global context defaults to.
|
|
24
|
+
globalContext.markAsCheckedOut(ctx.logger);
|
|
25
|
+
const externalFunctions = (0, easFunctions_1.getEasFunctions)(customBuildContext);
|
|
26
|
+
const externalFunctionGroups = (0, easFunctionGroups_1.getEasFunctionGroups)(customBuildContext);
|
|
27
|
+
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
|
28
|
+
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
|
29
|
+
// warn on keys this build won't run. An unknown key is inert — a key newer
|
|
30
|
+
// than this worker must be ignored, never fail the job.
|
|
31
|
+
for (const [key, steps] of Object.entries(hooks)) {
|
|
32
|
+
const parsed = (0, eas_build_job_1.parseHookKey)(key);
|
|
33
|
+
if (parsed === null) {
|
|
34
|
+
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(steps) && steps.length === 0) {
|
|
38
|
+
// `[]` is an explicit opt-out; validateSteps requires >= 1 step.
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
(0, eas_build_job_1.validateSteps)(steps);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `Invalid steps in "hooks.${key}": ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
46
|
+
}
|
|
47
|
+
// Registered but not wrapped by this build: shape-checked above, then
|
|
48
|
+
// skipped. Function existence is deliberately NOT checked (that happens only
|
|
49
|
+
// during construction) — an unknown `uses:` under an unwrapped key warns
|
|
50
|
+
// rather than failing the build.
|
|
51
|
+
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
|
52
|
+
ctx.logger.warn(`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
|
56
|
+
// before_ then after_) so generated step ids and output references follow the
|
|
57
|
+
// order steps actually run. All entries share one globalContext, so env and
|
|
58
|
+
// outputs accumulate across keys.
|
|
59
|
+
const hookEntriesByKey = {};
|
|
60
|
+
const orderedSteps = [];
|
|
61
|
+
for (const anchor of wrappedAnchors) {
|
|
62
|
+
for (const side of ['before', 'after']) {
|
|
63
|
+
const key = `${side}_${anchor}`;
|
|
64
|
+
const steps = hooks[key];
|
|
65
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await (0, steps_1.constructHookEntriesAsync)(globalContext, steps, {
|
|
71
|
+
externalFunctions,
|
|
72
|
+
externalFunctionGroups,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `Failed to parse hooks.${key}: ${err instanceof Error ? err.message : String(err)}. Hooks use the same syntax as workflow steps.`, { cause: err });
|
|
77
|
+
}
|
|
78
|
+
hookEntriesByKey[key] = entries;
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
orderedSteps.push(...entry.steps);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// One aggregate pass over every constructed step in execution order: unique
|
|
85
|
+
// ids, output references, and platform allowance. Per-key validation misses
|
|
86
|
+
// cross-key id collisions and would reject a legal after -> before output
|
|
87
|
+
// reference as a future reference.
|
|
88
|
+
try {
|
|
89
|
+
await (0, steps_1.validateHookStepsAsync)(globalContext, orderedSteps);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw new eas_build_job_1.UserError(eas_build_job_1.ErrorCode.HOOKS_ERROR, `The job's hooks are invalid: ${err instanceof Error ? err.message : String(err)}. Fix the reported step ids or output references.`, { cause: err });
|
|
93
|
+
}
|
|
94
|
+
return { globalContext, customBuildContext, hookEntriesByKey };
|
|
95
|
+
}
|
package/dist/common/setup.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
import { BuildJob } from '@expo/eas-build-job';
|
|
1
|
+
import { BuildJob, HookAnchorId } from '@expo/eas-build-job';
|
|
2
|
+
import { ParsedJobHooks } from './jobHooks';
|
|
2
3
|
import { BuildContext } from '../context';
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Mutable holder the builder passes into setupAsync. setupAsync assigns
|
|
6
|
+
* `current` the moment hooks are parsed (before any hook runs), so the builder's
|
|
7
|
+
* finally can drain queued metric uploads even if setup fails partway through.
|
|
8
|
+
*/
|
|
9
|
+
export interface JobHooksRef {
|
|
10
|
+
current: ParsedJobHooks | null;
|
|
11
|
+
}
|
|
12
|
+
export declare function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>, { wrappedAnchors, jobHooksRef, }: {
|
|
13
|
+
wrappedAnchors: readonly HookAnchorId[];
|
|
14
|
+
jobHooksRef: JobHooksRef;
|
|
15
|
+
}): Promise<void>;
|
package/dist/common/setup.js
CHANGED
|
@@ -10,7 +10,9 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
|
10
10
|
const nullthrows_1 = __importDefault(require("nullthrows"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const easBuildInternal_1 = require("./easBuildInternal");
|
|
13
|
+
const hookableBuildPhase_1 = require("./hookableBuildPhase");
|
|
13
14
|
const installDependencies_1 = require("./installDependencies");
|
|
15
|
+
const jobHooks_1 = require("./jobHooks");
|
|
14
16
|
const projectSources_1 = require("./projectSources");
|
|
15
17
|
const xcodeEnv_1 = require("../ios/xcodeEnv");
|
|
16
18
|
const hooks_1 = require("../utils/hooks");
|
|
@@ -26,7 +28,7 @@ class DoctorTimeoutError extends Error {
|
|
|
26
28
|
}
|
|
27
29
|
class InstallDependenciesTimeoutError extends Error {
|
|
28
30
|
}
|
|
29
|
-
async function setupAsync(ctx) {
|
|
31
|
+
async function setupAsync(ctx, { wrappedAnchors, jobHooksRef, }) {
|
|
30
32
|
await ctx.runBuildPhase(eas_build_job_1.BuildPhase.PREPARE_PROJECT, async () => {
|
|
31
33
|
await (0, retry_1.retryAsync)(async () => {
|
|
32
34
|
await fs_extra_1.default.rm(ctx.buildDirectory, { recursive: true, force: true });
|
|
@@ -64,27 +66,42 @@ async function setupAsync(ctx) {
|
|
|
64
66
|
ctx.logger.error({ err }, `Failed to read eas.json.`);
|
|
65
67
|
}
|
|
66
68
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
await ctx.runBuildPhase(eas_build_job_1.BuildPhase.READ_PACKAGE_JSON, async () => {
|
|
70
|
+
(0, project_1.readAndLogPackageJson)(ctx.logger, ctx.getReactNativeProjectDirectory());
|
|
69
71
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// Parse hooks unconditionally, after every pre-install env mutation
|
|
73
|
+
// (PREPARE_PROJECT, the git-based profile-env block, PRE_INSTALL_HOOK) and
|
|
74
|
+
// before the first wrapped phase.
|
|
75
|
+
jobHooksRef.current = await (0, jobHooks_1.parseJobHooksAsync)(ctx, wrappedAnchors);
|
|
76
|
+
await (0, hookableBuildPhase_1.runHookableBuildPhaseAsync)({
|
|
77
|
+
ctx,
|
|
78
|
+
hooks: jobHooksRef.current,
|
|
79
|
+
buildPhase: eas_build_job_1.BuildPhase.INSTALL_DEPENDENCIES,
|
|
80
|
+
anchor: 'install_node_modules',
|
|
81
|
+
fn: async () => {
|
|
82
|
+
// Read package.json fresh here rather than reusing the READ_PACKAGE_JSON
|
|
83
|
+
// capture: a before_install_node_modules hook may have patched it, and the
|
|
84
|
+
// steps-world install already reads it fresh. Silent read — READ_PACKAGE_JSON
|
|
85
|
+
// stays the only logging site.
|
|
86
|
+
const freshPackageJson = (0, project_1.readPackageJson)(ctx.getReactNativeProjectDirectory());
|
|
87
|
+
const expoVersion = ctx.metadata?.sdkVersion ??
|
|
88
|
+
(0, packageManager_1.getPackageVersionFromPackageJson)({
|
|
89
|
+
packageJson: freshPackageJson,
|
|
90
|
+
packageName: 'expo',
|
|
91
|
+
});
|
|
92
|
+
const reactNativeVersion = ctx.metadata?.reactNativeVersion ??
|
|
93
|
+
(0, packageManager_1.getPackageVersionFromPackageJson)({
|
|
94
|
+
packageJson: freshPackageJson,
|
|
95
|
+
packageName: 'react-native',
|
|
96
|
+
});
|
|
97
|
+
await runInstallDependenciesAsync(ctx, {
|
|
98
|
+
useFrozenLockfile: (0, packageManager_1.shouldUseFrozenLockfile)({
|
|
99
|
+
env: ctx.env,
|
|
100
|
+
sdkVersion: expoVersion,
|
|
101
|
+
reactNativeVersion,
|
|
102
|
+
}),
|
|
75
103
|
});
|
|
76
|
-
|
|
77
|
-
(0, packageManager_1.getPackageVersionFromPackageJson)({
|
|
78
|
-
packageJson,
|
|
79
|
-
packageName: 'react-native',
|
|
80
|
-
});
|
|
81
|
-
await runInstallDependenciesAsync(ctx, {
|
|
82
|
-
useFrozenLockfile: (0, packageManager_1.shouldUseFrozenLockfile)({
|
|
83
|
-
env: ctx.env,
|
|
84
|
-
sdkVersion: expoVersion,
|
|
85
|
-
reactNativeVersion,
|
|
86
|
-
}),
|
|
87
|
-
});
|
|
104
|
+
},
|
|
88
105
|
});
|
|
89
106
|
await ctx.runBuildPhase(eas_build_job_1.BuildPhase.READ_APP_CONFIG, async () => {
|
|
90
107
|
const appConfig = await ctx.appConfig;
|
|
@@ -111,7 +128,9 @@ async function setupAsync(ctx) {
|
|
|
111
128
|
ctx.updateJobInformation(newJob, newMetadata);
|
|
112
129
|
});
|
|
113
130
|
}
|
|
114
|
-
|
|
131
|
+
// Read fresh: a before_install_node_modules hook may have added or removed
|
|
132
|
+
// the expo dependency.
|
|
133
|
+
const hasExpoPackage = !!(0, project_1.readPackageJson)(ctx.getReactNativeProjectDirectory()).dependencies?.expo;
|
|
115
134
|
if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) {
|
|
116
135
|
await ctx.runBuildPhase(eas_build_job_1.BuildPhase.RUN_EXPO_DOCTOR, async () => {
|
|
117
136
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as Builders from './builders';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
export {
|
|
2
|
+
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
|
3
|
+
export { Builders, RemoteLoggerStream };
|
|
4
|
+
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
|
5
|
+
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
|
5
6
|
export { ArtifactToUpload, Artifacts, BuildContext, BuildContextOptions, CacheManager, LogBuffer, SkipNativeBuildError, } from './context';
|
|
6
7
|
export { PackageManager } from './utils/packageManager';
|
|
7
8
|
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
package/dist/index.js
CHANGED
|
@@ -39,13 +39,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
39
39
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
40
40
|
};
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
-
exports.Sentry = exports.Datadog = exports.formatGradleProfileReport = exports.parseGradleProfile = exports.runHookIfPresent = exports.Hook = exports.findAndUploadXcodeBuildLogsAsync = exports.PackageManager = exports.SkipNativeBuildError = exports.BuildContext = exports.
|
|
42
|
+
exports.Sentry = exports.Datadog = exports.formatGradleProfileReport = exports.parseGradleProfile = exports.runHookIfPresent = exports.Hook = exports.findAndUploadXcodeBuildLogsAsync = exports.PackageManager = exports.SkipNativeBuildError = exports.BuildContext = exports.uploadWithSignedUrl = exports.RemoteLoggerStream = exports.Builders = void 0;
|
|
43
43
|
const Builders = __importStar(require("./builders"));
|
|
44
44
|
exports.Builders = Builders;
|
|
45
|
-
const
|
|
46
|
-
exports.
|
|
47
|
-
|
|
48
|
-
Object.defineProperty(exports, "
|
|
45
|
+
const RemoteLoggerStream_1 = __importDefault(require("./logging/RemoteLoggerStream"));
|
|
46
|
+
exports.RemoteLoggerStream = RemoteLoggerStream_1.default;
|
|
47
|
+
var uploadWithSignedUrl_1 = require("./storage/uploadWithSignedUrl");
|
|
48
|
+
Object.defineProperty(exports, "uploadWithSignedUrl", { enumerable: true, get: function () { return uploadWithSignedUrl_1.uploadWithSignedUrl; } });
|
|
49
49
|
var context_1 = require("./context");
|
|
50
50
|
Object.defineProperty(exports, "BuildContext", { enumerable: true, get: function () { return context_1.BuildContext; } });
|
|
51
51
|
Object.defineProperty(exports, "SkipNativeBuildError", { enumerable: true, get: function () { return context_1.SkipNativeBuildError; } });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { bunyan } from '@expo/logger';
|
|
2
2
|
import { Writable } from 'stream';
|
|
3
|
-
import {
|
|
4
|
-
declare class
|
|
3
|
+
import { type SignedUrl } from '../storage/uploadWithSignedUrl';
|
|
4
|
+
declare class RemoteLoggerStream extends Writable {
|
|
5
5
|
writable: boolean;
|
|
6
6
|
private readonly logger;
|
|
7
7
|
private readonly uploadMethod?;
|
|
@@ -16,7 +16,7 @@ declare class GCSLoggerStream extends Writable {
|
|
|
16
16
|
private buffer;
|
|
17
17
|
private writePromise?;
|
|
18
18
|
private cleanUpCalled;
|
|
19
|
-
constructor({ logger, uploadMethod, options }:
|
|
19
|
+
constructor({ logger, uploadMethod, options }: RemoteLoggerStream.Config);
|
|
20
20
|
private findNormalizedHeader;
|
|
21
21
|
init(): Promise<string>;
|
|
22
22
|
cleanUp(): Promise<void>;
|
|
@@ -27,7 +27,7 @@ declare class GCSLoggerStream extends Writable {
|
|
|
27
27
|
private upload;
|
|
28
28
|
private createCompressedStream;
|
|
29
29
|
}
|
|
30
|
-
declare namespace
|
|
30
|
+
declare namespace RemoteLoggerStream {
|
|
31
31
|
enum CompressionMethod {
|
|
32
32
|
GZIP = "gzip",
|
|
33
33
|
BR = "br"
|
|
@@ -37,7 +37,7 @@ declare namespace GCSLoggerStream {
|
|
|
37
37
|
compress?: CompressionMethod;
|
|
38
38
|
}
|
|
39
39
|
type UploadMethod = {
|
|
40
|
-
signedUrl:
|
|
40
|
+
signedUrl: SignedUrl;
|
|
41
41
|
};
|
|
42
42
|
interface Config {
|
|
43
43
|
logger: bunyan;
|
|
@@ -45,4 +45,4 @@ declare namespace GCSLoggerStream {
|
|
|
45
45
|
options: Options;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
export default
|
|
48
|
+
export default RemoteLoggerStream;
|
|
@@ -11,9 +11,9 @@ const path_1 = __importDefault(require("path"));
|
|
|
11
11
|
const stream_1 = require("stream");
|
|
12
12
|
const util_1 = require("util");
|
|
13
13
|
const zlib_1 = __importDefault(require("zlib"));
|
|
14
|
-
const
|
|
14
|
+
const uploadWithSignedUrl_1 = require("../storage/uploadWithSignedUrl");
|
|
15
15
|
const pipe = (0, util_1.promisify)(stream_1.pipeline);
|
|
16
|
-
class
|
|
16
|
+
class RemoteLoggerStream extends stream_1.Writable {
|
|
17
17
|
writable = true;
|
|
18
18
|
logger;
|
|
19
19
|
uploadMethod;
|
|
@@ -74,7 +74,7 @@ class GCSLoggerStream extends stream_1.Writable {
|
|
|
74
74
|
await this.flush(true);
|
|
75
75
|
await fs_extra_1.default.remove(this.temporaryLogsPath);
|
|
76
76
|
await fs_extra_1.default.remove(this.temporaryCompressedLogsPath);
|
|
77
|
-
this.logger.info('Cleaning up
|
|
77
|
+
this.logger.info('Cleaning up log stream');
|
|
78
78
|
}
|
|
79
79
|
write(rec) {
|
|
80
80
|
if (!this.fileHandle) {
|
|
@@ -102,7 +102,7 @@ class GCSLoggerStream extends stream_1.Writable {
|
|
|
102
102
|
this.hasChangesToUpload = true;
|
|
103
103
|
}
|
|
104
104
|
catch (err) {
|
|
105
|
-
this.logger.error({ err, origin: '
|
|
105
|
+
this.logger.error({ err, origin: 'remote-logger' }, 'Failed to write logs to file');
|
|
106
106
|
}
|
|
107
107
|
finally {
|
|
108
108
|
this.writePromise = undefined;
|
|
@@ -129,7 +129,7 @@ class GCSLoggerStream extends stream_1.Writable {
|
|
|
129
129
|
return result;
|
|
130
130
|
})
|
|
131
131
|
.catch(err => {
|
|
132
|
-
this.logger.error({ err }, 'Failed to upload logs file
|
|
132
|
+
this.logger.error({ err }, 'Failed to upload logs file');
|
|
133
133
|
})
|
|
134
134
|
.then(result => {
|
|
135
135
|
this.uploadingPromise = undefined;
|
|
@@ -145,7 +145,7 @@ class GCSLoggerStream extends stream_1.Writable {
|
|
|
145
145
|
const srcGeneratorAsync = async () => {
|
|
146
146
|
return await this.createCompressedStream(fs_extra_1.default.createReadStream(this.temporaryLogsPath, { end: size }));
|
|
147
147
|
};
|
|
148
|
-
return await
|
|
148
|
+
return await (0, uploadWithSignedUrl_1.uploadWithSignedUrl)({
|
|
149
149
|
signedUrl: this.uploadMethod.signedUrl,
|
|
150
150
|
srcGeneratorAsync,
|
|
151
151
|
});
|
|
@@ -170,11 +170,11 @@ class GCSLoggerStream extends stream_1.Writable {
|
|
|
170
170
|
return fs_extra_1.default.createReadStream(this.temporaryCompressedLogsPath, { end: size });
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
-
(function (
|
|
173
|
+
(function (RemoteLoggerStream) {
|
|
174
174
|
let CompressionMethod;
|
|
175
175
|
(function (CompressionMethod) {
|
|
176
176
|
CompressionMethod["GZIP"] = "gzip";
|
|
177
177
|
CompressionMethod["BR"] = "br";
|
|
178
|
-
})(CompressionMethod =
|
|
179
|
-
})(
|
|
180
|
-
exports.default =
|
|
178
|
+
})(CompressionMethod = RemoteLoggerStream.CompressionMethod || (RemoteLoggerStream.CompressionMethod = {}));
|
|
179
|
+
})(RemoteLoggerStream || (RemoteLoggerStream = {}));
|
|
180
|
+
exports.default = RemoteLoggerStream;
|
|
@@ -17,6 +17,7 @@ export interface JUnitTestCaseResult {
|
|
|
17
17
|
tags: string[];
|
|
18
18
|
properties: Record<string, string>;
|
|
19
19
|
}
|
|
20
|
+
export declare function parseFailedFlowNamesFromJUnitFile(junitFile: string): Promise<Set<string>>;
|
|
20
21
|
export declare function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]>;
|
|
21
22
|
export declare function isFileAttrRun(testcases: JUnitTestCaseResult[]): testcases is (JUnitTestCaseResult & {
|
|
22
23
|
file: string;
|
|
@@ -3,6 +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.parseFailedFlowNamesFromJUnitFile = parseFailedFlowNamesFromJUnitFile;
|
|
6
7
|
exports.parseJUnitTestCases = parseJUnitTestCases;
|
|
7
8
|
exports.isFileAttrRun = isFileAttrRun;
|
|
8
9
|
exports.junitFileHasFileAttrs = junitFileHasFileAttrs;
|
|
@@ -99,6 +100,17 @@ async function parseJUnitFile(filePath) {
|
|
|
99
100
|
return [];
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
// Raw names (slashes kept) of the testcases a single JUnit file marks failed. Best-effort: an
|
|
104
|
+
// unreadable or malformed file yields an empty set rather than throwing.
|
|
105
|
+
async function parseFailedFlowNamesFromJUnitFile(junitFile) {
|
|
106
|
+
const failed = new Set();
|
|
107
|
+
for (const testcase of await parseJUnitFile(junitFile)) {
|
|
108
|
+
if (testcase.status === 'failed') {
|
|
109
|
+
failed.add(testcase.name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return failed;
|
|
113
|
+
}
|
|
102
114
|
async function parseJUnitTestCases(junitDirectory) {
|
|
103
115
|
let entries;
|
|
104
116
|
try {
|
|
@@ -18,6 +18,7 @@ export declare function harvestFailureScreenshotsAsync(args: {
|
|
|
18
18
|
testsDirectory: string;
|
|
19
19
|
capturedSinceMs: number;
|
|
20
20
|
attemptIndex: number;
|
|
21
|
+
failedFlowNames: ReadonlySet<string>;
|
|
21
22
|
logger: bunyan;
|
|
22
23
|
}): Promise<HarvestedScreenshot[]>;
|
|
23
24
|
export declare function computePureFailureFlowNames(testCases: readonly {
|
|
@@ -9,10 +9,18 @@ exports.computePureFailureFlowNames = computePureFailureFlowNames;
|
|
|
9
9
|
exports.selectFailureScreenshots = selectFailureScreenshots;
|
|
10
10
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
-
// Maestro
|
|
12
|
+
// pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
|
|
13
|
+
// `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
|
|
13
14
|
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
|
|
14
|
-
// `).png` suffix rather than scanning for parens.
|
|
15
|
+
// `).png` suffix rather than scanning for parens. Remove this path once the build fleet is
|
|
16
|
+
// entirely on Maestro >= 2.7.0.
|
|
15
17
|
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
|
|
18
|
+
// Maestro >= 2.7.0 bundle layout. Each flow gets its own dir under the session dir, with step
|
|
19
|
+
// screenshots under `screenshots/` named `step-<NNN>-<slug>.png` (NNN is a 1-based, zero-padded
|
|
20
|
+
// monotonic step sequence). Under the CLI (no `--analyze`) only failed/warned steps produce one.
|
|
21
|
+
const STEP_SCREENSHOTS_DIRNAME = 'screenshots';
|
|
22
|
+
const STEP_SCREENSHOT_PATTERN = /^step-(\d+)(?:-.*)?\.png$/;
|
|
23
|
+
// pre-v2.7.0: parses the legacy flat failure-screenshot filename.
|
|
16
24
|
function parseFailureScreenshotFilename(filename) {
|
|
17
25
|
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
|
|
18
26
|
if (!match) {
|
|
@@ -27,61 +35,186 @@ function parseFailureScreenshotFilename(filename) {
|
|
|
27
35
|
capturedAtMs,
|
|
28
36
|
};
|
|
29
37
|
}
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
38
|
+
// Harvests one attempt's failure screenshots from the maestro debug output. Walks each session
|
|
39
|
+
// dir under testsDirectory whose mtime is recent enough (mtime-based to also cover the maestro
|
|
40
|
+
// <2.5.0 bug that splits one invocation across two dirs). Within a session dir, a child is either
|
|
41
|
+
// a flat pre-v2.7.0 screenshot file or a Maestro >= 2.7.0 per-flow bundle dir — the single
|
|
42
|
+
// structural seam below. Never throws: an unreadable dir/file is logged and skipped so a
|
|
43
|
+
// screenshot harvest can't affect the maestro step outcome.
|
|
34
44
|
async function harvestFailureScreenshotsAsync(args) {
|
|
35
45
|
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
|
|
36
|
-
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate
|
|
46
|
+
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate per file is what
|
|
37
47
|
// prevents mis-attributing an earlier attempt's screenshot.
|
|
38
48
|
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
|
|
39
|
-
|
|
49
|
+
// Bundle dir names already have '/' -> '_'; normalize the JUnit names the same way to match.
|
|
50
|
+
const normalizedFailedFlowNames = new Set([...args.failedFlowNames].map(normalizeFlowName));
|
|
51
|
+
let sessionEntries;
|
|
40
52
|
try {
|
|
41
|
-
|
|
53
|
+
sessionEntries = await promises_1.default.readdir(args.testsDirectory, { withFileTypes: true });
|
|
42
54
|
}
|
|
43
55
|
catch (err) {
|
|
44
56
|
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
|
|
45
57
|
return [];
|
|
46
58
|
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const legacyShots = [];
|
|
60
|
+
const bundleShots = [];
|
|
61
|
+
for (const sessionEntry of sessionEntries) {
|
|
62
|
+
if (!sessionEntry.isDirectory()) {
|
|
50
63
|
continue;
|
|
51
64
|
}
|
|
52
|
-
const
|
|
53
|
-
let
|
|
65
|
+
const sessionDir = path_1.default.join(args.testsDirectory, sessionEntry.name);
|
|
66
|
+
let children;
|
|
54
67
|
try {
|
|
55
|
-
if ((await promises_1.default.stat(
|
|
68
|
+
if ((await promises_1.default.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
|
|
56
69
|
continue;
|
|
57
70
|
}
|
|
58
|
-
|
|
71
|
+
children = await promises_1.default.readdir(sessionDir, { withFileTypes: true });
|
|
59
72
|
}
|
|
60
73
|
catch (err) {
|
|
61
|
-
args.logger.info({ err }, `Skipping unreadable debug dir ${
|
|
74
|
+
args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
|
|
62
75
|
continue;
|
|
63
76
|
}
|
|
64
|
-
for (const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
for (const child of children) {
|
|
78
|
+
if (child.isDirectory()) {
|
|
79
|
+
// Maestro >= 2.7.0: a per-flow bundle dir.
|
|
80
|
+
const shot = await harvestFromBundleAsync({
|
|
81
|
+
bundleDir: path_1.default.join(sessionDir, child.name),
|
|
82
|
+
bundleName: child.name,
|
|
83
|
+
normalizedFailedFlowNames,
|
|
84
|
+
capturedSinceMs: args.capturedSinceMs,
|
|
85
|
+
attemptIndex: args.attemptIndex,
|
|
86
|
+
logger: args.logger,
|
|
87
|
+
});
|
|
88
|
+
if (shot !== null) {
|
|
89
|
+
bundleShots.push(shot);
|
|
90
|
+
}
|
|
71
91
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
else {
|
|
93
|
+
// pre-v2.7.0: a flat failure screenshot file in the session dir.
|
|
94
|
+
const shot = harvestLegacyFlatShot({
|
|
95
|
+
fileName: child.name,
|
|
96
|
+
sessionDir,
|
|
97
|
+
capturedSinceMs: args.capturedSinceMs,
|
|
78
98
|
attemptIndex: args.attemptIndex,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
});
|
|
100
|
+
if (shot !== null) {
|
|
101
|
+
legacyShots.push(shot);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// The website shows one screenshot per (flow, attempt). Two v2.7.0 bundle dirs can resolve to
|
|
107
|
+
// the same flow — a collision dir (`<flow>-2`) or a duplicate flow name — so keep the latest.
|
|
108
|
+
// Legacy shots need no dedupe: a flat filename is emitted once per failed flow, so there is no
|
|
109
|
+
// `<flow>-2` collision to collapse (and deduping them would change historical behavior).
|
|
110
|
+
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
|
111
|
+
}
|
|
112
|
+
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
|
113
|
+
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
|
114
|
+
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|
|
115
|
+
// lower numbers. Best-effort limitation: without reading Maestro's per-command JSON we can't tell a
|
|
116
|
+
// failed-step frame from a warned-step frame, so if the failing command captured nothing (e.g. a
|
|
117
|
+
// non-visible command like runScript) but an earlier optional step warned, we return that warned
|
|
118
|
+
// frame instead. Returns null when the bundle isn't a failed flow, its name is ambiguous, or it has
|
|
119
|
+
// no usable step screenshot. Never throws.
|
|
120
|
+
async function harvestFromBundleAsync(args) {
|
|
121
|
+
const flowName = resolveBundleFlowName(args.bundleName, args.normalizedFailedFlowNames);
|
|
122
|
+
if (flowName === null) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const screenshotsDir = path_1.default.join(args.bundleDir, STEP_SCREENSHOTS_DIRNAME);
|
|
126
|
+
let filenames;
|
|
127
|
+
try {
|
|
128
|
+
filenames = await promises_1.default.readdir(screenshotsDir);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// No screenshots/ dir — e.g. the flow failed on a non-visible command, which captures none.
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
let best = null;
|
|
135
|
+
for (const filename of filenames) {
|
|
136
|
+
const match = filename.match(STEP_SCREENSHOT_PATTERN);
|
|
137
|
+
if (match === null) {
|
|
138
|
+
continue; // final.png and any non-step file
|
|
139
|
+
}
|
|
140
|
+
const stepIndex = Number(match[1]);
|
|
141
|
+
const fileAbsPath = path_1.default.join(screenshotsDir, filename);
|
|
142
|
+
let capturedAtMs;
|
|
143
|
+
try {
|
|
144
|
+
capturedAtMs = Math.round((await promises_1.default.stat(fileAbsPath)).mtimeMs);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (capturedAtMs < args.capturedSinceMs) {
|
|
151
|
+
continue; // a prior attempt's frame (bundle dir touched later than this attempt started)
|
|
152
|
+
}
|
|
153
|
+
if (best === null || stepIndex > best.stepIndex) {
|
|
154
|
+
best = { fileAbsPath, stepIndex, capturedAtMs };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (best === null) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
fileAbsPath: best.fileAbsPath,
|
|
162
|
+
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
|
163
|
+
metadata: {
|
|
164
|
+
kind: 'maestro-test-screenshot',
|
|
165
|
+
flowName,
|
|
166
|
+
attemptIndex: args.attemptIndex,
|
|
167
|
+
capturedAtMs: best.capturedAtMs,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// Maestro names a bundle dir `<cleanFlow>` optionally + `-shard-<n>` (sharded) + `-<n>` (name
|
|
172
|
+
// collision), where cleanFlow is the flow name with '/' -> '_'. Those suffixes share a namespace
|
|
173
|
+
// with legitimate flow names, so we can't strip blindly (a flow may be named `x-shard-1` or `x-2`).
|
|
174
|
+
// Enumerate the ways the dir name could peel back to a flow name, keep those that match a failed
|
|
175
|
+
// flow, and use the result only if exactly one fits — otherwise skip rather than guess.
|
|
176
|
+
function resolveBundleFlowName(bundleName, normalizedFailedFlowNames) {
|
|
177
|
+
const withoutCollision = bundleName.replace(/-\d+$/, '');
|
|
178
|
+
const preimages = [
|
|
179
|
+
bundleName,
|
|
180
|
+
withoutCollision,
|
|
181
|
+
withoutCollision.replace(/-shard-\d+$/, ''),
|
|
182
|
+
bundleName.replace(/-shard-\d+$/, ''),
|
|
183
|
+
];
|
|
184
|
+
const matches = new Set(preimages.filter(name => normalizedFailedFlowNames.has(name)));
|
|
185
|
+
return matches.size === 1 ? [...matches][0] : null;
|
|
186
|
+
}
|
|
187
|
+
// Keep one screenshot per flow name (attemptIndex is constant within a harvest call), preferring
|
|
188
|
+
// the latest capture when two bundles resolve to the same flow.
|
|
189
|
+
function dedupeBundleShotsByFlowName(shots) {
|
|
190
|
+
const byFlowName = new Map();
|
|
191
|
+
for (const shot of shots) {
|
|
192
|
+
const existing = byFlowName.get(shot.metadata.flowName);
|
|
193
|
+
if (existing === undefined || shot.metadata.capturedAtMs > existing.metadata.capturedAtMs) {
|
|
194
|
+
byFlowName.set(shot.metadata.flowName, shot);
|
|
82
195
|
}
|
|
83
196
|
}
|
|
84
|
-
return
|
|
197
|
+
return [...byFlowName.values()];
|
|
198
|
+
}
|
|
199
|
+
// pre-v2.7.0 (Maestro <= 2.6.x) reader: a flat `screenshot-...❌...png` file whose name carries the
|
|
200
|
+
// flow name and capture epoch.
|
|
201
|
+
function harvestLegacyFlatShot(args) {
|
|
202
|
+
const parsed = parseFailureScreenshotFilename(args.fileName);
|
|
203
|
+
// Re-check capture time per file — a dir's mtime can advance after this attempt began, so the dir
|
|
204
|
+
// gate alone would re-attribute an earlier attempt's screenshot to this one.
|
|
205
|
+
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
fileAbsPath: path_1.default.join(args.sessionDir, args.fileName),
|
|
210
|
+
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
|
211
|
+
metadata: {
|
|
212
|
+
kind: 'maestro-test-screenshot',
|
|
213
|
+
flowName: parsed.flowName,
|
|
214
|
+
attemptIndex: args.attemptIndex,
|
|
215
|
+
capturedAtMs: parsed.capturedAtMs,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
85
218
|
}
|
|
86
219
|
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
|
|
87
220
|
// is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
|
|
@@ -216,10 +216,14 @@ function createMaestroTestsBuildFunction(ctx) {
|
|
|
216
216
|
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
|
217
217
|
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
|
218
218
|
if (outputFormat === 'junit') {
|
|
219
|
+
const failedFlowNames = outputPath
|
|
220
|
+
? await (0, maestroResultParser_1.parseFailedFlowNamesFromJUnitFile)(outputPath)
|
|
221
|
+
: new Set();
|
|
219
222
|
harvested.push(...(await (0, maestroScreenshots_1.harvestFailureScreenshotsAsync)({
|
|
220
223
|
testsDirectory,
|
|
221
224
|
capturedSinceMs: attemptStartedAtMs,
|
|
222
225
|
attemptIndex: attempt,
|
|
226
|
+
failedFlowNames,
|
|
223
227
|
logger,
|
|
224
228
|
})));
|
|
225
229
|
}
|
|
@@ -5,7 +5,7 @@ type RetryOptions = {
|
|
|
5
5
|
shouldRetryOnError: (error: unknown) => boolean;
|
|
6
6
|
shouldRetryOnResponse: (response: Response) => boolean;
|
|
7
7
|
};
|
|
8
|
-
export declare function
|
|
8
|
+
export declare function retryOnUploadFailure(fn: (attemptCount: number) => Promise<Response>, { retries, retryIntervalMs, }: {
|
|
9
9
|
retries: RetryOptions['retries'];
|
|
10
10
|
retryIntervalMs: RetryOptions['retryIntervalMs'];
|
|
11
11
|
}): Promise<Response>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.retryOnUploadFailure = retryOnUploadFailure;
|
|
4
4
|
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
|
5
|
-
async function
|
|
5
|
+
async function retryOnUploadFailure(fn, { retries, retryIntervalMs, }) {
|
|
6
6
|
return await retry(fn, {
|
|
7
7
|
retries,
|
|
8
8
|
retryIntervalMs,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
export type SignedUrl = {
|
|
3
|
+
url: string;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
export type UploadWithSignedUrlParams = {
|
|
7
|
+
signedUrl: SignedUrl;
|
|
8
|
+
srcGeneratorAsync: () => Promise<Readable>;
|
|
9
|
+
retryIntervalMs?: number;
|
|
10
|
+
retries?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries, retryIntervalMs, }: UploadWithSignedUrlParams): Promise<string>;
|
|
@@ -33,40 +33,36 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.
|
|
36
|
+
exports.uploadWithSignedUrl = uploadWithSignedUrl;
|
|
37
37
|
const node_fetch_1 = __importStar(require("node-fetch"));
|
|
38
38
|
const url_1 = require("url");
|
|
39
39
|
const retry_1 = require("./retry");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (err instanceof node_fetch_1.FetchError) {
|
|
56
|
-
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
|
57
|
-
}
|
|
58
|
-
throw err;
|
|
40
|
+
async function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries = 2, retryIntervalMs = 30_000, }) {
|
|
41
|
+
let resp;
|
|
42
|
+
try {
|
|
43
|
+
resp = await (0, retry_1.retryOnUploadFailure)(async () => {
|
|
44
|
+
const src = await srcGeneratorAsync();
|
|
45
|
+
return await (0, node_fetch_1.default)(signedUrl.url, {
|
|
46
|
+
method: 'PUT',
|
|
47
|
+
headers: signedUrl.headers,
|
|
48
|
+
body: src,
|
|
49
|
+
});
|
|
50
|
+
}, { retries, retryIntervalMs });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof node_fetch_1.FetchError) {
|
|
54
|
+
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
|
59
55
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new Error(`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`);
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
if (!resp.ok) {
|
|
59
|
+
let body;
|
|
60
|
+
try {
|
|
61
|
+
body = await resp.text();
|
|
67
62
|
}
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
catch { }
|
|
64
|
+
throw new Error(`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`);
|
|
70
65
|
}
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
const url = new url_1.URL(signedUrl.url);
|
|
67
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/build-tools",
|
|
3
|
-
"version": "21.0.
|
|
3
|
+
"version": "21.0.3",
|
|
4
4
|
"bugs": "https://github.com/expo/eas-cli/issues",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Expo <support@expo.io>",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
"@expo/config": "55.0.10",
|
|
40
40
|
"@expo/config-plugins": "55.0.7",
|
|
41
41
|
"@expo/downloader": "21.0.0",
|
|
42
|
-
"@expo/eas-build-job": "21.0.
|
|
42
|
+
"@expo/eas-build-job": "21.0.3",
|
|
43
43
|
"@expo/env": "^0.4.0",
|
|
44
44
|
"@expo/logger": "21.0.0",
|
|
45
45
|
"@expo/package-manager": "1.9.10",
|
|
46
46
|
"@expo/plist": "^0.3.5",
|
|
47
47
|
"@expo/results": "^1.0.0",
|
|
48
48
|
"@expo/spawn-async": "1.7.2",
|
|
49
|
-
"@expo/steps": "21.0.
|
|
49
|
+
"@expo/steps": "21.0.3",
|
|
50
50
|
"@expo/template-file": "21.0.2",
|
|
51
51
|
"@expo/turtle-spawn": "21.0.0",
|
|
52
52
|
"@expo/xcpretty": "^4.3.1",
|
|
@@ -100,5 +100,5 @@
|
|
|
100
100
|
"typescript": "^5.5.4",
|
|
101
101
|
"uuid": "^9.0.1"
|
|
102
102
|
},
|
|
103
|
-
"gitHead": "
|
|
103
|
+
"gitHead": "62d46fece55ab5b27c2a572fc91158214b9ae96b"
|
|
104
104
|
}
|
package/dist/gcs/client.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Readable } from 'stream';
|
|
2
|
-
interface UploadWithSignedUrlParams {
|
|
3
|
-
signedUrl: GCS.SignedUrl;
|
|
4
|
-
srcGeneratorAsync: () => Promise<Readable>;
|
|
5
|
-
retryIntervalMs?: number;
|
|
6
|
-
retries?: number;
|
|
7
|
-
}
|
|
8
|
-
export declare namespace GCS {
|
|
9
|
-
type SignedUrl = {
|
|
10
|
-
url: string;
|
|
11
|
-
headers: Record<string, string>;
|
|
12
|
-
};
|
|
13
|
-
function uploadWithSignedUrl({ signedUrl, srcGeneratorAsync, retries, retryIntervalMs, }: UploadWithSignedUrlParams): Promise<string>;
|
|
14
|
-
}
|
|
15
|
-
export {};
|