Skip to content

Commit 8969552

Browse files
authored
chore: clean e2e stuff (#21266)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Replaces the E2E runner with a new sharding+flakiness script, updates workflows and report script names, adjusts split matrices, and sets Jest to 1 worker. > > - **E2E Scripts**: > - Add `./.github/scripts/e2e-split-tags-shards.mjs` to find tagged specs, split across shards, optionally duplicate changed specs for flakiness, and run per-platform. > - Remove `./.github/scripts/run-e2e-tags-gha.mjs`. > - **Workflows**: > - Switch test execution to `e2e-split-tags-shards.mjs` in `run-e2e-workflow.yml`. > - Rename report generator calls to `./.github/scripts/e2e-create-test-report.mjs` in Android/iOS workflows. > - Update build check step to `./.github/scripts/e2e-check-build-needed.mjs` in `needs-e2e-build.yml`. > - Adjust shard matrices: > - Android/iOS: set some suites to 1 split (e.g., `SmokeTrade`, `SmokeAccounts`); increase `SmokeConfirmationsRedesigned` to 3 splits. > - **Testing Config**: > - In `e2e/jest.e2e.config.js`, simplify and set `maxWorkers` to `1` (run tests in-band). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 47fc666. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4918683 commit 8969552

10 files changed

+405
-471
lines changed
File renamed without changes.
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { spawn } from 'node:child_process';
4+
5+
// 1) Find all specs files that include the given E2E tags
6+
// 2) Compute sharding split (evenly across runners)
7+
// 3) Flaky test detector mechanism in PRs (test retries)
8+
// 4) Log and run the selected specs for the given shard split
9+
10+
const env = {
11+
TEST_SUITE_TAG: process.env.TEST_SUITE_TAG,
12+
BASE_DIR: process.env.BASE_DIR || './e2e/specs',
13+
METAMASK_BUILD_TYPE: process.env.METAMASK_BUILD_TYPE || 'main',
14+
PLATFORM: process.env.PLATFORM || 'ios',
15+
SPLIT_NUMBER: Number(process.env.SPLIT_NUMBER || '1'),
16+
TOTAL_SPLITS: Number(process.env.TOTAL_SPLITS || '1'),
17+
PR_NUMBER: process.env.PR_NUMBER || '',
18+
REPOSITORY: process.env.REPOSITORY || 'MetaMask/metamask-mobile',
19+
GITHUB_TOKEN: process.env.GITHUB_TOKEN || '',
20+
CHANGED_FILES: process.env.CHANGED_FILES || '',
21+
};
22+
// Example of format of CHANGED_FILES: .github/scripts/e2e-check-build-needed.mjs .github/scripts/needs-e2e-builds.mjs
23+
24+
if (!fs.existsSync(env.BASE_DIR)) throw new Error(`❌ Base directory not found: ${env.BASE_DIR}`);
25+
if (!env.TEST_SUITE_TAG) throw new Error('❌ Missing TEST_SUITE_TAG env var');
26+
27+
/**
28+
* Minimal GitHub GraphQL helper
29+
* @param {*} query - The query to run
30+
* @param {*} variables - The variables to pass to the query
31+
* @returns The data from the query
32+
*/
33+
async function githubGraphql(query, variables = {}) {
34+
try {
35+
const res = await fetch('https://api.github.com/graphql', {
36+
method: 'POST',
37+
headers: {
38+
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
39+
Accept: 'application/vnd.github+json',
40+
'X-GitHub-Api-Version': '2022-11-28',
41+
'User-Agent': 'metamask-mobile-ci',
42+
'Content-Type': 'application/json',
43+
},
44+
body: JSON.stringify({ query, variables }),
45+
});
46+
47+
if (!res.ok) {
48+
const errorText = await res.text().catch(() => 'Unable to read response');
49+
throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}\nResponse: ${errorText}`);
50+
}
51+
52+
const data = await res.json();
53+
if (data.errors) {
54+
const msg = Array.isArray(data.errors) ? data.errors.map((e) => e.message).join('; ') : String(data.errors);
55+
throw new Error(`GraphQL errors: ${msg}`);
56+
}
57+
return data.data;
58+
} catch (err) {
59+
// Re-throw with more context
60+
if (err.message) throw err;
61+
throw new Error(`GraphQL request failed: ${String(err)}`);
62+
}
63+
}
64+
65+
/**
66+
* Check if the flakiness detection (tests retries) should be skipped.
67+
* @returns True if the retries should be skipped, false otherwise
68+
*/
69+
async function shouldSkipFlakinessDetection() {
70+
if (!env.PR_NUMBER) {
71+
return true;
72+
}
73+
74+
const OWNER = env.REPOSITORY.split('/')[0];
75+
const REPO = env.REPOSITORY.split('/')[1];
76+
const PR_NUM = Number(env.PR_NUMBER);
77+
78+
try {
79+
const data = await githubGraphql(
80+
`query($owner:String!, $repo:String!, $number:Int!) {
81+
repository(owner: $owner, name: $repo) {
82+
pullRequest(number: $number) {
83+
labels(first: 100) { nodes { name } }
84+
}
85+
}
86+
}`,
87+
{ owner: OWNER, repo: REPO, number: PR_NUM },
88+
);
89+
90+
const labels = data?.repository?.pullRequest?.labels?.nodes || [];
91+
const labelFound = labels.some((l) => String(l?.name).toLowerCase() === 'skip-e2e-quality-gate');
92+
if (labelFound) {
93+
console.log('⏭️ Found "skip-e2e-quality-gate" label → SKIPPING flakiness detection');
94+
}
95+
return labelFound;
96+
} catch (e) {
97+
console.error(`❌ GitHub API call failed:`);
98+
console.error(`Error: ${e?.message || String(e)}`);
99+
return true;
100+
}
101+
}
102+
103+
/**
104+
* Check if a file is a spec file
105+
* @param {*} filePath - The path to the file
106+
* @returns True if the file is a spec file, false otherwise
107+
*/
108+
function isSpecFile(filePath) {
109+
return (filePath.endsWith('.spec.js') || filePath.endsWith('.spec.ts')) &&
110+
!filePath.split(path.sep).includes('quarantine');
111+
}
112+
113+
/**
114+
* Synchronous generator to recursively walk a directory
115+
* @param {*} dir - The directory to walk
116+
* @returns A generator of the files in the directory, sorted alphabetically
117+
*/
118+
function* walk(dir) {
119+
const entries = fs.readdirSync(dir, { withFileTypes: true });
120+
for (const entry of entries) {
121+
const fullPath = path.join(dir, entry.name);
122+
if (entry.isDirectory()) {
123+
yield* walk(fullPath);
124+
} else {
125+
yield fullPath;
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Find all spec files that contain the provided tag string
132+
* @param {*} baseDir - The base directory to search
133+
* @param {*} tag - The tag to search for
134+
* @returns The matching files, sorted alphabetically
135+
*/
136+
function findMatchingFiles(baseDir, tag) {
137+
const resolvedBase = path.resolve(baseDir);
138+
const results = [];
139+
// Escape the tag for safe usage in RegExp
140+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
141+
// Match tag with word-boundary semantics similar to `grep -w -F`
142+
// We require a non-word (or start) before and after the tag to avoid substring matches
143+
const boundaryPattern = new RegExp(
144+
`(^|[^A-Za-z0-9_])${escapeRegExp(tag)}([^A-Za-z0-9_]|$)`,
145+
'm',
146+
);
147+
for (const filePath of walk(resolvedBase)) {
148+
if (!isSpecFile(filePath)) continue;
149+
const content = fs.readFileSync(filePath, 'utf8');
150+
if (boundaryPattern.test(content)) {
151+
// Return repo-relative paths similar to the bash script output
152+
results.push(path.relative(process.cwd(), filePath));
153+
}
154+
}
155+
results.sort((a, b) => a.localeCompare(b));
156+
return Array.from(new Set(results));
157+
}
158+
159+
/**
160+
* Compute the ceiling of a division
161+
* @param {*} a - The dividend
162+
* @param {*} b - The divisor
163+
* @returns The ceiling of the division
164+
*/
165+
function ceilDiv(a, b) {
166+
return Math.floor((a + b - 1) / b);
167+
}
168+
169+
/**
170+
* Split files evenly across runners
171+
* @param {*} files - The files to split
172+
* @param {*} splitNumber - The number of the split (1-based index)
173+
* @param {*} totalSplits - The total number of splits
174+
* @returns The split files for this runner
175+
*/
176+
function computeShardingSplit(files, splitNumber, totalSplits) {
177+
const filesPerSplit = ceilDiv(files.length, totalSplits);
178+
const startIndex = (splitNumber - 1) * filesPerSplit;
179+
const endIndex = Math.min(startIndex + filesPerSplit, files.length);
180+
return files.slice(startIndex, endIndex);
181+
}
182+
183+
/**
184+
* Spawn a yarn script with inherited stdio
185+
* @param {*} scriptName - The name of the script to run
186+
* @param {*} args - The arguments to pass to the script
187+
* @param {*} extraEnv - The extra environment variables to set
188+
* @returns A promise that resolves when the script exits
189+
*/
190+
function runYarn(scriptName, args, extraEnv = {}) {
191+
return new Promise((resolve, reject) => {
192+
const child = spawn('yarn', [scriptName, ...args], {
193+
stdio: 'inherit',
194+
env: { ...process.env, ...extraEnv },
195+
});
196+
child.on('exit', (code) => {
197+
if (code === 0) resolve();
198+
else reject(new Error(`Command failed with exit code ${code}`));
199+
});
200+
child.on('error', (err) => reject(err));
201+
});
202+
}
203+
204+
/**
205+
* Derive the retry filename for a given spec: base -> base-retry-N
206+
* @param {*} originalPath - The original path to the spec file
207+
* @param {*} retryIndex - The retry index
208+
* @returns The retry filename, or null if the original path is not a spec file
209+
*/
210+
function computeRetryFilePath(originalPath, retryIndex) {
211+
// originalPath must end with .spec.ts or .spec.js
212+
const match = originalPath.match(/^(.*)\.spec\.(ts|js)$/);
213+
if (!match) return null;
214+
const base = match[1];
215+
const ext = match[2];
216+
return `${base}-retry-${retryIndex}.spec.${ext}`;
217+
}
218+
219+
/**
220+
* Create two retry copies of a given spec if not already present
221+
* @param {*} originalPath - The original path to the spec file
222+
*/
223+
function duplicateSpecFile(originalPath) {
224+
try {
225+
const srcPath = path.resolve(originalPath);
226+
if (!fs.existsSync(srcPath)) return;
227+
const content = fs.readFileSync(srcPath);
228+
for (let i = 1; i <= 2; i += 1) {
229+
const retryRel = computeRetryFilePath(originalPath, i);
230+
if (!retryRel) continue;
231+
const retryAbs = path.resolve(retryRel);
232+
if (!fs.existsSync(path.dirname(retryAbs))) {
233+
fs.mkdirSync(path.dirname(retryAbs), { recursive: true });
234+
}
235+
if (!fs.existsSync(retryAbs)) {
236+
fs.writeFileSync(retryAbs, content);
237+
console.log(`🧪 Duplicated for flakiness check: ${retryRel}`);
238+
}
239+
}
240+
} catch (e) {
241+
console.warn(`⚠️ Failed duplicating ${originalPath}: ${e?.message || e}`);
242+
}
243+
}
244+
245+
/**
246+
* Normalize a path (repo-relative) for comparisons
247+
* @param {*} p - The path to normalize
248+
* @returns The normalized path, relative to the current working directory
249+
*/
250+
function normalizePathForCompare(p) {
251+
// Ensure relative to CWD, normalized separators
252+
const rel = path.isAbsolute(p) ? path.relative(process.cwd(), p) : p;
253+
return path.normalize(rel);
254+
}
255+
256+
/**
257+
* Parse CHANGED_FILES env var to extract changed spec files
258+
* @returns Set of normalized spec file paths
259+
*/
260+
function getChangedSpecFiles() {
261+
const raw = (env.CHANGED_FILES || '').trim();
262+
if (!raw) return new Set();
263+
264+
// Handle "changed_files=path1 path2" format
265+
let cleaned = raw;
266+
const eqIdx = raw.indexOf('=');
267+
if (eqIdx > -1 && /changed_files/i.test(raw.slice(0, eqIdx))) {
268+
cleaned = raw.slice(eqIdx + 1).trim();
269+
}
270+
271+
const parts = cleaned.split(/\s+/g).map((p) => p.trim()).filter(Boolean);
272+
const specFiles = new Set();
273+
for (const p of parts) {
274+
if (p.endsWith('.spec.ts') || p.endsWith('.spec.js')) {
275+
specFiles.add(path.normalize(p));
276+
}
277+
}
278+
return specFiles;
279+
}
280+
281+
/**
282+
* Apply flakiness detection: duplicate changed spec files assigned to this shard
283+
* @param {string[]} splitFiles - The test files assigned to this shard
284+
* @returns {string[]} Expanded list with base + retry files for changed tests
285+
*/
286+
function applyFlakinessDetection(splitFiles) {
287+
const changedSpecs = getChangedSpecFiles();
288+
if (changedSpecs.size === 0) {
289+
return splitFiles;
290+
}
291+
292+
// Find which changed files are in this shard's split
293+
const selectedSet = new Set(splitFiles.map(normalizePathForCompare));
294+
const duplicatedSet = new Set();
295+
for (const changed of changedSpecs) {
296+
const normalized = normalizePathForCompare(changed);
297+
if (selectedSet.has(normalized)) {
298+
duplicateSpecFile(normalized);
299+
duplicatedSet.add(normalized);
300+
}
301+
}
302+
if (duplicatedSet.size === 0) {
303+
console.log('ℹ️ No changed spec files found for this shard split -> No test retries.');
304+
return splitFiles;
305+
}
306+
307+
// Build expanded list: base + retry-1 + retry-2 for duplicated files
308+
const expanded = [];
309+
for (const file of splitFiles) {
310+
const normalized = normalizePathForCompare(file);
311+
if (duplicatedSet.has(normalized)) {
312+
// Add base file
313+
expanded.push(file);
314+
// Add retry files
315+
const retry1 = computeRetryFilePath(normalized, 1);
316+
const retry2 = computeRetryFilePath(normalized, 2);
317+
if (retry1) expanded.push(retry1);
318+
if (retry2) expanded.push(retry2);
319+
} else {
320+
// Not changed, add as-is
321+
expanded.push(file);
322+
}
323+
}
324+
325+
console.log(`ℹ️ Duplicated ${duplicatedSet.size} changed file(s) for flakiness detection.`);
326+
return expanded;
327+
}
328+
329+
async function main() {
330+
331+
console.log("🚀 Starting E2E tests...");
332+
333+
// 1) Find all specs files that include the given E2E tags
334+
console.log(`Searching for E2E test files with tags: ${env.TEST_SUITE_TAG}`);
335+
let allMatches = findMatchingFiles(env.BASE_DIR, env.TEST_SUITE_TAG); // TODO - review this function (!).
336+
if (allMatches.length === 0) throw new Error(`❌ No test files found containing tags: ${env.TEST_SUITE_TAG}`);
337+
console.log(`Found ${allMatches.length} matching spec files to split across ${env.TOTAL_SPLITS} shards`);
338+
339+
340+
// 2) Compute sharding split (evenly across runners)
341+
const splitFiles = computeShardingSplit(allMatches, env.SPLIT_NUMBER, env.TOTAL_SPLITS);
342+
let runFiles = [...splitFiles];
343+
if (runFiles.length === 0) {
344+
console.log(`⚠️ No test files for split ${env.SPLIT_NUMBER}/${env.TOTAL_SPLITS} (only ${allMatches.length} test files found, but ${env.TOTAL_SPLITS} runners configured).`);
345+
console.log(` 💡 Tip: Reduce shard splits or add more tests to this tag ${env.TEST_SUITE_TAG}`);
346+
process.exit(0);
347+
}
348+
349+
// 3) Flaky test detector mechanism in PRs (test retries)
350+
// - Only duplicates changed files that are in this shard's split
351+
// - Creates base + retry-1 + retry-2 for flakiness detection
352+
const shouldSkipFlakinessGate = await shouldSkipFlakinessDetection();
353+
if (!shouldSkipFlakinessGate) {
354+
runFiles = applyFlakinessDetection(splitFiles);
355+
}
356+
357+
// 4) Log and run the selected specs for the given shard split
358+
console.log(`\n🧪 Running ${runFiles.length} spec files for this given shard split (${env.SPLIT_NUMBER}/${env.TOTAL_SPLITS}):`);
359+
for (const f of runFiles) {
360+
console.log(` - ${f}`);
361+
}
362+
363+
const args = [...runFiles];
364+
try {
365+
if (env.PLATFORM.toLowerCase() === 'ios') {
366+
console.log('\n 🍎 Running iOS tests for build type: ', env.METAMASK_BUILD_TYPE);
367+
await runYarn(`test:e2e:ios:${env.METAMASK_BUILD_TYPE}:ci`, args);
368+
} else {
369+
console.log('\n 🤖 Running Android tests for build type: ', env.METAMASK_BUILD_TYPE);
370+
await runYarn(`test:e2e:android:${env.METAMASK_BUILD_TYPE}:ci`, args);
371+
}
372+
console.log("✅ Test execution completed");
373+
} catch (err) {
374+
console.error(err.message || String(err));
375+
process.exit(1);
376+
}
377+
}
378+
379+
main().catch((error) => {
380+
console.error('\n❌ Unexpected error:', error);
381+
process.exit(1);
382+
});

0 commit comments

Comments
 (0)