Skip to content

Commit 9591b5f

Browse files
committed
Merge remote-tracking branch 'origin/main' into swaps-4038-trending-tokens
# Conflicts: # app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap # app/components/UI/Bridge/Views/BridgeView/index.tsx
2 parents b837b83 + 86fddcb commit 9591b5f

705 files changed

Lines changed: 23811 additions & 20600 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
/**
3+
*
4+
* Downloads pre-aggregated QA stats artifacts from the triggering CI run via the
5+
* GitHub API and writes a qa-stats.json file for consumption by downstream workflows.
6+
*
7+
* Required env vars:
8+
* GITHUB_TOKEN — GitHub Actions token for API access
9+
* WORKFLOW_RUN_ID — ID of the CI run that produced the artifacts
10+
*
11+
* Example of output format of qa-stats.json:
12+
* {
13+
* "component_view_tests_count": 34,
14+
* "unit_test_count": 679,
15+
* }
16+
*
17+
* How to add a new metric:
18+
* 1. Add a collector function below (see existing example)
19+
* 2. Call it in main() and assign the result to stats
20+
*/
21+
22+
import { readFile, writeFile, mkdir } from 'fs/promises';
23+
import { execSync } from 'child_process';
24+
import { join } from 'path';
25+
26+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
27+
const WORKFLOW_RUN_ID = process.env.WORKFLOW_RUN_ID;
28+
29+
if (!WORKFLOW_RUN_ID) throw new Error('Missing required WORKFLOW_RUN_ID env var');
30+
if (!GITHUB_TOKEN) throw new Error('Missing required GITHUB_TOKEN env var');
31+
32+
33+
// ---------------------------------------------------------------------------
34+
// GitHub artifact helpers
35+
// ---------------------------------------------------------------------------
36+
37+
let _artifactList = null;
38+
39+
/**
40+
* Fetches (and caches) the list of artifacts for the triggering CI run.
41+
* First call fetches and stores, every subsequent call returns the cached value.
42+
*
43+
* @returns {Promise<Array>}
44+
*/
45+
async function getArtifactList() {
46+
if (_artifactList) return _artifactList;
47+
48+
const artifacts = [];
49+
let page = 1;
50+
51+
while (true) {
52+
const url = `https://api.github.com/repos/MetaMask/metamask-mobile/actions/runs/${WORKFLOW_RUN_ID}/artifacts?per_page=100&page=${page}`;
53+
const res = await fetch(url, {
54+
headers: {
55+
Authorization: `Bearer ${GITHUB_TOKEN}`,
56+
Accept: 'application/vnd.github+json',
57+
},
58+
});
59+
60+
if (!res.ok) {
61+
throw new Error(`Failed to list artifacts (page ${page}): ${res.status} ${res.statusText}`);
62+
}
63+
64+
const data = await res.json();
65+
artifacts.push(...data.artifacts);
66+
67+
if (data.artifacts.length < 100) break;
68+
page++;
69+
}
70+
71+
_artifactList = artifacts;
72+
return _artifactList;
73+
}
74+
75+
/**
76+
* Downloads a named artifact from the triggering CI run, extracts it into a
77+
* local directory named after the artifact, and returns that directory path.
78+
*
79+
* @param {string} artifactName
80+
* @returns {Promise<string>} Path to the directory containing the extracted files
81+
*/
82+
async function downloadArtifact(artifactName) {
83+
const artifacts = await getArtifactList();
84+
const artifact = artifacts.find((a) => a.name === artifactName);
85+
86+
if (!artifact) {
87+
throw new Error(
88+
`Artifact "${artifactName}" not found in run ${WORKFLOW_RUN_ID}`,
89+
);
90+
}
91+
92+
// GitHub redirects to a pre-signed S3 URL. Follow manually so the
93+
// Authorization header is not forwarded to S3.
94+
const redirectRes = await fetch(artifact.archive_download_url, {
95+
headers: {
96+
Authorization: `Bearer ${GITHUB_TOKEN}`,
97+
Accept: 'application/vnd.github+json',
98+
},
99+
redirect: 'manual',
100+
});
101+
102+
const downloadUrl = redirectRes.headers.get('location');
103+
if (!downloadUrl) {
104+
throw new Error(`No redirect URL returned for artifact "${artifactName}"`);
105+
}
106+
107+
const zipRes = await fetch(downloadUrl);
108+
if (!zipRes.ok) {
109+
throw new Error(
110+
`Failed to download artifact "${artifactName}": ${zipRes.status} ${zipRes.statusText}`,
111+
);
112+
}
113+
114+
const destDir = `./${artifactName}`;
115+
await mkdir(destDir, { recursive: true });
116+
const zipPath = join(destDir, `${artifactName}.zip`);
117+
await writeFile(zipPath, Buffer.from(await zipRes.arrayBuffer()));
118+
execSync(`unzip -q "${zipPath}" -d "${destDir}"`);
119+
120+
return destDir;
121+
}
122+
123+
// ---------------------------------------------------------------------------
124+
// Collectors — one async function per metric source
125+
// ---------------------------------------------------------------------------
126+
127+
async function collectComponentViewTestCount() {
128+
const destDir = await downloadArtifact('cv-test-stats');
129+
const raw = await readFile(join(destDir, 'cv-test-stats.json'), 'utf8');
130+
const data = JSON.parse(raw);
131+
return data.component_view_test_number;
132+
}
133+
134+
async function collectUnitTestCount() {
135+
const destDir = await downloadArtifact('unit-test-stats');
136+
const raw = await readFile(join(destDir, 'unit-test-stats.json'), 'utf8');
137+
const data = JSON.parse(raw);
138+
return data.unit_test_number;
139+
}
140+
141+
// ---------------------------------------------------------------------------
142+
// Main
143+
// ---------------------------------------------------------------------------
144+
145+
async function main() {
146+
const stats = {};
147+
148+
const collectors = [
149+
{
150+
key: 'component_view_tests_count',
151+
collect: collectComponentViewTestCount,
152+
},
153+
{
154+
key: 'unit_tests_count',
155+
collect: collectUnitTestCount,
156+
},
157+
];
158+
159+
for (const { key, collect } of collectors) {
160+
try {
161+
stats[key] = await collect();
162+
} catch (err) {
163+
// stat will not be present in the output file if the collector fails
164+
console.error(`[${key}] collector failed, skipping stat:`, err.message);
165+
}
166+
}
167+
168+
const outputPath = './qa-stats.json';
169+
await writeFile(outputPath, JSON.stringify(stats, null, 2), 'utf8');
170+
console.log(`✅ QA stats written to ${outputPath}:`, stats);
171+
}
172+
173+
main().catch((err) => {
174+
console.error('\n❌ Unexpected error:', err);
175+
process.exit(1);
176+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Reports E2E fixture validation results as a PR comment and GitHub annotation.
3+
*
4+
* Reads fixture-validation-result.json from the downloaded artifact directory,
5+
* posts/updates a PR comment with the results, and emits GitHub annotations.
6+
*
7+
* Environment variables:
8+
* RESULTS_PATH - Path to the downloaded artifact directory
9+
* VALIDATION_RESULT - The upstream job result (success/failure/cancelled)
10+
* GITHUB_TOKEN - GitHub token for API calls
11+
* PR_NUMBER - Pull request number (empty for non-PR events)
12+
* GITHUB_REPOSITORY - owner/repo
13+
* RUN_URL - URL to the workflow run
14+
*/
15+
16+
import fs from 'node:fs';
17+
import path from 'node:path';
18+
19+
const {
20+
RESULTS_PATH = '',
21+
VALIDATION_RESULT = '',
22+
GITHUB_TOKEN = '',
23+
PR_NUMBER = '',
24+
GITHUB_REPOSITORY = '',
25+
RUN_URL = '',
26+
} = process.env;
27+
28+
const COMMENT_MARKER = '**E2E Fixture Validation';
29+
30+
function readResults() {
31+
const jsonPath = path.join(RESULTS_PATH, 'fixture-validation-result.json');
32+
if (!fs.existsSync(jsonPath)) return null;
33+
return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
34+
}
35+
36+
function buildComment(results) {
37+
if (VALIDATION_RESULT === 'failure' && !results) {
38+
return `❌ ${COMMENT_MARKER} — Failed**\nThe fixture validation job failed. [Review the logs](${RUN_URL})`;
39+
}
40+
41+
if (!results) {
42+
if (VALIDATION_RESULT === 'success') {
43+
return `✅ ${COMMENT_MARKER} — Passed**\n[View details](${RUN_URL})`;
44+
}
45+
return null;
46+
}
47+
48+
const { hasStructuralChanges, newKeys, missingKeys, typeMismatches, valueMismatches } = results;
49+
50+
if (hasStructuralChanges) {
51+
return [
52+
`⚠️ ${COMMENT_MARKER} — Structural changes detected**`,
53+
'',
54+
'| Category | Count |',
55+
'|----------|-------|',
56+
`| New keys | ${newKeys} |`,
57+
`| Missing keys | ${missingKeys} |`,
58+
`| Type mismatches | ${typeMismatches} |`,
59+
`| Value mismatches | ${valueMismatches} (informational) |`,
60+
'',
61+
'The committed fixture schema is out of date. To update, comment:',
62+
'```',
63+
'@metamaskbot update-mobile-fixture',
64+
'```',
65+
`[View full details](${RUN_URL}) | [Download diff report](${RUN_URL}#artifacts)`,
66+
].join('\n');
67+
}
68+
69+
if (valueMismatches > 0) {
70+
return `✅ ${COMMENT_MARKER} — Schema is up to date**\n${valueMismatches} value mismatches detected (expected — fixture represents an existing user).\n[View details](${RUN_URL})`;
71+
}
72+
73+
return `✅ ${COMMENT_MARKER} — No differences found**\nFixture is up to date. [View details](${RUN_URL})`;
74+
}
75+
76+
function emitAnnotation(results) {
77+
if (!results) {
78+
if (VALIDATION_RESULT === 'failure') {
79+
console.log('::error::Fixture validation job failed.');
80+
} else if (VALIDATION_RESULT === 'success') {
81+
console.log('::notice::Fixture validation passed.');
82+
}
83+
return;
84+
}
85+
86+
const { hasStructuralChanges, newKeys, missingKeys, typeMismatches, valueMismatches } = results;
87+
88+
if (hasStructuralChanges) {
89+
console.log(`::warning::Fixture schema out of date — New: ${newKeys}, Missing: ${missingKeys}, Type mismatches: ${typeMismatches}. Run @metamaskbot update-mobile-fixture`);
90+
} else if (valueMismatches > 0) {
91+
console.log(`::notice::Fixture schema up to date. ${valueMismatches} value mismatches (expected).`);
92+
} else {
93+
console.log('::notice::Fixture validation passed — no differences found.');
94+
}
95+
}
96+
97+
async function ghApi(endpoint, options = {}) {
98+
const { headers: extraHeaders, ...restOptions } = options;
99+
const res = await fetch(`https://api.github.com${endpoint}`, {
100+
headers: {
101+
Authorization: `Bearer ${GITHUB_TOKEN}`,
102+
Accept: 'application/vnd.github+json',
103+
'X-GitHub-Api-Version': '2022-11-28',
104+
'Content-Type': 'application/json',
105+
...extraHeaders,
106+
},
107+
...restOptions,
108+
});
109+
if (!res.ok && restOptions.method !== 'DELETE') {
110+
const text = await res.text().catch(() => '');
111+
throw new Error(`GitHub API ${res.status}: ${text}`);
112+
}
113+
if (res.status === 204) return null;
114+
if (!res.ok) return null; // DELETE with error — skip silently
115+
return res.json();
116+
}
117+
118+
async function deletePreviousComments() {
119+
let page = 1;
120+
// eslint-disable-next-line no-constant-condition
121+
while (true) {
122+
const comments = await ghApi(
123+
`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100&page=${page}`,
124+
);
125+
if (!comments || comments.length === 0) break;
126+
for (const comment of comments) {
127+
if (comment.body && comment.body.includes(COMMENT_MARKER)) {
128+
await ghApi(`/repos/${GITHUB_REPOSITORY}/issues/comments/${comment.id}`, { method: 'DELETE' });
129+
}
130+
}
131+
if (comments.length < 100) break;
132+
page++;
133+
}
134+
}
135+
136+
async function postComment(body) {
137+
await ghApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments`, {
138+
method: 'POST',
139+
body: JSON.stringify({ body }),
140+
});
141+
}
142+
143+
async function main() {
144+
const results = readResults();
145+
146+
// Always emit annotation
147+
emitAnnotation(results);
148+
149+
// Post PR comment if this is a PR
150+
if (PR_NUMBER) {
151+
const comment = buildComment(results);
152+
if (comment) {
153+
await deletePreviousComments();
154+
await postComment(comment);
155+
console.log(`Posted fixture validation comment on PR #${PR_NUMBER}`);
156+
}
157+
}
158+
}
159+
160+
main().catch((err) => {
161+
console.error('Failed to report fixture validation:', err);
162+
process.exit(1);
163+
});

.github/workflows/build-android-e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
5454
- name: Setup Android Build Environment
5555
timeout-minutes: 15
56-
uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1
56+
uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1.7.0
5757
with:
5858
platform: android
5959
setup-simulator: false

.github/workflows/build-ios-e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ jobs:
100100
# Install Node.js, Xcode tools, and other iOS development dependencies
101101
- name: Installing iOS Environment Setup
102102
timeout-minutes: 15
103-
uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1
103+
uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1.7.0
104104
with:
105105
platform: ios
106106
setup-simulator: false

0 commit comments

Comments
 (0)