Skip to content

Commit 53996cd

Browse files
fix(ci): resilient BrowserStack main reuse + fallback upload (#33277)
## **Description** On test-only PRs, performance E2E reuses BrowserStack Android apps uploaded from `main` instead of compiling fresh dual builds. The resolve step was fragile: it only scanned BrowserStack `recent_apps` for IDs like `MetaMask-Android-*-main-<run_id>`, so main apps that existed could still be missed when the short recent list was flooded by other uploads. When resolve failed hard, dual upload was skipped (`reuse_main_builds`) and performance suites ran with no app. This PR makes main BrowserStack reuse resilient: 1. Upload with a **stable per-branch `custom_id`** (`MetaMask-Android-With-SRP-<slug>` / `…-Without-SRP-<slug>`, no `run_id`) so BrowserStack overwrites the previous app for that ID. 2. Resolve via **`recent_apps/{custom_id}`** for the stable main IDs first, then fall back to scanning the recent list for stable or legacy `…-main-<run_id>` IDs. 3. If nothing is found, emit `found=false` and **fall back to a fresh dual Android build/upload** instead of failing empty. 4. Quiet the unit-test J7 flaky-pattern flag by using `fs.mkdtempSync` instead of `Date.now()` in the temp output path. Follow-up to MMQA-1667 (#33180). Observed on verification PR #33270. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MMQA-1667 ## **Manual testing steps** ```gherkin Feature: BrowserStack main-app resolve for test-only performance PRs Scenario: Resolve finds stable main custom_id Given main has uploaded Android apps with custom_id MetaMask-Android-*-main When a test-only PR runs performance E2E with reuse_main_builds=true Then resolve-main-browserstack-urls sets found=true And dual Android upload is skipped And performance tests use the resolved BrowserStack app URLs Scenario: Resolve miss falls back to fresh upload Given BrowserStack has no resolvable main custom_id When a test-only PR runs performance E2E with reuse_main_builds=true Then resolve-main-browserstack-urls sets found=false And Trigger Android Dual Versions runs and uploads new apps ` ` ` ## **Screenshots/Recordings** ### **Before** N/A — CI-only change, no UI affected. ### **After** N/A — CI-only change, no UI affected. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ``` --- --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: javiergarciavera <javiergarciavera@users.noreply.github.com>
1 parent da9a364 commit 53996cd

6 files changed

Lines changed: 245 additions & 67 deletions

.github/guidelines/E2E_DECISION_TREE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ When a PR only changes E2E/performance test files (and other ignorable files), C
3232

3333
The native build fingerprint for test-only PRs is computed from **`main` HEAD** (not the PR merge tree) so the lookup key matches completed `ci.yml` runs on `main`. Reuse tries GitHub Actions artifacts first, then the Cirrus `main` APK cache on Android.
3434

35-
If `main` has new native-changing commits but its CI build has not finished yet, reuse lookup may miss — CI logs a warning and **falls back to a fresh native build** instead of failing the workflow. Performance E2E on test-only PRs resolves BrowserStack apps by `custom_id` prefix `MetaMask-Android-*-main-*` so only uploads from `main` are reused (not other branches or PRs).
35+
If `main` has new native-changing commits but its CI build has not finished yet, reuse lookup may miss — CI logs a warning and **falls back to a fresh native build** instead of failing the workflow. Performance E2E on test-only PRs resolves BrowserStack apps via stable main `custom_id`s (`MetaMask-Android-*-main`) first, then legacy `…-main-<run_id>` IDs; if none are found it **falls back to a fresh dual Android upload** instead of failing.
3636

3737
This applies when all changed files match `e2e_test_files` or `e2e_ignorable` filters in `.github/rules/filter-rules.yml`, with at least one E2E test file changed, and no E2E-relevant workflow files were modified.
3838

.github/scripts/browserstack-app-validation.cjs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,24 @@
66
/** BrowserStack app URLs use the bs:// scheme with an opaque app hash. */
77
const BROWSERSTACK_APP_URL_PATTERN = /^bs:\/\/[A-Za-z0-9]+$/;
88

9+
/**
10+
* custom_id format after MMQA-1667:
11+
* - Stable per branch: MetaMask-Android-With-SRP-<branchSlug>
12+
* - Legacy with run id: MetaMask-Android-With-SRP-<branchSlug>-<runId>
13+
* Slug must include at least one letter so pure-numeric pre-MMQA IDs are rejected.
14+
*/
915
const WITH_SRP_CUSTOM_ID_PATTERN =
10-
/^MetaMask-Android-With-SRP-[A-Za-z0-9._-]+-\d+$/;
16+
/^MetaMask-Android-With-SRP-(?=.*[A-Za-z])[A-Za-z0-9._-]+$/;
1117
const WITHOUT_SRP_CUSTOM_ID_PATTERN =
12-
/^MetaMask-Android-Without-SRP-[A-Za-z0-9._-]+-\d+$/;
18+
/^MetaMask-Android-Without-SRP-(?=.*[A-Za-z])[A-Za-z0-9._-]+$/;
1319

1420
const MAIN_BRANCH_BROWSERSTACK_SLUG = 'main';
15-
const MAIN_WITH_SRP_CUSTOM_ID_PREFIX = `MetaMask-Android-With-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}-`;
16-
const MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX = `MetaMask-Android-Without-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}-`;
21+
/** Stable custom_id used for main uploads (overwritten on each main upload). */
22+
const MAIN_WITH_SRP_CUSTOM_ID = `MetaMask-Android-With-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}`;
23+
const MAIN_WITHOUT_SRP_CUSTOM_ID = `MetaMask-Android-Without-SRP-${MAIN_BRANCH_BROWSERSTACK_SLUG}`;
24+
/** Prefix for older main uploads that still embed github.run_id. */
25+
const MAIN_WITH_SRP_CUSTOM_ID_PREFIX = `${MAIN_WITH_SRP_CUSTOM_ID}-`;
26+
const MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX = `${MAIN_WITHOUT_SRP_CUSTOM_ID}-`;
1727

1828
/**
1929
* @param {unknown} value
@@ -50,6 +60,30 @@ function assertBrowserStackCustomId(value, kind) {
5060
return assertMatchesPattern(value, pattern, `${kind} custom_id`);
5161
}
5262

63+
/**
64+
* @param {unknown} customId
65+
* @param {'with-srp' | 'without-srp'} kind
66+
* @returns {boolean}
67+
*/
68+
function isMainBranchBrowserStackCustomId(customId, kind) {
69+
if (typeof customId !== 'string') {
70+
return false;
71+
}
72+
const stable =
73+
kind === 'with-srp' ? MAIN_WITH_SRP_CUSTOM_ID : MAIN_WITHOUT_SRP_CUSTOM_ID;
74+
const legacyPrefix =
75+
kind === 'with-srp'
76+
? MAIN_WITH_SRP_CUSTOM_ID_PREFIX
77+
: MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX;
78+
if (customId === stable) {
79+
return true;
80+
}
81+
return (
82+
customId.startsWith(legacyPrefix) &&
83+
/^\d+$/.test(customId.slice(legacyPrefix.length))
84+
);
85+
}
86+
5387
/**
5488
* @param {string} path
5589
* @param {Record<string, string>} outputs
@@ -70,9 +104,12 @@ module.exports = {
70104
WITH_SRP_CUSTOM_ID_PATTERN,
71105
WITHOUT_SRP_CUSTOM_ID_PATTERN,
72106
MAIN_BRANCH_BROWSERSTACK_SLUG,
107+
MAIN_WITH_SRP_CUSTOM_ID,
108+
MAIN_WITHOUT_SRP_CUSTOM_ID,
73109
MAIN_WITH_SRP_CUSTOM_ID_PREFIX,
74110
MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX,
75111
assertBrowserStackAppUrl,
76112
assertBrowserStackCustomId,
113+
isMainBranchBrowserStackCustomId,
77114
writeGithubOutputs,
78115
};

.github/scripts/browserstack-app-validation.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from 'node:path';
55
const {
66
assertBrowserStackAppUrl,
77
assertBrowserStackCustomId,
8+
isMainBranchBrowserStackCustomId,
89
writeGithubOutputs,
910
} = require('./browserstack-app-validation.cjs');
1011

@@ -24,7 +25,10 @@ describe('browserstack-app-validation', () => {
2425
).toThrow('Invalid test');
2526
});
2627

27-
it('accepts expected custom_id formats with branch slug', () => {
28+
it('accepts stable and run-scoped custom_id formats with branch slug', () => {
29+
expect(
30+
assertBrowserStackCustomId('MetaMask-Android-With-SRP-main', 'with-srp'),
31+
).toBe('MetaMask-Android-With-SRP-main');
2832
expect(
2933
assertBrowserStackCustomId(
3034
'MetaMask-Android-With-SRP-main-123',
@@ -45,14 +49,42 @@ describe('browserstack-app-validation', () => {
4549
).toBe('MetaMask-Android-With-SRP-feature_x-789');
4650
});
4751

48-
it('rejects legacy custom_id formats without branch slug', () => {
52+
it('rejects legacy custom_id formats without a letterful branch slug', () => {
4953
expect(() =>
5054
assertBrowserStackCustomId('MetaMask-Android-With-SRP-123', 'with-srp'),
5155
).toThrow('Invalid with-srp custom_id');
5256
});
5357

58+
it('detects main-branch custom_ids only', () => {
59+
expect(
60+
isMainBranchBrowserStackCustomId(
61+
'MetaMask-Android-With-SRP-main',
62+
'with-srp',
63+
),
64+
).toBe(true);
65+
expect(
66+
isMainBranchBrowserStackCustomId(
67+
'MetaMask-Android-With-SRP-main-999',
68+
'with-srp',
69+
),
70+
).toBe(true);
71+
expect(
72+
isMainBranchBrowserStackCustomId(
73+
'MetaMask-Android-With-SRP-feature_x',
74+
'with-srp',
75+
),
76+
).toBe(false);
77+
expect(
78+
isMainBranchBrowserStackCustomId(
79+
'MetaMask-Android-With-SRP-maintenance-1',
80+
'with-srp',
81+
),
82+
).toBe(false);
83+
});
84+
5485
it('writes only validated single-line GitHub outputs', () => {
55-
const outputPath = path.join(os.tmpdir(), `gh-output-${Date.now()}.txt`);
86+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-output-'));
87+
const outputPath = path.join(tempDir, 'gh-output-test.txt');
5688
writeGithubOutputs(outputPath, {
5789
found: 'true',
5890
'with-srp-browserstack-url': 'bs://abc123',
@@ -61,6 +93,6 @@ describe('browserstack-app-validation', () => {
6193
expect(fs.readFileSync(outputPath, 'utf8')).toBe(
6294
'found=true\nwith-srp-browserstack-url=bs://abc123\n',
6395
);
64-
fs.unlinkSync(outputPath);
96+
fs.rmSync(tempDir, { recursive: true, force: true });
6597
});
6698
});

.github/scripts/resolve-main-browserstack-apps.cjs

Lines changed: 138 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22
/**
33
* Resolves the latest BrowserStack app URLs for performance E2E when a PR only
44
* changes tests and should reuse main-branch builds instead of compiling fresh.
5+
*
6+
* Prefer BrowserStack's recent_apps/{custom_id} endpoint with the stable main
7+
* custom_id. Fall back to scanning recent_apps for stable or legacy main IDs.
8+
* When nothing is found, exits 0 with found=false so CI can fall back to a fresh
9+
* dual Android upload.
510
*/
611

712
const {
8-
MAIN_WITH_SRP_CUSTOM_ID_PREFIX,
9-
MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX,
13+
MAIN_WITH_SRP_CUSTOM_ID,
14+
MAIN_WITHOUT_SRP_CUSTOM_ID,
1015
assertBrowserStackAppUrl,
1116
assertBrowserStackCustomId,
17+
isMainBranchBrowserStackCustomId,
1218
writeGithubOutputs,
1319
} = require('./browserstack-app-validation.cjs');
1420

@@ -26,53 +32,62 @@ if (!username || !accessKey) {
2632
process.exit(1);
2733
}
2834

29-
async function main() {
30-
const auth = Buffer.from(`${username}:${accessKey}`).toString('base64');
35+
/**
36+
* @param {string} auth
37+
* @param {string} path
38+
* @returns {Promise<{ ok: boolean; status: number; payload: unknown }>}
39+
*/
40+
async function browserStackGet(auth, path) {
3141
const response = await fetch(
32-
'https://api-cloud.browserstack.com/app-automate/recent_apps',
42+
`https://api-cloud.browserstack.com/app-automate/${path}`,
3343
{
3444
headers: {
3545
Authorization: `Basic ${auth}`,
3646
},
3747
},
3848
);
39-
40-
if (!response.ok) {
41-
console.error(
42-
`BrowserStack recent_apps request failed: ${response.status} ${response.statusText}`,
43-
);
44-
process.exit(1);
49+
let payload = null;
50+
try {
51+
payload = await response.json();
52+
} catch {
53+
payload = null;
4554
}
55+
return { ok: response.ok, status: response.status, payload };
56+
}
4657

47-
const payload = await response.json();
48-
if (!Array.isArray(payload)) {
49-
console.error('BrowserStack recent_apps response was not an array');
50-
process.exit(1);
58+
/**
59+
* @param {unknown} payload
60+
* @returns {Array<{ custom_id?: string; app_url?: string; uploaded_at?: string }>}
61+
*/
62+
function asAppList(payload) {
63+
if (Array.isArray(payload)) {
64+
return payload;
5165
}
52-
53-
/** @type {Array<{ custom_id?: string; app_url?: string; uploaded_at?: string }>} */
54-
const apps = payload;
55-
56-
function findLatestApp(prefix, kind) {
57-
const matches = apps
58-
.filter(
59-
(app) =>
60-
typeof app.custom_id === 'string' && app.custom_id.startsWith(prefix),
61-
)
62-
.filter(
63-
(app) => typeof app.app_url === 'string' && app.app_url.length > 0,
64-
)
65-
.sort((left, right) => {
66-
const leftTime = Date.parse(left.uploaded_at || '') || 0;
67-
const rightTime = Date.parse(right.uploaded_at || '') || 0;
68-
return rightTime - leftTime;
69-
});
70-
71-
const candidate = matches[0];
72-
if (!candidate) {
73-
return null;
66+
if (payload && typeof payload === 'object') {
67+
const maybeApps = /** @type {{ apps?: unknown }} */ (payload).apps;
68+
if (Array.isArray(maybeApps)) {
69+
return maybeApps;
7470
}
71+
}
72+
return [];
73+
}
7574

75+
/**
76+
* @param {Array<{ custom_id?: string; app_url?: string; uploaded_at?: string }>} apps
77+
* @param {'with-srp' | 'without-srp'} kind
78+
* @returns {{ customId: string; appUrl: string } | null}
79+
*/
80+
function pickLatestValidApp(apps, kind) {
81+
const matches = apps
82+
.filter((app) => isMainBranchBrowserStackCustomId(app.custom_id, kind))
83+
.filter((app) => typeof app.app_url === 'string' && app.app_url.length > 0)
84+
.sort((left, right) => {
85+
const leftTime = Date.parse(left.uploaded_at || '') || 0;
86+
const rightTime = Date.parse(right.uploaded_at || '') || 0;
87+
return rightTime - leftTime;
88+
});
89+
90+
for (const candidate of matches) {
7691
try {
7792
return {
7893
customId: assertBrowserStackCustomId(candidate.custom_id, kind),
@@ -85,24 +100,103 @@ async function main() {
85100
console.error(
86101
`Rejected BrowserStack ${kind} candidate ${candidate.custom_id || 'unknown'}: ${error.message}`,
87102
);
88-
return null;
89103
}
90104
}
91105

92-
const withSrp = findLatestApp(MAIN_WITH_SRP_CUSTOM_ID_PREFIX, 'with-srp');
93-
const withoutSrp = findLatestApp(
94-
MAIN_WITHOUT_SRP_CUSTOM_ID_PREFIX,
106+
return null;
107+
}
108+
109+
/**
110+
* @param {string} auth
111+
* @param {string} customId
112+
* @param {'with-srp' | 'without-srp'} kind
113+
* @returns {Promise<{ customId: string; appUrl: string } | null>}
114+
*/
115+
async function resolveByCustomId(auth, customId, kind) {
116+
const encodedId = encodeURIComponent(customId);
117+
const { ok, status, payload } = await browserStackGet(
118+
auth,
119+
`recent_apps/${encodedId}`,
120+
);
121+
122+
if (!ok) {
123+
console.warn(
124+
`BrowserStack recent_apps/${customId} returned ${status}; will try recent_apps list fallback.`,
125+
);
126+
return null;
127+
}
128+
129+
const picked = pickLatestValidApp(asAppList(payload), kind);
130+
if (picked) {
131+
console.log(`Resolved ${kind} via recent_apps/${customId}: ${picked.customId}`);
132+
}
133+
return picked;
134+
}
135+
136+
/**
137+
* @param {string} auth
138+
* @param {'with-srp' | 'without-srp'} kind
139+
* @returns {Promise<{ customId: string; appUrl: string } | null>}
140+
*/
141+
async function resolveFromRecentAppsList(auth, kind) {
142+
const { ok, status, payload } = await browserStackGet(auth, 'recent_apps');
143+
if (!ok) {
144+
console.warn(
145+
`BrowserStack recent_apps list returned ${status}; cannot fall back for ${kind}.`,
146+
);
147+
return null;
148+
}
149+
150+
const apps = asAppList(payload);
151+
if (!Array.isArray(payload) && apps.length === 0) {
152+
console.warn('BrowserStack recent_apps response was not a usable app list');
153+
return null;
154+
}
155+
156+
const picked = pickLatestValidApp(apps, kind);
157+
if (picked) {
158+
console.log(
159+
`Resolved ${kind} via recent_apps list scan: ${picked.customId}`,
160+
);
161+
}
162+
return picked;
163+
}
164+
165+
async function main() {
166+
const auth = Buffer.from(`${username}:${accessKey}`).toString('base64');
167+
168+
let withSrp = await resolveByCustomId(
169+
auth,
170+
MAIN_WITH_SRP_CUSTOM_ID,
171+
'with-srp',
172+
);
173+
if (!withSrp) {
174+
withSrp = await resolveFromRecentAppsList(auth, 'with-srp');
175+
}
176+
177+
let withoutSrp = await resolveByCustomId(
178+
auth,
179+
MAIN_WITHOUT_SRP_CUSTOM_ID,
95180
'without-srp',
96181
);
182+
if (!withoutSrp) {
183+
withoutSrp = await resolveFromRecentAppsList(auth, 'without-srp');
184+
}
97185

98186
if (!withSrp || !withoutSrp) {
99-
console.error(
187+
console.warn(
100188
'Could not resolve latest BrowserStack Android apps for main-build reuse.',
101189
);
102-
console.error(
190+
console.warn(
103191
`Found with-SRP=${withSrp?.customId || 'none'}, without-SRP=${withoutSrp?.customId || 'none'}`,
104192
);
105-
process.exit(1);
193+
console.warn(
194+
'Emitting found=false so CI can fall back to a fresh Android dual upload.',
195+
);
196+
writeGithubOutputs(githubOutputPath, {
197+
found: 'false',
198+
});
199+
return;
106200
}
107201

108202
console.log(`Reusing BrowserStack with-SRP app: ${withSrp.customId}`);

0 commit comments

Comments
 (0)