diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index eae6b21d6d2..489f59c9b90 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -11,6 +11,7 @@ There are also plenty of open issues we'd love help with. Search the [`good firs If you're picking up a bounty or an existing issue, feel free to ask clarifying questions on the issue as you go about your work. ### Submitting a pull request + When you're done with your project / bugfix / feature and ready to submit a PR, there are a couple guidelines we ask you to follow: - [ ] **Make sure you followed our [`coding guidelines`](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md)**: These guidelines aim to maintain consistency and readability across the codebase. They help ensure that the code is easy to understand, maintain, and modify, which is particularly important when working with multiple contributors. diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index f4b43e0bd49..77dec0bea84 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -400,6 +400,46 @@ jobs: path: tests/reports/ retention-days: 7 + - name: Archive E2E diagnostics + id: archive-e2e-diagnostics + if: failure() + shell: bash + run: | + diagnostics_dir="tests/artifacts/e2e-diagnostics" + diagnostics_zip="tests/artifacts/e2e-diagnostics.zip" + + if [ ! -d "$diagnostics_dir" ]; then + echo "No E2E diagnostics directory found." + echo "has-diagnostics=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if command -v zip >/dev/null 2>&1; then + zip -r "$diagnostics_zip" "$diagnostics_dir" + else + tar -czf "${diagnostics_zip%.zip}.tar.gz" "$diagnostics_dir" + diagnostics_zip="${diagnostics_zip%.zip}.tar.gz" + fi + + echo "Archived E2E diagnostics to $diagnostics_zip" + echo "has-diagnostics=true" >> "$GITHUB_OUTPUT" + echo "diagnostics-archive=$diagnostics_zip" >> "$GITHUB_OUTPUT" + + - name: Upload E2E diagnostics (Namespace) + if: ${{ failure() && inputs.runner_provider == 'namespace' && steps.archive-e2e-diagnostics.outputs.has-diagnostics == 'true' }} + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: test-e2e-${{ inputs.artifact_name_prefix }}${{ inputs.test-suite-name }}-diagnostics + path: ${{ steps.archive-e2e-diagnostics.outputs.diagnostics-archive }} + retention-days: 7 + - name: Upload E2E diagnostics (current) + if: ${{ failure() && inputs.runner_provider != 'namespace' && steps.archive-e2e-diagnostics.outputs.has-diagnostics == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: test-e2e-${{ inputs.artifact_name_prefix }}${{ inputs.test-suite-name }}-diagnostics + path: ${{ steps.archive-e2e-diagnostics.outputs.diagnostics-archive }} + retention-days: 7 + - name: Upload Screenshots (Namespace) if: ${{ failure() && inputs.runner_provider == 'namespace' }} uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 diff --git a/app/core/Engine/controllers/remote-feature-flag-controller-init.ts b/app/core/Engine/controllers/remote-feature-flag-controller-init.ts index 2ab54cceae6..4ab5261b8f5 100644 --- a/app/core/Engine/controllers/remote-feature-flag-controller-init.ts +++ b/app/core/Engine/controllers/remote-feature-flag-controller-init.ts @@ -14,6 +14,33 @@ import { isRemoteFeatureFlagOverrideActivated, } from './remote-feature-flag-controller'; import { getBaseSemVerVersion } from '../../../util/version'; +import { + getE2ERemoteFeatureFlagDiagnostics, + isE2E, + postE2EDiagnostics, +} from '../../../util/test/utils'; + +const getDiagnosticErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const postRemoteFeatureFlagDiagnostics = ( + controller: RemoteFeatureFlagController, + phase: string, + error?: unknown, +) => { + if (!isE2E) { + return; + } + + postE2EDiagnostics( + getE2ERemoteFeatureFlagDiagnostics(controller.state, { + phase, + ...(error === undefined + ? {} + : { error: getDiagnosticErrorMessage(error) }), + }), + ); +}; /** * Initialize the remote feature flag controller. @@ -52,15 +79,21 @@ export const remoteFeatureFlagControllerInit: MessengerClientInitFunction< if (disabled) { Logger.log('Feature flag controller disabled.'); + postRemoteFeatureFlagDiagnostics(controller, 'disabled'); } else if (isRemoteFeatureFlagOverrideActivated) { Logger.log('Remote feature flags override activated.'); + postRemoteFeatureFlagDiagnostics(controller, 'override-activated'); } else { controller .updateRemoteFeatureFlags() .then(() => { Logger.log('Feature flags updated'); + postRemoteFeatureFlagDiagnostics(controller, 'update-success'); }) - .catch((error) => Logger.log('Feature flags update failed: ', error)); + .catch((error) => { + Logger.log('Feature flags update failed: ', error); + postRemoteFeatureFlagDiagnostics(controller, 'update-failed', error); + }); } return { diff --git a/app/util/test/network-store.js b/app/util/test/network-store.js index 1d2b7e29a7c..6271a5baa1c 100644 --- a/app/util/test/network-store.js +++ b/app/util/test/network-store.js @@ -1,6 +1,10 @@ import axios from 'axios'; import { Platform } from 'react-native'; -import { getFixturesServerPortInApp } from './utils'; +import { + appendE2EDiagnosticsToUrl, + getE2ETestConfigDiagnostics, + getFixturesServerPortInApp, +} from './utils'; const FETCH_TIMEOUT = 40000; // Timeout in milliseconds @@ -90,15 +94,21 @@ class ReadOnlyNetworkStore { // 1. localhost with actual port (works on iOS, might work on Android with adb reverse) // 2. 10.0.2.2 with actual port (Android emulator host, direct access without adb reverse!) // 3. bs-local.com (BrowserStack) - const urls = [`http://localhost:${port}/state.json`]; + const diagnostics = getE2ETestConfigDiagnostics({ + source: 'network-store', + platform: Platform.OS, + }); + const appendDiagnostics = (url) => + appendE2EDiagnosticsToUrl(url, diagnostics); + const urls = [appendDiagnostics(`http://localhost:${port}/state.json`)]; // Android emulator can access host via 10.0.2.2 without adb reverse if (isAndroid) { - urls.push(`http://10.0.2.2:${port}/state.json`); + urls.push(appendDiagnostics(`http://10.0.2.2:${port}/state.json`)); } // BrowserStack uses bs-local.com - urls.push(`http://bs-local.com:${port}/state.json`); + urls.push(appendDiagnostics(`http://bs-local.com:${port}/state.json`)); try { for (const url of urls) { diff --git a/app/util/test/utils.js b/app/util/test/utils.js index fc552f6b511..e1f917f036c 100644 --- a/app/util/test/utils.js +++ b/app/util/test/utils.js @@ -1,3 +1,5 @@ +/* global globalThis */ + export const flushPromises = () => new Promise(setImmediate); // Fallback ports - used in fixture data and when LaunchArgs are unavailable @@ -5,9 +7,16 @@ export const flushPromises = () => new Promise(setImmediate); // iOS: These are overridden by LaunchArgs at runtime export const FALLBACK_FIXTURE_SERVER_PORT = 12345; export const FALLBACK_COMMAND_QUEUE_SERVER_PORT = 2446; +export const E2E_TEST_CONFIG_GLOBAL_KEY = '__METAMASK_E2E_TEST_CONFIG__'; +export const E2E_DIAGNOSTICS_ENDPOINT = '/e2e-diagnostics'; -// E2E test configuration required in app -export const testConfig = {}; +// E2E test configuration required in app. Metro/RN can evaluate this module +// through more than one path during startup, so keep the backing object on +// globalThis and make every module instance share the same runtime config. +if (!globalThis[E2E_TEST_CONFIG_GLOBAL_KEY]) { + globalThis[E2E_TEST_CONFIG_GLOBAL_KEY] = {}; +} +export const testConfig = globalThis[E2E_TEST_CONFIG_GLOBAL_KEY]; // SEGMENT TRACK URL for E2E tests - this is not a real URL and is used for testing purposes only export const E2E_METAMETRICS_TRACK_URL = 'https://metametrics.test/track'; @@ -43,4 +52,102 @@ export const getFixturesServerPortInApp = () => export const getCommandQueueServerPortInApp = () => testConfig.commandQueueServerPort ?? FALLBACK_COMMAND_QUEUE_SERVER_PORT; +const formatE2EDiagnosticValue = (value) => { + if (value === undefined) { + return 'missing'; + } + if (value === null) { + return 'null'; + } + return String(value); +}; + +export const getE2ETestConfigDiagnostics = (extra = {}) => ({ + source: extra.source ?? 'unknown', + phase: extra.phase ?? 'unknown', + platform: extra.platform ?? 'unknown', + fixtureServerPortInApp: getFixturesServerPortInApp(), + commandQueueServerPortInApp: getCommandQueueServerPortInApp(), + fixtureServerPortRaw: formatE2EDiagnosticValue( + testConfig.rawFixtureServerPort, + ), + commandQueueServerPortRaw: formatE2EDiagnosticValue( + testConfig.rawCommandQueueServerPort, + ), + mockServerPortRaw: formatE2EDiagnosticValue(testConfig.rawMockServerPort), + hasGlobalE2EConfig: + globalThis[E2E_TEST_CONFIG_GLOBAL_KEY] === testConfig ? 'true' : 'false', + configKeys: Object.keys(testConfig).sort().join(',') || 'none', + launchArgumentKeys: Array.isArray(testConfig.launchArgumentKeys) + ? testConfig.launchArgumentKeys.join(',') + : 'missing', + ...extra, +}); + +const getObjectValue = (value, key) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return value[key]; +}; + +const getObjectKeysDiagnostic = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return 'missing'; + } + + const keys = Object.keys(value).sort(); + return keys.length > 0 ? keys.join(',') : 'none'; +}; + +export const getE2ERemoteFeatureFlagDiagnostics = (state = {}, extra = {}) => { + const remoteFeatureFlags = getObjectValue(state, 'remoteFeatureFlags'); + const rawRemoteFeatureFlags = getObjectValue(state, 'rawRemoteFeatureFlags'); + const localOverrides = getObjectValue(state, 'localOverrides'); + + return { + source: extra.source ?? 'RemoteFeatureFlagController', + phase: extra.phase ?? 'unknown', + explorePageV2EnabledInApp: formatE2EDiagnosticValue( + getObjectValue(remoteFeatureFlags, 'explorePageV2Enabled'), + ), + explorePageV2EnabledRaw: formatE2EDiagnosticValue( + getObjectValue(rawRemoteFeatureFlags, 'explorePageV2Enabled'), + ), + explorePageV2EnabledOverride: formatE2EDiagnosticValue( + getObjectValue(localOverrides, 'explorePageV2Enabled'), + ), + remoteFeatureFlagsCacheTimestamp: formatE2EDiagnosticValue( + getObjectValue(state, 'cacheTimestamp'), + ), + remoteFeatureFlagsKeys: getObjectKeysDiagnostic(remoteFeatureFlags), + rawRemoteFeatureFlagsKeys: getObjectKeysDiagnostic(rawRemoteFeatureFlags), + localOverridesKeys: getObjectKeysDiagnostic(localOverrides), + ...extra, + }; +}; + +export const postE2EDiagnostics = async (diagnostics) => { + const postDiagnostics = testConfig.postE2EDiagnostics; + + if (typeof postDiagnostics !== 'function') { + return; + } + + try { + await postDiagnostics(diagnostics); + } catch { + // Diagnostics must never affect app startup or test behavior. + } +}; + +export const appendE2EDiagnosticsToUrl = (url, diagnostics) => { + const nextUrl = new URL(url); + Object.entries(diagnostics).forEach(([key, value]) => { + nextUrl.searchParams.set(`e2e_${key}`, formatE2EDiagnosticValue(value)); + }); + return nextUrl.toString(); +}; + export const isRc = process.env.METAMASK_ENVIRONMENT === 'rc'; diff --git a/shim.js b/shim.js index f946306f4d6..eb0f13daecd 100644 --- a/shim.js +++ b/shim.js @@ -33,8 +33,10 @@ import { } from 'react-native-quick-crypto'; import { LaunchArguments } from 'react-native-launch-arguments'; import { + E2E_DIAGNOSTICS_ENDPOINT, FALLBACK_FIXTURE_SERVER_PORT, FALLBACK_COMMAND_QUEUE_SERVER_PORT, + getE2ETestConfigDiagnostics, isE2E, isTest, enableApiCallLogs, @@ -45,6 +47,16 @@ import { defaultMockPort } from './tests/api-mocking/mock-config/mockUrlCollecti import './shimPerf'; +const getLaunchArgumentKeys = (raw) => + raw && typeof raw === 'object' ? Object.keys(raw).sort() : []; + +const recordLaunchArgumentDiagnostics = (raw) => { + testConfig.launchArgumentKeys = getLaunchArgumentKeys(raw); + testConfig.rawFixtureServerPort = raw?.fixtureServerPort ?? null; + testConfig.rawCommandQueueServerPort = raw?.commandQueueServerPort ?? null; + testConfig.rawMockServerPort = raw?.mockServerPort ?? null; +}; + // Needed to polyfill random number generation import 'react-native-get-random-values'; @@ -108,12 +120,14 @@ if (isE2E) { // See FixtureHelper.ts for the port mapping implementation. if (isTest) { const raw = LaunchArguments.value(); + recordLaunchArgumentDiagnostics(raw); testConfig.fixtureServerPort = raw?.fixtureServerPort ? raw.fixtureServerPort : FALLBACK_FIXTURE_SERVER_PORT; testConfig.commandQueueServerPort = raw?.commandQueueServerPort ? raw.commandQueueServerPort : FALLBACK_COMMAND_QUEUE_SERVER_PORT; + testConfig.mockServerPort = raw?.mockServerPort ?? defaultMockPort; } // Fix for https://github.com/facebook/react-native/issues/5667 @@ -267,27 +281,88 @@ if (typeof localStorage !== 'undefined') { } if (enableApiCallLogs || isTest) { - (async () => { - const raw = LaunchArguments.value(); - const mockServerPort = raw?.mockServerPort ?? defaultMockPort; - const { fetch: originalFetch } = global; + const raw = LaunchArguments.value(); + recordLaunchArgumentDiagnostics(raw); + const mockServerPort = raw?.mockServerPort ?? defaultMockPort; + testConfig.mockServerPort = mockServerPort; + const originalFetch = + typeof global.fetch === 'function' ? global.fetch.bind(global) : undefined; - // eslint-disable-next-line no-console - console.log( - `[E2E SHIM] Platform: ${Platform.OS}, mockServerPort: ${mockServerPort}`, - ); + // eslint-disable-next-line no-console + console.log( + `[E2E SHIM] Platform: ${Platform.OS}, mockServerPort: ${mockServerPort}`, + ); - // Try multiple hosts to find available mock server - // Priority order: - // 1. localhost (works on iOS, works on Android with adb reverse) - // 2. 10.0.2.2 (Android emulator host - direct access without adb reverse!) - const hosts = ['localhost']; - if (Platform.OS === 'android') { - hosts.push('10.0.2.2'); + // Try multiple hosts to find available mock server + // Priority order: + // 1. localhost (works on iOS, works on Android with adb reverse) + // 2. 10.0.2.2 (Android emulator host - direct access without adb reverse!) + const hosts = ['localhost']; + if (Platform.OS === 'android') { + hosts.push('10.0.2.2'); + } + + let MOCKTTP_URL = ''; + let isMockServerAvailable = false; + + const getUrlString = (url) => { + if (typeof url === 'string') { + return url; + } + if (url instanceof URL) { + return url.href; } + if (url && typeof url === 'object' && url.url) { + return url.url; + } + return String(url); + }; - let MOCKTTP_URL = ''; - let isMockServerAvailable = false; + const buildProxyUrl = (url) => + `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(url)}`; + const shouldProxy = (url) => { + if (typeof url !== 'string') return false; + if (!MOCKTTP_URL) return false; + if (!url.startsWith('http://') && !url.startsWith('https://')) return false; + if (url.startsWith(MOCKTTP_URL)) return false; + if (url.includes('/proxy?url=')) return false; + try { + const parsed = new URL(url); + const isLocalHost = + parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '10.0.2.2'; + if (isLocalHost && parsed.port === String(mockServerPort)) { + return false; + } + } catch (e) { + // ignore URL parse errors and continue to proxy + } + return true; + }; + + const postDiagnosticsToMockServer = async (diagnostics) => { + if (!isMockServerAvailable || !MOCKTTP_URL || !originalFetch) { + return; + } + + await originalFetch(`${MOCKTTP_URL}${E2E_DIAGNOSTICS_ENDPOINT}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(diagnostics), + }).catch((error) => { + // eslint-disable-next-line no-console + console.warn(`[E2E SHIM] Failed to post diagnostics: ${error.message}`); + }); + }; + + const mockServerReadyPromise = (async () => { + if (!originalFetch) { + // eslint-disable-next-line no-console + console.warn('[E2E SHIM] global.fetch is not available to proxy'); + testConfig.mockServerAvailable = 'false'; + return false; + } for (const host of hosts) { const testUrl = `http://${host}:${mockServerPort}`; @@ -315,273 +390,309 @@ if (enableApiCallLogs || isTest) { } } - // if mockServer is off we route to original destination + testConfig.mockServerUrl = MOCKTTP_URL || 'missing'; + testConfig.mockServerAvailable = String(isMockServerAvailable); + + if (isMockServerAvailable) { + await postDiagnosticsToMockServer( + getE2ETestConfigDiagnostics({ + source: 'shim', + phase: 'mock-server-connected', + platform: Platform.OS, + mockServerUrl: MOCKTTP_URL, + mockServerAvailable: String(isMockServerAvailable), + launchArgFixtureServerPort: raw?.fixtureServerPort ?? 'missing', + launchArgCommandQueueServerPort: + raw?.commandQueueServerPort ?? 'missing', + launchArgMockServerPort: raw?.mockServerPort ?? 'missing', + }), + ); + } + + return isMockServerAvailable; + })(); + + testConfig.postE2EDiagnostics = async (diagnostics) => { + await mockServerReadyPromise; + await postDiagnosticsToMockServer(diagnostics); + }; + + if (originalFetch) { global.fetch = async (url, options) => { - // Extract URL string from Request or URL objects - let urlString; - if (typeof url === 'string') { - urlString = url; - } else if (url instanceof URL) { - urlString = url.href; - } else if (url && typeof url === 'object' && url.url) { - // Request object has a 'url' property - urlString = url.url; - } else { - urlString = String(url); + const urlString = getUrlString(url); + const mockServerReady = await mockServerReadyPromise; + + if (!mockServerReady || !shouldProxy(urlString)) { + return originalFetch(url, options); } - return isMockServerAvailable - ? originalFetch( - `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(urlString)}`, - options, - ).catch(() => originalFetch(url, options)) - : originalFetch(url, options); + return originalFetch(buildProxyUrl(urlString), options).catch((error) => { + if (isE2E) { + throw error; + } + + return originalFetch(url, options); + }); }; + testConfig.fetchProxyInstalled = 'true'; - if (isMockServerAvailable) { - // Patch XMLHttpRequest for Axios and other libraries - const OriginalXHR = global.XMLHttpRequest; - - if (OriginalXHR) { - global.XMLHttpRequest = function (...args) { - const xhr = new OriginalXHR(...args); - const originalOpen = xhr.open; - - xhr.open = function (method, url, ...openArgs) { - try { - // Route external URLs through mock server proxy - if ( - typeof url === 'string' && - (url.startsWith('http://') || url.startsWith('https://')) - ) { - // Bypass proxy for local command queue server - try { - const parsed = new URL(url); - const isLocalHost = - parsed.hostname === 'localhost' || - parsed.hostname === '127.0.0.1' || - parsed.hostname === '10.0.2.2'; - const isCommandQueue = - isLocalHost && - parsed.port === - String( - testConfig.commandQueueServerPort || - FALLBACK_COMMAND_QUEUE_SERVER_PORT, - ); - if (isCommandQueue) { - return originalOpen.call(this, method, url, ...openArgs); - } - } catch (e) { - // ignore URL parse errors and continue to proxy - } + // eslint-disable-next-line no-console + console.log('[E2E SHIM] Installed fetch proxy wrapper'); + } + + mockServerReadyPromise + .then(() => { + if (isMockServerAvailable) { + const diagnostics = getE2ETestConfigDiagnostics({ + source: 'shim', + phase: 'mock-server-network-patches-installing', + platform: Platform.OS, + mockServerUrl: MOCKTTP_URL, + mockServerAvailable: String(isMockServerAvailable), + launchArgFixtureServerPort: raw?.fixtureServerPort ?? 'missing', + launchArgCommandQueueServerPort: + raw?.commandQueueServerPort ?? 'missing', + launchArgMockServerPort: raw?.mockServerPort ?? 'missing', + }); + + postDiagnosticsToMockServer(diagnostics); + // Patch XMLHttpRequest for Axios and other libraries + const OriginalXHR = global.XMLHttpRequest; + + if (OriginalXHR) { + global.XMLHttpRequest = function (...args) { + const xhr = new OriginalXHR(...args); + const originalOpen = xhr.open; + + xhr.open = function (method, url, ...openArgs) { + try { + // Route external URLs through mock server proxy if ( - !url.includes(`localhost:${mockServerPort}`) && - !url.includes('/proxy') + typeof url === 'string' && + (url.startsWith('http://') || url.startsWith('https://')) ) { - const originalUrl = url; - url = `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(url)}`; + // Bypass proxy for local command queue server + try { + const parsed = new URL(url); + const isLocalHost = + parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '10.0.2.2'; + const isCommandQueue = + isLocalHost && + parsed.port === + String( + testConfig.commandQueueServerPort || + FALLBACK_COMMAND_QUEUE_SERVER_PORT, + ); + if (isCommandQueue) { + return originalOpen.call(this, method, url, ...openArgs); + } + } catch (e) { + // ignore URL parse errors and continue to proxy + } + if (shouldProxy(url)) { + url = buildProxyUrl(url); + } } + return originalOpen.call(this, method, url, ...openArgs); + } catch (error) { + return originalOpen.call(this, method, url, ...openArgs); } - return originalOpen.call(this, method, url, ...openArgs); - } catch (error) { - return originalOpen.call(this, method, url, ...openArgs); - } - }; + }; - return xhr; - }; + return xhr; + }; - // Copy static properties and prototype chain - try { - Object.setPrototypeOf(global.XMLHttpRequest, OriginalXHR); - Object.assign(global.XMLHttpRequest, OriginalXHR); + // Copy static properties and prototype chain + try { + Object.setPrototypeOf(global.XMLHttpRequest, OriginalXHR); + Object.assign(global.XMLHttpRequest, OriginalXHR); - // Store reference to verify patching worked - global.__MOCK_XHR_PATCHED = true; - global.__ORIGINAL_XHR = OriginalXHR; + // Store reference to verify patching worked + global.__MOCK_XHR_PATCHED = true; + global.__ORIGINAL_XHR = OriginalXHR; - // eslint-disable-next-line no-console - console.log( - '[XHR Patch] Successfully patched XMLHttpRequest for E2E testing', + // eslint-disable-next-line no-console + console.log( + '[XHR Patch] Successfully patched XMLHttpRequest for E2E testing', + ); + } catch (error) { + console.warn('[XHR Patch] Failed to copy XHR properties:', error); + // Restore original if copying failed + global.XMLHttpRequest = OriginalXHR; + } + } else { + console.warn( + '[XHR Patch] XMLHttpRequest not available, skipping patch', ); - } catch (error) { - console.warn('[XHR Patch] Failed to copy XHR properties:', error); - // Restore original if copying failed - global.XMLHttpRequest = OriginalXHR; } - } else { - console.warn( - '[XHR Patch] XMLHttpRequest not available, skipping patch', - ); - } - // Patch WebSocket to route production wss:// URLs to local mock servers. - // Each WS service gets its own mock port via WS_SERVICES config. - // Non-matching wss:// URLs pass through unchanged. - if (WS_SERVICES.length > 0 && global.WebSocket) { - const OriginalWebSocket = global.WebSocket; + // Patch WebSocket to route production wss:// URLs to local mock servers. + // Each WS service gets its own mock port via WS_SERVICES config. + // Non-matching wss:// URLs pass through unchanged. + if (WS_SERVICES.length > 0 && global.WebSocket) { + const OriginalWebSocket = global.WebSocket; - const wsRoutes = {}; - for (const svc of WS_SERVICES) { - const port = raw?.[svc.launchArgKey] ?? svc.fallbackPort; - wsRoutes[svc.url] = `ws://localhost:${port}`; - } + const wsRoutes = {}; + for (const svc of WS_SERVICES) { + const port = raw?.[svc.launchArgKey] ?? svc.fallbackPort; + wsRoutes[svc.url] = `ws://localhost:${port}`; + } - global.WebSocket = function (url, protocols) { - let targetUrl = url; - if (typeof url === 'string') { - for (const [prefix, localUrl] of Object.entries(wsRoutes)) { - if (url.startsWith(prefix)) { - targetUrl = localUrl; - break; + global.WebSocket = function (url, protocols) { + let targetUrl = url; + if (typeof url === 'string') { + for (const [prefix, localUrl] of Object.entries(wsRoutes)) { + if (url.startsWith(prefix)) { + targetUrl = localUrl; + break; + } } } - } - return protocols !== undefined - ? new OriginalWebSocket(targetUrl, protocols) - : new OriginalWebSocket(targetUrl); - }; - - Object.setPrototypeOf(global.WebSocket, OriginalWebSocket); - Object.assign(global.WebSocket, OriginalWebSocket); - global.WebSocket.prototype = OriginalWebSocket.prototype; + return protocols !== undefined + ? new OriginalWebSocket(targetUrl, protocols) + : new OriginalWebSocket(targetUrl); + }; - // eslint-disable-next-line no-console - console.log(`[WS Patch] Routes: ${JSON.stringify(wsRoutes)}`); - } + Object.setPrototypeOf(global.WebSocket, OriginalWebSocket); + Object.assign(global.WebSocket, OriginalWebSocket); + global.WebSocket.prototype = OriginalWebSocket.prototype; - // Patch expo/fetch so its native networking routes through the mock - // proxy. Bridge-controller's SSE `getQuoteStream` (and any other expo - // fetch consumer) MUST hit the mock or the swap/bridge E2E quote - // mocks never fire and tests time out waiting for "Rate" / - // "Network fee". - // - // Defence-in-depth: we patch THREE places because we cannot rely on - // any single one surviving Metro/Babel transpilation in `expo@54`: - // - // 1. The native module prototype `ExpoFetchModule.NativeRequest - // .prototype.start(url, init, body)`. This is the lowest layer - // that EVERY expo fetch ultimately calls, regardless of how - // `import { fetch } from 'expo/fetch'` was bundled or which - // module captured the reference. Bulletproof. - // 2. The re-exporter `expo/src/winter/fetch/index` (what - // `expo/fetch` resolves to). Catches consumers that destructure - // the binding from the re-exporter at module load time. - // 3. The source module `expo/src/winter/fetch/fetch`. Catches - // consumers that read through the live `export *` getter. - // - // If we miss the JS-level patches but get the native one, requests - // still get redirected — they just won't show the [E2E SHIM] log - // line at the JS layer. - const buildProxyUrl = (url) => - `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(url)}`; - const shouldProxy = (url) => { - if (typeof url !== 'string') return false; - if (!url.startsWith('http://') && !url.startsWith('https://')) - return false; - if (url.startsWith(MOCKTTP_URL)) return false; - return true; - }; - - // (1) Native prototype — bulletproof - try { - const { - ExpoFetchModule, - } = require('expo/src/winter/fetch/ExpoFetchModule'); - const NativeRequest = ExpoFetchModule?.NativeRequest; - const proto = NativeRequest?.prototype; - if (proto && typeof proto.start === 'function' && !proto.__e2ePatched) { - const originalStart = proto.start; - proto.start = function patchedStart(url, init, body) { - const targetUrl = shouldProxy(url) ? buildProxyUrl(url) : url; - if (targetUrl !== url) { - // eslint-disable-next-line no-console - console.log( - `[E2E SHIM] expo/fetch (native): ${url} → ${targetUrl}`, - ); - } - return originalStart.call(this, targetUrl, init, body); - }; - proto.__e2ePatched = true; - // eslint-disable-next-line no-console - console.log( - '[E2E SHIM] Patched ExpoFetchModule.NativeRequest.prototype.start', - ); - } else if (proto?.__e2ePatched) { // eslint-disable-next-line no-console - console.log('[E2E SHIM] NativeRequest.start already patched'); - } else { - // eslint-disable-next-line no-console - console.warn( - '[E2E SHIM] ExpoFetchModule.NativeRequest.prototype.start not patchable', - ); + console.log(`[WS Patch] Routes: ${JSON.stringify(wsRoutes)}`); } - } catch (e) { - // eslint-disable-next-line no-console - console.warn( - '[E2E SHIM] Failed to patch ExpoFetchModule native prototype:', - e.message, - ); - } - // (2) + (3) JS-level patches for log visibility and as a fallback. - // NOTE: each `require(...)` call MUST use a string literal — Metro - // statically analyses requires and rejects variable paths. - const patchExpoFetchModuleObject = (mod, label) => { + // Patch expo/fetch so its native networking routes through the mock + // proxy. Bridge-controller's SSE `getQuoteStream` (and any other expo + // fetch consumer) MUST hit the mock or the swap/bridge E2E quote + // mocks never fire and tests time out waiting for "Rate" / + // "Network fee". + // + // Defence-in-depth: we patch THREE places because we cannot rely on + // any single one surviving Metro/Babel transpilation in `expo@54`: + // + // 1. The native module prototype `ExpoFetchModule.NativeRequest + // .prototype.start(url, init, body)`. This is the lowest layer + // that EVERY expo fetch ultimately calls, regardless of how + // `import { fetch } from 'expo/fetch'` was bundled or which + // module captured the reference. Bulletproof. + // 2. The re-exporter `expo/src/winter/fetch/index` (what + // `expo/fetch` resolves to). Catches consumers that destructure + // the binding from the re-exporter at module load time. + // 3. The source module `expo/src/winter/fetch/fetch`. Catches + // consumers that read through the live `export *` getter. + // + // If we miss the JS-level patches but get the native one, requests + // still get redirected — they just won't show the [E2E SHIM] log + // line at the JS layer. + // (1) Native prototype — bulletproof try { - const originalExpoFetch = mod.fetch; - if (typeof originalExpoFetch !== 'function') { + const { + ExpoFetchModule, + } = require('expo/src/winter/fetch/ExpoFetchModule'); + const NativeRequest = ExpoFetchModule?.NativeRequest; + const proto = NativeRequest?.prototype; + if ( + proto && + typeof proto.start === 'function' && + !proto.__e2ePatched + ) { + const originalStart = proto.start; + proto.start = function patchedStart(url, init, body) { + const targetUrl = shouldProxy(url) ? buildProxyUrl(url) : url; + if (targetUrl !== url) { + // eslint-disable-next-line no-console + console.log( + `[E2E SHIM] expo/fetch (native): ${url} → ${targetUrl}`, + ); + } + return originalStart.call(this, targetUrl, init, body); + }; + proto.__e2ePatched = true; + // eslint-disable-next-line no-console + console.log( + '[E2E SHIM] Patched ExpoFetchModule.NativeRequest.prototype.start', + ); + } else if (proto?.__e2ePatched) { // eslint-disable-next-line no-console - console.warn(`[E2E SHIM] ${label}: no fetch export to patch`); - return; + console.log('[E2E SHIM] NativeRequest.start already patched'); + } else { + // eslint-disable-next-line no-console + console.warn( + '[E2E SHIM] ExpoFetchModule.NativeRequest.prototype.start not patchable', + ); } - const patchedExpoFetch = (url, options) => { - if (!shouldProxy(url)) { - return originalExpoFetch(url, options); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + '[E2E SHIM] Failed to patch ExpoFetchModule native prototype:', + e.message, + ); + } + + // (2) + (3) JS-level patches for log visibility and as a fallback. + // NOTE: each `require(...)` call MUST use a string literal — Metro + // statically analyses requires and rejects variable paths. + const patchExpoFetchModuleObject = (mod, label) => { + try { + const originalExpoFetch = mod.fetch; + if (typeof originalExpoFetch !== 'function') { + // eslint-disable-next-line no-console + console.warn(`[E2E SHIM] ${label}: no fetch export to patch`); + return; } - const proxyUrl = buildProxyUrl(url); + const patchedExpoFetch = (url, options) => { + if (!shouldProxy(url)) { + return originalExpoFetch(url, options); + } + const proxyUrl = buildProxyUrl(url); + // eslint-disable-next-line no-console + console.log(`[E2E SHIM] ${label}: ${url} → ${proxyUrl}`); + return originalExpoFetch(proxyUrl, options); + }; + Object.defineProperty(mod, 'fetch', { + configurable: true, + enumerable: true, + writable: true, + value: patchedExpoFetch, + }); + const installed = mod.fetch === patchedExpoFetch; // eslint-disable-next-line no-console - console.log(`[E2E SHIM] ${label}: ${url} → ${proxyUrl}`); - return originalExpoFetch(proxyUrl, options); - }; - Object.defineProperty(mod, 'fetch', { - configurable: true, - enumerable: true, - writable: true, - value: patchedExpoFetch, - }); - const installed = mod.fetch === patchedExpoFetch; + console.log(`[E2E SHIM] Patched ${label} (installed=${installed})`); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`[E2E SHIM] Failed to patch ${label}:`, e.message); + } + }; + try { + patchExpoFetchModuleObject( + require('expo/src/winter/fetch/fetch'), + 'expo/fetch source', + ); + } catch (e) { // eslint-disable-next-line no-console - console.log(`[E2E SHIM] Patched ${label} (installed=${installed})`); + console.warn( + '[E2E SHIM] Failed to require expo/fetch source:', + e.message, + ); + } + try { + patchExpoFetchModuleObject( + require('expo/src/winter/fetch/index'), + 'expo/fetch re-exporter', + ); } catch (e) { // eslint-disable-next-line no-console - console.warn(`[E2E SHIM] Failed to patch ${label}:`, e.message); + console.warn( + '[E2E SHIM] Failed to require expo/fetch re-exporter:', + e.message, + ); } - }; - try { - patchExpoFetchModuleObject( - require('expo/src/winter/fetch/fetch'), - 'expo/fetch source', - ); - } catch (e) { - // eslint-disable-next-line no-console - console.warn( - '[E2E SHIM] Failed to require expo/fetch source:', - e.message, - ); - } - try { - patchExpoFetchModuleObject( - require('expo/src/winter/fetch/index'), - 'expo/fetch re-exporter', - ); - } catch (e) { - // eslint-disable-next-line no-console - console.warn( - '[E2E SHIM] Failed to require expo/fetch re-exporter:', - e.message, - ); } - } - })(); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.warn('[E2E SHIM] Failed to install network patches:', error); + }); } diff --git a/tests/api-mocking/MockServerE2E.ts b/tests/api-mocking/MockServerE2E.ts index 43edaeb59d6..20f803bb589 100644 --- a/tests/api-mocking/MockServerE2E.ts +++ b/tests/api-mocking/MockServerE2E.ts @@ -11,8 +11,11 @@ import { TestSpecificMock, } from '../framework'; import { + collectSeenProxiedRequests, + filterProxiedRequests, findMatchingPostEvent, processPostRequestBody, + type SeenProxiedRequest, setupAccountsV2SupportedNetworksMock, setupAccountsV4TransactionsMock, } from './helpers/mockHelpers.ts'; @@ -93,10 +96,54 @@ interface LiveRequest { timestamp: string; } +export interface E2EDiagnostic { + timestamp: string; + payload: unknown; +} + export interface InternalMockServer extends Mockttp { _liveRequests?: LiveRequest[]; + _e2eDiagnostics?: E2EDiagnostic[]; +} + +const MAX_E2E_DIAGNOSTICS = 20; +const MAX_E2E_DIAGNOSTICS_IN_ERROR = 3; +const REMOTE_FEATURE_FLAGS_URL_REGEX = + /^https:\/\/client-config\.api\.cx\.metamask\.io\/v1\/flags\?/u; + +export interface RemoteFeatureFlagRequestDiagnostics { + seen: boolean; + count: number; + requests: SeenProxiedRequest[]; + error?: string; } +const stringifyE2EDiagnostic = (payload: unknown): string => { + try { + return JSON.stringify(payload, null, 2); + } catch { + return String(payload); + } +}; + +const formatE2EDiagnostics = (diagnostics?: E2EDiagnostic[]): string => { + if (!diagnostics || diagnostics.length === 0) { + return 'E2E app diagnostics: none received.'; + } + + const latestDiagnostics = diagnostics.slice(-MAX_E2E_DIAGNOSTICS_IN_ERROR); + const diagnosticsSummary = latestDiagnostics + .map( + (diagnostic, index) => + `${index + 1}. ${diagnostic.timestamp}\n${stringifyE2EDiagnostic( + diagnostic.payload, + )}`, + ) + .join('\n'); + + return `E2E app diagnostics (latest ${latestDiagnostics.length}/${diagnostics.length}):\n${diagnosticsSummary}`; +}; + /** * Translates fallback ports to actual allocated ports in URLs. * This allows the MockServer (running on host) to forward requests to dynamically allocated local resources. @@ -266,6 +313,42 @@ export default class MockServerE2E implements Resource { return this._server; } + getE2EDiagnostics(): E2EDiagnostic[] { + return [...(this._server?._e2eDiagnostics ?? [])]; + } + + async getRemoteFeatureFlagRequestDiagnostics(): Promise { + if (!this._server) { + return { + seen: false, + count: 0, + requests: [], + error: 'mock-server-not-started', + }; + } + + try { + const seen = await collectSeenProxiedRequests(this._server); + const requests = filterProxiedRequests(seen, { + method: 'GET', + urlRegex: REMOTE_FEATURE_FLAGS_URL_REGEX, + }); + + return { + seen: requests.length > 0, + count: requests.length, + requests, + }; + } catch (error) { + return { + seen: false, + count: 0, + requests: [], + error: error instanceof Error ? error.message : String(error), + }; + } + } + setServerPort(port: number): void { this._serverPort = port; } @@ -278,6 +361,7 @@ export default class MockServerE2E implements Resource { this._server = getLocal() as InternalMockServer; this._server._liveRequests = []; + this._server._e2eDiagnostics = []; await this._server.start(this._serverPort); logger.info( @@ -287,6 +371,38 @@ export default class MockServerE2E implements Resource { await this._server .forGet('/health-check') .thenReply(200, 'Mock server is running'); + await this._server + .forPost('/e2e-diagnostics') + .thenCallback(async (request) => { + const bodyText = await safeGetBodyText(request); + let payload: unknown = bodyText; + + if (bodyText) { + try { + payload = JSON.parse(bodyText); + } catch { + payload = bodyText; + } + } + + const diagnostic = { + timestamp: new Date().toISOString(), + payload, + }; + this._server?._e2eDiagnostics?.push(diagnostic); + + if ( + this._server?._e2eDiagnostics && + this._server._e2eDiagnostics.length > MAX_E2E_DIAGNOSTICS + ) { + this._server._e2eDiagnostics.shift(); + } + + logger.debug( + `Received E2E app diagnostics: ${stringifyE2EDiagnostic(payload)}`, + ); + return { statusCode: 204, body: '' }; + }); await this._server .forGet( /^http:\/\/(localhost|127\.0\.0\.1|10\.0\.2\.2)(:\\d+)?\/favicon\.ico$/, @@ -854,8 +970,10 @@ export default class MockServerE2E implements Resource { const totalCount = mockServer._liveRequests.length; const uniqueCount = uniqueRequests.length; + const diagnosticsSummary = formatE2EDiagnostics(mockServer._e2eDiagnostics); const message = `Test made ${totalCount} unmocked request(s) (${uniqueCount} unique):\n${requestsSummary}\n\n` + + `${diagnosticsSummary}\n\n` + "Check your test-specific mocks or add them to the default mocks.\n You can also add the URL to the allowlist if it's a known live request."; logger.error(message); throw new Error(message); diff --git a/tests/framework/E2EDiagnostics.ts b/tests/framework/E2EDiagnostics.ts new file mode 100644 index 00000000000..0b11ba2e300 --- /dev/null +++ b/tests/framework/E2EDiagnostics.ts @@ -0,0 +1,274 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { copyFile, mkdir, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { LoginViewSelectors } from '../../app/components/Views/Login/LoginView.testIds'; +import { OnboardingSelectorIDs } from '../../app/components/Views/Onboarding/Onboarding.testIds'; +import { WalletViewSelectorsIDs } from '../../app/components/Views/Wallet/WalletView.testIds'; +import { ErrorBoundarySelectorsIDs } from '../selectors/ErrorBoundary/ErrorBoundaryView.selectors'; +import { createLogger } from './logger'; + +const logger = createLogger({ name: 'E2EDiagnostics' }); + +const ARTIFACT_ROOT = + process.env.E2E_DIAGNOSTICS_ROOT || + join(process.cwd(), 'tests', 'artifacts', 'e2e-diagnostics'); + +const REDACTED_VALUE = '[redacted]'; + +const SENSITIVE_KEY_PATTERN = + /password|secret|seed|token|key|mnemonic|vault|auth|credential/iu; + +type JsonRecord = Record; + +export interface E2EDiagnosticsContext { + reason: string; + error?: unknown; + metadata?: JsonRecord; +} + +interface JestStateLike { + currentTestName?: string; + testPath?: string; +} + +interface ArtifactWriteResult { + path?: string; + error?: JsonRecord; +} + +const UI_SENTINELS = [ + { name: 'login.container', value: LoginViewSelectors.CONTAINER }, + { name: 'login.passwordInput', value: LoginViewSelectors.PASSWORD_INPUT }, + { name: 'login.button', value: LoginViewSelectors.LOGIN_BUTTON_ID }, + { name: 'wallet.container', value: WalletViewSelectorsIDs.WALLET_CONTAINER }, + { name: 'wallet.accountPicker', value: WalletViewSelectorsIDs.ACCOUNT_ICON }, + { name: 'wallet.buyButton', value: WalletViewSelectorsIDs.WALLET_BUY_BUTTON }, + { name: 'onboarding.container', value: OnboardingSelectorIDs.CONTAINER_ID }, + { + name: 'onboarding.createWallet', + value: OnboardingSelectorIDs.NEW_WALLET_BUTTON, + }, + { + name: 'onboarding.existingWallet', + value: OnboardingSelectorIDs.EXISTING_WALLET_BUTTON, + }, + { + name: 'errorBoundary.container', + value: ErrorBoundarySelectorsIDs.CONTAINER, + }, +] as const; + +let captureCount = 0; + +const safeFileFragment = (value: string): string => + value + .replace(/[^a-z0-9._-]+/giu, '-') + .replace(/^-+|-+$/gu, '') + .slice(0, 120) || 'unknown'; + +const getJestState = (): JestStateLike => { + try { + if (typeof expect === 'undefined' || !expect.getState) { + return {}; + } + return expect.getState() as JestStateLike; + } catch { + return {}; + } +}; + +const getDevicePlatform = (): string => { + try { + return device.getPlatform(); + } catch { + return 'unknown'; + } +}; + +const serializeError = (error: unknown): JsonRecord | undefined => { + if (!error) { + return undefined; + } + + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return { + message: String(error), + }; +}; + +const sanitizeMetadata = (metadata: unknown): unknown => { + if (Array.isArray(metadata)) { + return metadata.map((item) => sanitizeMetadata(item)); + } + + if (!metadata || typeof metadata !== 'object') { + return metadata; + } + + return Object.entries(metadata as JsonRecord).reduce( + (sanitized, [key, value]) => { + sanitized[key] = SENSITIVE_KEY_PATTERN.test(key) + ? REDACTED_VALUE + : sanitizeMetadata(value); + return sanitized; + }, + {}, + ); +}; + +const writeJson = async (filePath: string, value: unknown): Promise => { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +}; + +const captureScreenshot = async ( + artifactId: string, + outputDir: string, +): Promise => { + try { + const tempPath = await device.takeScreenshot(`${artifactId}-screenshot`); + const outputPath = join(outputDir, 'screenshot.png'); + await copyFile(tempPath, outputPath); + return { path: outputPath }; + } catch (error) { + return { error: serializeError(error) }; + } +}; + +const captureViewHierarchyXml = async ( + outputDir: string, +): Promise<{ xml?: string; result: ArtifactWriteResult }> => { + try { + const xml = await device.generateViewHierarchyXml(); + const outputPath = join(outputDir, 'view-hierarchy.xml'); + await writeFile(outputPath, xml); + return { xml, result: { path: outputPath } }; + } catch (error) { + return { result: { error: serializeError(error) } }; + } +}; + +const captureIosViewHierarchy = async ( + artifactId: string, + outputDir: string, +): Promise => { + if (getDevicePlatform() !== 'ios') { + return undefined; + } + + try { + const tempPath = await device.captureViewHierarchy( + `${artifactId}-view-hierarchy`, + ); + const outputPath = join(outputDir, 'view-hierarchy.viewhierarchy'); + await copyFile(tempPath, outputPath); + return { path: outputPath }; + } catch (error) { + return { error: serializeError(error) }; + } +}; + +const summarizeUiState = (viewHierarchyXml?: string): JsonRecord => { + if (!viewHierarchyXml) { + return { + classification: 'unknown', + foundSentinels: [], + missingSentinels: UI_SENTINELS.map((sentinel) => sentinel.name), + }; + } + + const foundSentinels: string[] = UI_SENTINELS.filter((sentinel) => + viewHierarchyXml.includes(sentinel.value), + ).map((sentinel) => sentinel.name); + + const has = (name: string) => foundSentinels.includes(name); + const classification = has('errorBoundary.container') + ? 'error-boundary' + : has('login.container') + ? 'login' + : has('wallet.container') + ? 'wallet' + : has('onboarding.container') + ? 'onboarding' + : 'unknown'; + + return { + classification, + foundSentinels, + missingSentinels: UI_SENTINELS.filter( + (sentinel) => !foundSentinels.includes(sentinel.name), + ).map((sentinel) => sentinel.name), + }; +}; + +export const captureE2EDiagnostics = async ({ + reason, + error, + metadata = {}, +}: E2EDiagnosticsContext): Promise => { + if (process.env.E2E_DIAGNOSTICS === 'false') { + return; + } + + try { + captureCount += 1; + const timestamp = new Date().toISOString().replace(/[:.]/gu, '-'); + const jestState = getJestState(); + const testName = jestState.currentTestName || 'unknown-test'; + const artifactId = safeFileFragment( + `${timestamp}-${process.pid}-${captureCount}-${reason}-${testName}`, + ); + const outputDir = join(ARTIFACT_ROOT, artifactId); + + await mkdir(outputDir, { recursive: true }); + + const screenshot = await captureScreenshot(artifactId, outputDir); + const viewHierarchy = await captureViewHierarchyXml(outputDir); + const iosViewHierarchy = await captureIosViewHierarchy( + artifactId, + outputDir, + ); + const uiState = summarizeUiState(viewHierarchy.xml); + + await writeJson(join(outputDir, 'metadata.json'), { + capturedAt: new Date().toISOString(), + reason, + platform: getDevicePlatform(), + testName, + testPath: jestState.testPath, + process: { + pid: process.pid, + cwd: process.cwd(), + }, + ci: { + isCI: process.env.CI === 'true', + githubJob: process.env.GITHUB_JOB, + githubRunId: process.env.GITHUB_RUN_ID, + githubRunAttempt: process.env.GITHUB_RUN_ATTEMPT, + jobName: process.env.JOB_NAME, + runId: process.env.RUN_ID, + runAttempt: process.env.RUN_ATTEMPT, + }, + error: serializeError(error), + metadata: sanitizeMetadata(metadata), + artifacts: { + screenshot, + viewHierarchyXml: viewHierarchy.result, + iosViewHierarchy, + }, + uiState, + }); + + await writeJson(join(outputDir, 'ui-state.json'), uiState); + + logger.info(`Captured E2E diagnostics in ${outputDir}`); + } catch (captureError) { + logger.warn('Failed to capture E2E diagnostics', captureError); + } +}; diff --git a/tests/framework/fixtures/FixtureHelper.ts b/tests/framework/fixtures/FixtureHelper.ts index 756b75a16c1..b8dbba7f84a 100644 --- a/tests/framework/fixtures/FixtureHelper.ts +++ b/tests/framework/fixtures/FixtureHelper.ts @@ -62,11 +62,34 @@ import { setupAccountActivityMocks, resetAccountActivityMockState, } from '../../websocket/account-activity-mocks'; +import { captureE2EDiagnostics } from '../E2EDiagnostics'; const logger = createLogger({ name: 'FixtureHelper', }); +const getMockServerDiagnosticsMetadata = async ( + mockServerInstance?: MockServerE2E, +) => { + if (!mockServerInstance) { + return { + appDiagnostics: [], + remoteFeatureFlagRequests: { + seen: false, + count: 0, + requests: [], + error: 'mock-server-not-started', + }, + }; + } + + return { + appDiagnostics: mockServerInstance.getE2EDiagnostics(), + remoteFeatureFlagRequests: + await mockServerInstance.getRemoteFeatureFlagRequestDiagnostics(), + }; +}; + /** * Handles the dapps by starting the servers and listening to the ports. * @param dapps - The dapps to start. @@ -668,6 +691,48 @@ export async function withFixtures( } catch (error) { testError = error as Error; logger.error('Error in withFixtures:', error); + const mockServerDiagnostics = + await getMockServerDiagnosticsMetadata(mockServerInstance); + await captureE2EDiagnostics({ + reason: 'withFixtures-test-failure', + error, + metadata: { + resources: { + fixtureServer: { + port: fixtureServer.getServerPort(), + url: fixtureServer.getServerUrl, + started: fixtureServer.isStarted(), + status: fixtureServer.getServerStatus(), + }, + mockServer: { + port: mockServerPort, + started: mockServerInstance?.isStarted() ?? false, + }, + commandQueueServer: { + port: commandQueueServer.getServerPort(), + started: commandQueueServer.isStarted(), + status: commandQueueServer.getServerStatus(), + }, + accountActivityWsServer: { + port: accountActivityWsServer.getServerPort(), + started: accountActivityWsServer.isStarted(), + status: accountActivityWsServer.getServerStatus(), + }, + }, + mockServerDiagnostics, + options: { + restartDevice, + skipReactNativeReload, + disableSynchronization, + useCommandQueueServer, + hasLaunchArgs: Boolean(launchArgs), + launchArgs, + hasTestSpecificMock: Boolean(testSpecificMock), + hasDapps: Boolean(dapps?.length), + disableLocalNodes, + }, + }, + }); } finally { const cleanupErrors: Error[] = []; @@ -754,6 +819,20 @@ export async function withFixtures( mockServerInstance.validateLiveRequests(); } catch (cleanupError) { logger.error('Error during live request validation:', cleanupError); + await captureE2EDiagnostics({ + reason: 'withFixtures-live-request-validation-failure', + error: cleanupError, + metadata: { + mockServerDiagnostics: + await getMockServerDiagnosticsMetadata(mockServerInstance), + resources: { + mockServer: { + port: mockServerPort, + started: mockServerInstance.isStarted(), + }, + }, + }, + }); cleanupErrors.push(cleanupError as Error); } }