|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Fetches E2E test timing data and writes it to a file so it can be uploaded |
| 5 | + * as a run-scoped artifact and reused by every shard (including re-runs). |
| 6 | + * |
| 7 | + * Timing source priority: |
| 8 | + * 1. qa-stats artifact from the merge-base commit of the PR branch and main |
| 9 | + * (most accurate: not affected by test renames/additions on main after branching) |
| 10 | + * 2. qa-stats artifact from the latest successful main run (current fallback) |
| 11 | + * 3. null → callers fall back to alphabetical equal-count split |
| 12 | + * |
| 13 | + * Outputs: |
| 14 | + * - Writes e2e-timings.json to OUTPUT_PATH (default: ./e2e-timings.json) |
| 15 | + * - Sets GITHUB_OUTPUT available=true|false |
| 16 | + */ |
| 17 | + |
| 18 | +import fs from 'node:fs'; |
| 19 | +import os from 'node:os'; |
| 20 | +import path from 'node:path'; |
| 21 | +import { spawnSync } from 'node:child_process'; |
| 22 | + |
| 23 | +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; |
| 24 | +const REPOSITORY = process.env.REPOSITORY || 'MetaMask/metamask-mobile'; |
| 25 | +const PR_NUMBER = process.env.PR_NUMBER || ''; |
| 26 | +const OUTPUT_PATH = process.env.OUTPUT_PATH || './e2e-timings.json'; |
| 27 | + |
| 28 | +const QA_STATS_WORKFLOW_FILE = 'qa-stats.yml'; |
| 29 | +const QA_STATS_ARTIFACT_NAME = 'qa-stats'; |
| 30 | +const QA_STATS_JSON_FILENAME = 'qa-stats.json'; |
| 31 | + |
| 32 | +const [OWNER, REPO] = REPOSITORY.split('/'); |
| 33 | +const API_BASE = `https://api.github.com/repos/${REPOSITORY}`; |
| 34 | + |
| 35 | +/** |
| 36 | + * Minimal GitHub REST helper. |
| 37 | + * @param {string} url |
| 38 | + * @returns {Promise<any>} |
| 39 | + */ |
| 40 | +async function githubRest(url) { |
| 41 | + const res = await fetch(url, { |
| 42 | + headers: { |
| 43 | + Authorization: `Bearer ${GITHUB_TOKEN}`, |
| 44 | + Accept: 'application/vnd.github+json', |
| 45 | + 'X-GitHub-Api-Version': '2022-11-28', |
| 46 | + 'User-Agent': 'metamask-mobile-ci', |
| 47 | + }, |
| 48 | + }); |
| 49 | + if (!res.ok) { |
| 50 | + const body = await res.text().catch(() => ''); |
| 51 | + const suffix = body ? `: ${body.slice(0, 200)}` : ''; |
| 52 | + throw new Error(`GET ${url} → ${res.status} ${res.statusText}${suffix}`); |
| 53 | + } |
| 54 | + return res.json(); |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * Download and extract qa-stats.json from a GitHub Actions run artifact. |
| 59 | + * Returns the e2e_test_times map, or null on any failure. |
| 60 | + * @param {number} runId |
| 61 | + * @returns {Promise<object|null>} |
| 62 | + */ |
| 63 | +async function extractTimingsFromRun(runId) { |
| 64 | + const artifactsData = await githubRest(`${API_BASE}/actions/runs/${runId}/artifacts`); |
| 65 | + const artifact = (artifactsData?.artifacts || []).find( |
| 66 | + (a) => a?.name === QA_STATS_ARTIFACT_NAME && !a?.expired, |
| 67 | + ); |
| 68 | + if (!artifact?.archive_download_url) { |
| 69 | + return null; |
| 70 | + } |
| 71 | + |
| 72 | + // GitHub redirects to a pre-signed storage URL. Follow manually so the |
| 73 | + // Authorization header is not forwarded (storage rejects it). |
| 74 | + const redirectRes = await fetch(artifact.archive_download_url, { |
| 75 | + headers: { |
| 76 | + Authorization: `Bearer ${GITHUB_TOKEN}`, |
| 77 | + Accept: 'application/vnd.github+json', |
| 78 | + 'X-GitHub-Api-Version': '2022-11-28', |
| 79 | + 'User-Agent': 'metamask-mobile-ci', |
| 80 | + }, |
| 81 | + redirect: 'manual', |
| 82 | + }); |
| 83 | + const downloadUrl = redirectRes.headers.get('location'); |
| 84 | + if (!downloadUrl) { |
| 85 | + throw new Error(`no redirect URL for artifact on run #${runId} (status ${redirectRes.status})`); |
| 86 | + } |
| 87 | + |
| 88 | + const zipRes = await fetch(downloadUrl); |
| 89 | + if (!zipRes.ok) { |
| 90 | + throw new Error(`download zip → ${zipRes.status} ${zipRes.statusText}`); |
| 91 | + } |
| 92 | + |
| 93 | + // Reject unexpectedly large responses before writing to disk. |
| 94 | + const MAX_ZIP_BYTES = 50 * 1024 * 1024; // 50 MB |
| 95 | + const contentLength = Number(zipRes.headers.get('content-length') ?? 0); |
| 96 | + if (contentLength > MAX_ZIP_BYTES) { |
| 97 | + throw new Error(`artifact zip too large (${contentLength} bytes) — aborting`); |
| 98 | + } |
| 99 | + |
| 100 | + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `qa-stats-${runId}-`)); |
| 101 | + const zipPath = path.join(tmpRoot, 'qa-stats.zip'); |
| 102 | + fs.writeFileSync(zipPath, Buffer.from(await zipRes.arrayBuffer())); |
| 103 | + |
| 104 | + const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', tmpRoot], { stdio: 'pipe' }); |
| 105 | + if (unzipResult.status !== 0) { |
| 106 | + throw new Error(`unzip failed (code ${unzipResult.status}): ${unzipResult.stderr?.toString() || ''}`); |
| 107 | + } |
| 108 | + |
| 109 | + const jsonPath = path.join(tmpRoot, QA_STATS_JSON_FILENAME); |
| 110 | + if (!fs.existsSync(jsonPath)) return null; |
| 111 | + |
| 112 | + const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); |
| 113 | + const times = parsed?.e2e_test_times; |
| 114 | + if (!times || typeof times !== 'object' || Object.keys(times).length === 0) return null; |
| 115 | + |
| 116 | + return times; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Resolve the merge-base SHA for the current PR branch against main using |
| 121 | + * the GitHub compare API. Returns null when unavailable. |
| 122 | + * @returns {Promise<string|null>} |
| 123 | + */ |
| 124 | +async function getMergeBaseSha() { |
| 125 | + if (!PR_NUMBER) return null; |
| 126 | + try { |
| 127 | + const pr = await githubRest(`${API_BASE}/pulls/${PR_NUMBER}`); |
| 128 | + const headSha = pr?.head?.sha; |
| 129 | + if (!headSha) return null; |
| 130 | + |
| 131 | + const compare = await githubRest( |
| 132 | + `${API_BASE}/compare/main...${headSha}`, |
| 133 | + ); |
| 134 | + return compare?.merge_base_commit?.sha || null; |
| 135 | + } catch (e) { |
| 136 | + console.log(`ℹ️ Could not resolve merge-base SHA: ${e?.message || e}`); |
| 137 | + return null; |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +/** |
| 142 | + * Fetch timings from the qa-stats artifact produced for a specific commit SHA. |
| 143 | + * Returns null if no matching run or artifact is found. |
| 144 | + * @param {string} sha |
| 145 | + * @returns {Promise<object|null>} |
| 146 | + */ |
| 147 | +async function fetchTimingsForSha(sha) { |
| 148 | + const runsData = await githubRest( |
| 149 | + `${API_BASE}/actions/workflows/${QA_STATS_WORKFLOW_FILE}/runs?head_sha=${sha}&status=success&per_page=1`, |
| 150 | + ); |
| 151 | + const run = runsData?.workflow_runs?.[0]; |
| 152 | + if (!run?.id) return null; |
| 153 | + |
| 154 | + console.log(`📥 Fetching qa-stats artifact from merge-base run #${run.id} (sha: ${sha.slice(0, 8)})`); |
| 155 | + return extractTimingsFromRun(run.id); |
| 156 | +} |
| 157 | + |
| 158 | +/** |
| 159 | + * Fetch timings from the latest successful qa-stats run on main. |
| 160 | + * @returns {Promise<object|null>} |
| 161 | + */ |
| 162 | +async function fetchLatestMainTimings() { |
| 163 | + const runsData = await githubRest( |
| 164 | + `${API_BASE}/actions/workflows/${QA_STATS_WORKFLOW_FILE}/runs?branch=main&status=success&per_page=1`, |
| 165 | + ); |
| 166 | + const run = runsData?.workflow_runs?.[0]; |
| 167 | + if (!run?.id) return null; |
| 168 | + |
| 169 | + console.log(`📥 Fetching qa-stats artifact from latest main run #${run.id}`); |
| 170 | + return extractTimingsFromRun(run.id); |
| 171 | +} |
| 172 | + |
| 173 | +/** |
| 174 | + * Write a value to GITHUB_OUTPUT if available. |
| 175 | + * @param {string} name |
| 176 | + * @param {string} value |
| 177 | + */ |
| 178 | +function setOutput(name, value) { |
| 179 | + const outputFile = process.env.GITHUB_OUTPUT; |
| 180 | + if (outputFile) { |
| 181 | + fs.appendFileSync(outputFile, `${name}=${value}\n`); |
| 182 | + } |
| 183 | +} |
| 184 | + |
| 185 | +async function main() { |
| 186 | + if (!GITHUB_TOKEN) { |
| 187 | + console.log('ℹ️ No GITHUB_TOKEN — skipping timings fetch'); |
| 188 | + setOutput('available', 'false'); |
| 189 | + return; |
| 190 | + } |
| 191 | + |
| 192 | + let times = null; |
| 193 | + |
| 194 | + // 1. Try merge-base timings (most stable and accurate for PRs) |
| 195 | + try { |
| 196 | + const mergeBaseSha = await getMergeBaseSha(); |
| 197 | + if (mergeBaseSha) { |
| 198 | + console.log(`🔍 Resolved merge-base SHA: ${mergeBaseSha.slice(0, 8)}`); |
| 199 | + times = await fetchTimingsForSha(mergeBaseSha); |
| 200 | + if (times) { |
| 201 | + console.log(`✅ Using merge-base timings (${Object.keys(times).length} entries)`); |
| 202 | + } else { |
| 203 | + console.log('ℹ️ No qa-stats artifact found for merge-base — trying latest main'); |
| 204 | + } |
| 205 | + } else { |
| 206 | + console.log('ℹ️ No merge-base SHA (non-PR run) — trying latest main'); |
| 207 | + } |
| 208 | + } catch (e) { |
| 209 | + console.log(`ℹ️ Merge-base timings fetch failed (${e?.message || e}) — trying latest main`); |
| 210 | + } |
| 211 | + |
| 212 | + // 2. Fall back to latest successful main run |
| 213 | + if (!times) { |
| 214 | + try { |
| 215 | + times = await fetchLatestMainTimings(); |
| 216 | + if (times) { |
| 217 | + console.log(`✅ Using latest-main timings (${Object.keys(times).length} entries)`); |
| 218 | + } else { |
| 219 | + console.log('ℹ️ No qa-stats artifact on latest main run'); |
| 220 | + } |
| 221 | + } catch (e) { |
| 222 | + console.log(`ℹ️ Latest-main timings fetch failed: ${e?.message || e}`); |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + if (!times) { |
| 227 | + console.log('ℹ️ No timings available — shards will use alphabetical equal-count split'); |
| 228 | + setOutput('available', 'false'); |
| 229 | + return; |
| 230 | + } |
| 231 | + |
| 232 | + const outputDir = path.dirname(path.resolve(OUTPUT_PATH)); |
| 233 | + fs.mkdirSync(outputDir, { recursive: true }); |
| 234 | + fs.writeFileSync(OUTPUT_PATH, JSON.stringify({ e2e_test_times: times }, null, 2)); |
| 235 | + console.log(`💾 Wrote timings to ${OUTPUT_PATH}`); |
| 236 | + setOutput('available', 'true'); |
| 237 | +} |
| 238 | + |
| 239 | +main().catch((err) => { |
| 240 | + console.error('❌ Unexpected error:', err); |
| 241 | + setOutput('available', 'false'); |
| 242 | + // Exit 0 — timings are best-effort; failure here must never block E2E jobs. |
| 243 | + process.exit(0); |
| 244 | +}); |
0 commit comments