|
| 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