diff --git a/.github/workflows/scripts/boot-simulator.sh b/.github/workflows/scripts/boot-simulator.sh index 4c9f8b5efd..b41c3f603b 100755 --- a/.github/workflows/scripts/boot-simulator.sh +++ b/.github/workflows/scripts/boot-simulator.sh @@ -155,6 +155,16 @@ wait_for_simulator_ready() { return 1 } +BOOT_MODE="${RNFB_SIM_BOOT_MODE:-full}" + +# shellcheck source=simulator-logging.sh +source "$(dirname "$0")/simulator-logging.sh" + +if [[ "$BOOT_MODE" == "logs" ]]; then + restart_simulator_logging || true + exit 0 +fi + # Get our simulator name from our test Detox config pushd "$(dirname "$0")/../../../tests" >/dev/null || exit 1 SIM="$(grep iPhone .detoxrc.js | head -1 | cut -d"'" -f2)" @@ -220,3 +230,7 @@ log_boot_status "install complete in $((SECONDS - install_start))s" popd >/dev/null || exit 1 log_boot_status "phase=complete device=\"${SIM}\" ready with test app installed" + +if [[ "${RNFB_START_SIM_LOGS:-1}" == "1" && "$BOOT_MODE" == "full" ]]; then + restart_simulator_logging || true +fi diff --git a/.github/workflows/scripts/deploy-e2e-cloud-functions.sh b/.github/workflows/scripts/deploy-e2e-cloud-functions.sh new file mode 100755 index 0000000000..ab82a73129 --- /dev/null +++ b/.github/workflows/scripts/deploy-e2e-cloud-functions.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Deploy live-project e2e helper callables (Cloud Logging metrics, App Check, RC admin). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +yarn firebase -c "$(pwd)/firebase.json" --project react-native-firebase-testing deploy --only \ + functions:e2eCloudMetricsV2,functions:e2eCloudMetricsSummaryV2,functions:fetchAppCheckTokenV2,functions:testFunctionRemoteConfigUpdateV2 diff --git a/.github/workflows/scripts/deploy-remote-config-function.sh b/.github/workflows/scripts/deploy-remote-config-function.sh deleted file mode 100755 index 9788662bb2..0000000000 --- a/.github/workflows/scripts/deploy-remote-config-function.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -yarn firebase -c "$(pwd)/firebase.json" --project react-native-firebase-testing deploy --only functions:testFunctionRemoteConfigUpdate \ No newline at end of file diff --git a/.github/workflows/scripts/flake-summary.sh b/.github/workflows/scripts/flake-summary.sh index 7de83150cb..af857a43d4 100644 --- a/.github/workflows/scripts/flake-summary.sh +++ b/.github/workflows/scripts/flake-summary.sh @@ -19,18 +19,34 @@ DETOX_LOG="${RNFB_DETOX_LOG:-}" fi echo "=== ${label}: ${file} ($(wc -l <"$file" | tr -d ' ') lines) ===" rg -n \ - '\[jet-ws\]|\[rnfb-e2e\]|\[jet-coverage\]|\[rnfb-lifecycle\]|RETRYABLE_DISCONNECT|reconnect_recovered|FrontBoard|FBSOpenApplication|coverage-ready|retry-eligibility|install-state|terminateApp|launchApp failure|Jet attempt|FAIL e2e' \ + '\[jet-ws\]|\[rnfb-e2e\]|\[jet-coverage\]|\[rnfb-lifecycle\]|RETRYABLE_DISCONNECT|reconnect_recovered|FrontBoard|FBSOpenApplication|coverage-ready|retry-eligibility|install-state|terminateApp|launchApp failure|Jet attempt|FAIL e2e|orchestrate-state|Child process terminated|log stream restarted|\[load-settle\]' \ "$file" 2>/dev/null | tail -200 || true echo "" } summarize "detox-log" "${DETOX_LOG}" - summarize "simulator-log" "simulator.log" - summarize "testing-log" "testing.log" - summarize "springboard-log" "springboard-invertase.log" + summarize "sim-app-log" "sim-app.log" summarize "resource-monitor" "resource-monitor.log" summarize "resource-monitor-android" "resource-monitor-android.log" summarize "metro-log" "metro.log" + + echo "=== disconnect_context vs resource-monitor load ===" + if [[ -f "${DETOX_LOG}" ]]; then + rg -n '\[jet-ws\] disconnect_context' "${DETOX_LOG}" 2>/dev/null | tail -20 || true + fi + if [[ -f "resource-monitor.log" ]]; then + echo "--- resource-monitor load around disconnect (last 80 load lines) ---" + rg -n 'load averages|^\[resource-monitor\] ps-empty' resource-monitor.log 2>/dev/null | tail -80 || true + fi + echo "" + + echo "=== log stream death markers ===" + for f in sim-app.log; do + if [[ -f "$f" ]]; then + rg -n 'Child process terminated|log stream restarted' "$f" 2>/dev/null | tail -20 || true + fi + done + echo "" } >"$OUT" echo "[flake-summary] wrote ${OUT} ($(wc -l <"$OUT" | tr -d ' ') lines)" diff --git a/.github/workflows/scripts/functions/package.json b/.github/workflows/scripts/functions/package.json index e459ec9c0e..c09b5131f5 100644 --- a/.github/workflows/scripts/functions/package.json +++ b/.github/workflows/scripts/functions/package.json @@ -13,6 +13,7 @@ }, "main": "lib/index.js", "dependencies": { + "@google-cloud/logging": "^11.2.1", "firebase-admin": "^13.8.0", "firebase-functions": "^7.2.5" }, diff --git a/.github/workflows/scripts/functions/src/e2eCallOptions.ts b/.github/workflows/scripts/functions/src/e2eCallOptions.ts new file mode 100644 index 0000000000..3c04a8c245 --- /dev/null +++ b/.github/workflows/scripts/functions/src/e2eCallOptions.ts @@ -0,0 +1,2 @@ +/** Server timeout for e2e callables that are not probing client deadline behavior. */ +export const E2E_TEST_FUNCTION_TIMEOUT_SECONDS = 120; diff --git a/.github/workflows/scripts/functions/src/e2eCloudMetrics.ts b/.github/workflows/scripts/functions/src/e2eCloudMetrics.ts new file mode 100644 index 0000000000..5542cdff8d --- /dev/null +++ b/.github/workflows/scripts/functions/src/e2eCloudMetrics.ts @@ -0,0 +1,153 @@ +import { Logging, type Entry } from '@google-cloud/logging'; +import { logger } from 'firebase-functions/v2'; +import { CallableRequest, onCall } from 'firebase-functions/v2/https'; + +export type E2eCloudMetricPayload = { + source?: string; + category: string; + event: string; + platform?: string; + durationMs?: number; + attempt?: number; + error?: string; + metadata?: Record; +}; + +const METRICS_LOG_MESSAGE = '[rnfb-e2e-metrics]'; +const SUMMARY_QUERY_EVENT = 'summary-query'; + +function logE2eCloudMetric(payload: Record): void { + logger.write({ + severity: 'INFO', + message: METRICS_LOG_MESSAGE, + ...payload, + }); +} + +function parseJsonObject(value: unknown): Record | null { + if (!value) { + return null; + } + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + } + return null; +} + +function metricPayloadFromLogEntry(entry: Entry): Record | null { + const metadata = entry.metadata as Record | undefined; + const candidates = [ + metadata?.jsonPayload, + entry.data, + metadata?.textPayload, + ]; + + for (const candidate of candidates) { + const parsed = parseJsonObject(candidate); + if (!parsed || parsed.message !== METRICS_LOG_MESSAGE) { + continue; + } + if (parsed.event === SUMMARY_QUERY_EVENT) { + return null; + } + return parsed; + } + + return null; +} + +/** + * Records e2e cloud-pressure events in Cloud Logging (live project only). + * Grep Cloud Logging for `[rnfb-e2e-metrics]` to aggregate cross-run pressure. + */ +export const e2eCloudMetricsV2 = onCall( + { timeoutSeconds: 30 }, + async (req: CallableRequest) => { + const payload = { + ...req.data, + receivedAt: new Date().toISOString(), + }; + logE2eCloudMetric(payload); + return { ok: true }; + }, +); + +type MetricsSummary = { + lookbackHours: number; + since: string; + total: number; + byCategory: Record; + byEvent: Record; + byPlatform: Record; +}; + +/** + * Summarizes recent `[rnfb-e2e-metrics]` entries from Cloud Logging. + * Callable for retrospective cross-run pressure analysis (not logged per Detox run). + */ +export const e2eCloudMetricsSummaryV2 = onCall( + { timeoutSeconds: 60 }, + async (req: CallableRequest<{ lookbackHours?: number }>) => { + const lookbackHours = req.data?.lookbackHours ?? 24; + const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000); + + const logging = new Logging(); + const filter = [ + 'resource.type="cloud_run_revision"', + `timestamp>="${since.toISOString()}"`, + `jsonPayload.message="${METRICS_LOG_MESSAGE}"`, + ].join(' AND '); + + const summary: MetricsSummary = { + lookbackHours, + since: since.toISOString(), + total: 0, + byCategory: {}, + byEvent: {}, + byPlatform: {}, + }; + + let pageToken: string | undefined; + let scanned = 0; + const maxEntries = 5000; + + do { + const [entries, , response] = await logging.getEntries({ + filter, + pageSize: 500, + pageToken, + }); + + for (const entry of entries) { + const payload = metricPayloadFromLogEntry(entry); + if (!payload) { + continue; + } + + summary.total++; + const category = String(payload.category || 'unknown'); + const event = String(payload.event || 'unknown'); + const platform = String(payload.platform || payload.source || 'unknown'); + summary.byCategory[category] = (summary.byCategory[category] || 0) + 1; + summary.byEvent[event] = (summary.byEvent[event] || 0) + 1; + summary.byPlatform[platform] = (summary.byPlatform[platform] || 0) + 1; + } + + scanned += entries.length; + pageToken = response?.nextPageToken || undefined; + } while (pageToken && scanned < maxEntries); + + logE2eCloudMetric({ event: SUMMARY_QUERY_EVENT, ...summary }); + return summary; + }, +); diff --git a/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts b/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts index 955af46e79..3fbbf595b3 100644 --- a/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts +++ b/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts @@ -9,13 +9,17 @@ import { getAppCheck } from 'firebase-admin/app-check'; import { CallableRequest, onCall } from 'firebase-functions/v2/https'; +import { E2E_TEST_FUNCTION_TIMEOUT_SECONDS } from './e2eCallOptions'; import { getAdminApp } from '.'; // Note: this will only work in a live environment, not locally via the Firebase emulator. -export const fetchAppCheckTokenV2 = onCall(async (req: CallableRequest<{ appId: string }>) => { - const { appId } = req.data; - const expireTimeMillis = Math.floor(Date.now() / 1000) + 60 * 60; - getAdminApp(); - const result = await getAppCheck().createToken(appId); - return { ...result, expireTimeMillis }; -}); +export const fetchAppCheckTokenV2 = onCall( + { timeoutSeconds: E2E_TEST_FUNCTION_TIMEOUT_SECONDS }, + async (req: CallableRequest<{ appId: string }>) => { + const { appId } = req.data; + const expireTimeMillis = Math.floor(Date.now() / 1000) + 60 * 60; + getAdminApp(); + const result = await getAppCheck().createToken(appId); + return { ...result, expireTimeMillis }; + }, +); diff --git a/.github/workflows/scripts/functions/src/index.ts b/.github/workflows/scripts/functions/src/index.ts index 688bdfc890..fcc662dbfa 100644 --- a/.github/workflows/scripts/functions/src/index.ts +++ b/.github/workflows/scripts/functions/src/index.ts @@ -32,6 +32,8 @@ export { sendFCM } from './sendFCM'; export { testFetchStream, testFetch } from './vertexaiFunctions'; +export { e2eCloudMetricsV2, e2eCloudMetricsSummaryV2 } from './e2eCloudMetrics'; + export { testStreamingCallable, testProgressStream, diff --git a/.github/workflows/scripts/functions/src/sendFCM.ts b/.github/workflows/scripts/functions/src/sendFCM.ts index 3a78927b5a..31652d13ed 100644 --- a/.github/workflows/scripts/functions/src/sendFCM.ts +++ b/.github/workflows/scripts/functions/src/sendFCM.ts @@ -10,10 +10,12 @@ import { logger } from 'firebase-functions/v2'; import { CallableRequest, onCall } from 'firebase-functions/v2/https'; import { getMessaging, TokenMessage } from 'firebase-admin/messaging'; +import { E2E_TEST_FUNCTION_TIMEOUT_SECONDS } from './e2eCallOptions'; import { getAdminApp } from '.'; // Note: this will only work in a live environment, not locally via the Firebase emulator. export const sendFCM = onCall( + { timeoutSeconds: E2E_TEST_FUNCTION_TIMEOUT_SECONDS }, async (req: CallableRequest<{ message: TokenMessage; delay?: number }>) => { const { message, delay } = req.data; return await new Promise(() => { diff --git a/.github/workflows/scripts/functions/src/testFunctionCustomRegion.ts b/.github/workflows/scripts/functions/src/testFunctionCustomRegion.ts index 4c6e555810..f83a44f158 100644 --- a/.github/workflows/scripts/functions/src/testFunctionCustomRegion.ts +++ b/.github/workflows/scripts/functions/src/testFunctionCustomRegion.ts @@ -8,10 +8,12 @@ */ import { onCall } from 'firebase-functions/v2/https'; +import { E2E_TEST_FUNCTION_TIMEOUT_SECONDS } from './e2eCallOptions'; export const testFunctionCustomRegion = onCall( { region: 'europe-west1', + timeoutSeconds: E2E_TEST_FUNCTION_TIMEOUT_SECONDS, }, () => 'europe-west1', ); diff --git a/.github/workflows/scripts/functions/src/testFunctionDefaultRegion.ts b/.github/workflows/scripts/functions/src/testFunctionDefaultRegion.ts index 5f8c1f8f5c..089933a0b4 100644 --- a/.github/workflows/scripts/functions/src/testFunctionDefaultRegion.ts +++ b/.github/workflows/scripts/functions/src/testFunctionDefaultRegion.ts @@ -10,9 +10,11 @@ import { deepEqual } from 'assert'; import { FirebaseError } from 'firebase-admin'; import { CallableRequest, onCall, HttpsError } from 'firebase-functions/v2/https'; +import { E2E_TEST_FUNCTION_TIMEOUT_SECONDS } from './e2eCallOptions'; import SAMPLE_DATA from './sample-data'; export const testFunctionDefaultRegionV2 = onCall( + { timeoutSeconds: E2E_TEST_FUNCTION_TIMEOUT_SECONDS }, (req: CallableRequest<{ type: string; asError: boolean; inputData: any }>) => { console.log(Date.now(), req.data); diff --git a/.github/workflows/scripts/functions/src/testFunctionRemoteConfigUpdate.ts b/.github/workflows/scripts/functions/src/testFunctionRemoteConfigUpdate.ts index 1b8c7dbb17..41f6ea6391 100644 --- a/.github/workflows/scripts/functions/src/testFunctionRemoteConfigUpdate.ts +++ b/.github/workflows/scripts/functions/src/testFunctionRemoteConfigUpdate.ts @@ -10,12 +10,14 @@ import { getRemoteConfig } from 'firebase-admin/remote-config'; import { logger } from 'firebase-functions/v2'; import { CallableRequest, onCall } from 'firebase-functions/v2/https'; +import { E2E_TEST_FUNCTION_TIMEOUT_SECONDS } from './e2eCallOptions'; import { getAdminApp } from '.'; export const testFunctionRemoteConfigUpdateV2 = onCall( { maxInstances: 1, concurrency: 1, + timeoutSeconds: E2E_TEST_FUNCTION_TIMEOUT_SECONDS, }, async ( req: CallableRequest<{ diff --git a/.github/workflows/scripts/functions/src/testStreamingCallable.ts b/.github/workflows/scripts/functions/src/testStreamingCallable.ts index 043d6a239f..8fb2a4771d 100644 --- a/.github/workflows/scripts/functions/src/testStreamingCallable.ts +++ b/.github/workflows/scripts/functions/src/testStreamingCallable.ts @@ -1,12 +1,17 @@ import { onCall, CallableRequest, CallableResponse, HttpsError } from 'firebase-functions/v2/https'; import { logger } from 'firebase-functions/v2'; +import { E2E_TEST_FUNCTION_TIMEOUT_SECONDS } from './e2eCallOptions'; import SAMPLE_DATA from './sample-data'; +const e2eOnCall = ( + handler: (req: CallableRequest, response?: CallableResponse) => unknown, +) => onCall({ timeoutSeconds: E2E_TEST_FUNCTION_TIMEOUT_SECONDS }, handler); + /** * Test streaming callable function that sends multiple chunks of data * This function demonstrates Server-Sent Events (SSE) streaming */ -export const testStreamingCallable = onCall( +export const testStreamingCallable = e2eOnCall( async ( req: CallableRequest<{ count?: number; delay?: number }>, response?: CallableResponse, @@ -43,7 +48,7 @@ export const testStreamingCallable = onCall( /** * Test streaming callable that sends progressive updates */ -export const testProgressStream = onCall( +export const testProgressStream = e2eOnCall( async (req: CallableRequest<{ task?: string }>, response?: CallableResponse) => { const task = req.data.task || 'Processing'; @@ -71,7 +76,7 @@ export const testProgressStream = onCall( /** * Test streaming with complex data types */ -export const testComplexDataStream = onCall( +export const testComplexDataStream = e2eOnCall( async (req: CallableRequest, response?: CallableResponse) => { logger.info('testComplexDataStream called'); @@ -126,7 +131,7 @@ export const testComplexDataStream = onCall( /** * Test streaming with error handling */ -export const testStreamWithError = onCall( +export const testStreamWithError = e2eOnCall( async ( req: CallableRequest<{ shouldError?: boolean; errorAfter?: number }>, response?: CallableResponse, @@ -161,7 +166,7 @@ export const testStreamWithError = onCall( * Test streaming callable that returns the type of data sent * Similar to Dart's testStreamResponse - sends back the type of input data */ -export const testStreamResponse = onCall( +export const testStreamResponse = e2eOnCall( async (req: CallableRequest, response?: CallableResponse) => { logger.info('testStreamResponse called', { data: req.data }); @@ -207,7 +212,7 @@ export const testStreamResponse = onCall( * Test streaming callable that handles null data * This function specifically accepts null and returns success: true */ -export const testStreamingCallableWithNull = onCall( +export const testStreamingCallableWithNull = e2eOnCall( async (req: CallableRequest, response?: CallableResponse) => { logger.info('testStreamingCallableWithNull called', { data: req.data }); @@ -228,7 +233,7 @@ export const testStreamingCallableWithNull = onCall( * Streaming callable that throws HttpsError (for testing stream-by-name). * Only throws: invalid-argument (bad/missing type), or cancelled with details (when asError). */ -export const testStreamWithHttpsError = onCall( +export const testStreamWithHttpsError = e2eOnCall( async ( req: CallableRequest<{ type?: string; asError?: boolean; inputData?: any }>, response?: CallableResponse, @@ -263,7 +268,7 @@ export const testStreamWithHttpsError = onCall( * Streaming callable that throws HttpsError (for testing stream-from-URL). * Same behaviour as testStreamWithHttpsError; separate export for httpsCallableFromUrl.stream() e2e. */ -export const testStreamWithHttpsErrorFromUrl = onCall( +export const testStreamWithHttpsErrorFromUrl = e2eOnCall( async ( req: CallableRequest<{ type?: string; asError?: boolean; inputData?: any }>, response?: CallableResponse, diff --git a/.github/workflows/scripts/functions/yarn.lock b/.github/workflows/scripts/functions/yarn.lock index 2b492d10f3..0aeb647b78 100644 --- a/.github/workflows/scripts/functions/yarn.lock +++ b/.github/workflows/scripts/functions/yarn.lock @@ -204,6 +204,23 @@ __metadata: languageName: node linkType: hard +"@google-cloud/common@npm:^5.0.0": + version: 5.0.2 + resolution: "@google-cloud/common@npm:5.0.2" + dependencies: + "@google-cloud/projectify": "npm:^4.0.0" + "@google-cloud/promisify": "npm:^4.0.0" + arrify: "npm:^2.0.1" + duplexify: "npm:^4.1.1" + extend: "npm:^3.0.2" + google-auth-library: "npm:^9.0.0" + html-entities: "npm:^2.5.2" + retry-request: "npm:^7.0.0" + teeny-request: "npm:^9.0.0" + checksum: 10/a21a07d5cb3ab7bbe8c2756dd5b853210eacdd11b7866610003c1c87b93d4ae176373b163fe80bd5dbdb6e8fae3e0a3f999f546ed5d1c177eeae2af9a4e7b701 + languageName: node + linkType: hard + "@google-cloud/firestore@npm:^7.11.0": version: 7.11.3 resolution: "@google-cloud/firestore@npm:7.11.3" @@ -217,6 +234,31 @@ __metadata: languageName: node linkType: hard +"@google-cloud/logging@npm:^11.2.1": + version: 11.2.3 + resolution: "@google-cloud/logging@npm:11.2.3" + dependencies: + "@google-cloud/common": "npm:^5.0.0" + "@google-cloud/paginator": "npm:^5.0.0" + "@google-cloud/projectify": "npm:^4.0.0" + "@google-cloud/promisify": "npm:4.0.0" + "@grpc/grpc-js": "npm:^1.14.3" + "@opentelemetry/api": "npm:^1.7.0" + arrify: "npm:^2.0.1" + dot-prop: "npm:^6.0.0" + eventid: "npm:^2.0.0" + extend: "npm:^3.0.2" + gcp-metadata: "npm:^6.0.0" + google-auth-library: "npm:^9.0.0" + google-gax: "npm:^4.0.3" + long: "npm:^5.3.2" + on-finished: "npm:^2.3.0" + pumpify: "npm:^2.0.1" + stream-events: "npm:^1.0.5" + checksum: 10/2be25e5a8c5311e7fc66d7a0c4ad7c3f94ed27ed6161e103c0dbef7ef340035616578010e031e8f17509f6712a60dec0236ef835a7560c58cb6fa21f656adf39 + languageName: node + linkType: hard + "@google-cloud/paginator@npm:^5.0.0": version: 5.0.2 resolution: "@google-cloud/paginator@npm:5.0.2" @@ -257,13 +299,20 @@ __metadata: languageName: node linkType: hard -"@google-cloud/promisify@npm:<4.1.0": +"@google-cloud/promisify@npm:4.0.0, @google-cloud/promisify@npm:<4.1.0": version: 4.0.0 resolution: "@google-cloud/promisify@npm:4.0.0" checksum: 10/c5de81321b3a5c567edcbe0b941fb32644611147f3ba22f20575918c225a979988a99bc2ebda05ac914fa8714b0a54c69be72c3f46c7a64c3b19db7d7fba8d04 languageName: node linkType: hard +"@google-cloud/promisify@npm:^4.0.0": + version: 4.1.0 + resolution: "@google-cloud/promisify@npm:4.1.0" + checksum: 10/b933047c197e248c50428b17b5ac7ca8f711eb112a2e7a10af231ac00d1ff1d20325788556b07bc270a2406d165a198fd9ad1d114d7f11214e861b02077aaa0c + languageName: node + linkType: hard + "@google-cloud/promisify@npm:^5.0.0": version: 5.0.0 resolution: "@google-cloud/promisify@npm:5.0.0" @@ -346,6 +395,16 @@ __metadata: languageName: node linkType: hard +"@grpc/grpc-js@npm:^1.14.3": + version: 1.14.4 + resolution: "@grpc/grpc-js@npm:1.14.4" + dependencies: + "@grpc/proto-loader": "npm:^0.8.0" + "@js-sdsl/ordered-map": "npm:^4.4.2" + checksum: 10/f9cdbd81e7388dc784c57274fcf6f4f4484da8968dd0975b97a14708d3fb117ae4a7bc2848e1bd1cc8b8ed9ee7a80ff131bfe728c85260da90a4e0b170e31ca9 + languageName: node + linkType: hard + "@grpc/proto-loader@npm:^0.7.13": version: 0.7.15 resolution: "@grpc/proto-loader@npm:0.7.15" @@ -769,6 +828,13 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:^1.7.0": + version: 1.9.1 + resolution: "@opentelemetry/api@npm:1.9.1" + checksum: 10/b26032739d3c54ca99b5a2920844a1fbd4c3ee383cacbb0915e8c706a2626fe91e96feaa6e893397abe0545dc8d0a765b220aa18a31b1773176eeaf3a225e10e + languageName: node + linkType: hard + "@opentelemetry/core@npm:^1.30.1": version: 1.30.1 resolution: "@opentelemetry/core@npm:1.30.1" @@ -1366,7 +1432,7 @@ __metadata: languageName: node linkType: hard -"arrify@npm:^2.0.0": +"arrify@npm:^2.0.0, arrify@npm:^2.0.1": version: 2.0.1 resolution: "arrify@npm:2.0.1" checksum: 10/067c4c1afd182806a82e4c1cb8acee16ab8b5284fbca1ce29408e6e91281c36bb5b612f6ddfbd40a0f7a7e0c75bf2696eb94c027f6e328d6e9c52465c98e4209 @@ -2307,6 +2373,15 @@ __metadata: languageName: node linkType: hard +"dot-prop@npm:^6.0.0": + version: 6.0.1 + resolution: "dot-prop@npm:6.0.1" + dependencies: + is-obj: "npm:^2.0.0" + checksum: 10/1200a4f6f81151161b8526c37966d60738cf12619b0ed1f55be01bdb55790bf0a5cd1398b8f2c296dcc07d0a7c2dd0e650baf0b069c367e74bb5df2f6603aba0 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -2318,7 +2393,7 @@ __metadata: languageName: node linkType: hard -"duplexify@npm:^4.0.0, duplexify@npm:^4.1.3": +"duplexify@npm:^4.0.0, duplexify@npm:^4.1.1, duplexify@npm:^4.1.3": version: 4.1.3 resolution: "duplexify@npm:4.1.3" dependencies: @@ -2404,7 +2479,7 @@ __metadata: languageName: node linkType: hard -"end-of-stream@npm:^1.4.1": +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": version: 1.4.5 resolution: "end-of-stream@npm:1.4.5" dependencies: @@ -2546,6 +2621,15 @@ __metadata: languageName: node linkType: hard +"eventid@npm:^2.0.0": + version: 2.0.1 + resolution: "eventid@npm:2.0.1" + dependencies: + uuid: "npm:^8.0.0" + checksum: 10/951957a9b23653fe4e6f2318e1469b35fcb46eef55ba18a0e40762c7d0ee9a2759efab4359055e36230bc6be22e4dac97a9fbff1c3dd211d2cd9b0210ea3594c + languageName: node + linkType: hard + "events-listener@npm:^1.1.0": version: 1.1.0 resolution: "events-listener@npm:1.1.0" @@ -3142,6 +3226,7 @@ __metadata: version: 0.0.0-use.local resolution: "functions@workspace:." dependencies: + "@google-cloud/logging": "npm:^11.2.1" firebase-admin: "npm:^13.8.0" firebase-functions: "npm:^7.2.5" firebase-functions-test: "npm:^3.4.1" @@ -3210,7 +3295,7 @@ __metadata: languageName: node linkType: hard -"gcp-metadata@npm:^6.1.0": +"gcp-metadata@npm:^6.0.0, gcp-metadata@npm:^6.1.0": version: 6.1.1 resolution: "gcp-metadata@npm:6.1.1" dependencies: @@ -3390,7 +3475,7 @@ __metadata: languageName: node linkType: hard -"google-auth-library@npm:^9.11.0, google-auth-library@npm:^9.3.0, google-auth-library@npm:^9.6.3": +"google-auth-library@npm:^9.0.0, google-auth-library@npm:^9.11.0, google-auth-library@npm:^9.3.0, google-auth-library@npm:^9.6.3": version: 9.15.1 resolution: "google-auth-library@npm:9.15.1" dependencies: @@ -3404,7 +3489,7 @@ __metadata: languageName: node linkType: hard -"google-gax@npm:^4.3.3": +"google-gax@npm:^4.0.3, google-gax@npm:^4.3.3": version: 4.6.1 resolution: "google-gax@npm:4.6.1" dependencies: @@ -4402,7 +4487,7 @@ __metadata: languageName: node linkType: hard -"long@npm:^5.0.0": +"long@npm:^5.0.0, long@npm:^5.3.2": version: 5.3.2 resolution: "long@npm:5.3.2" checksum: 10/b6b55ddae56fcce2864d37119d6b02fe28f6dd6d9e44fd22705f86a9254b9321bd69e9ffe35263b4846d54aba197c64882adcb8c543f2383c1e41284b321ea64 @@ -5018,7 +5103,7 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:2.4.1, on-finished@npm:^2.2.0, on-finished@npm:^2.4.1": +"on-finished@npm:2.4.1, on-finished@npm:^2.2.0, on-finished@npm:^2.3.0, on-finished@npm:^2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" dependencies: @@ -5043,7 +5128,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.4.0": +"once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -5531,6 +5616,27 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49 + languageName: node + linkType: hard + +"pumpify@npm:^2.0.1": + version: 2.0.1 + resolution: "pumpify@npm:2.0.1" + dependencies: + duplexify: "npm:^4.1.1" + inherits: "npm:^2.0.3" + pump: "npm:^3.0.0" + checksum: 10/54bfdd04a30f459de5f1d1d022dc729e7257748900adf567a3b009f5aefe4a862ca91f3fb272f86c621eae631c4cc41f0efe5ee270752e2f9a90e7e63a9f8570 + languageName: node + linkType: hard + "pupa@npm:^2.1.1": version: 2.1.1 resolution: "pupa@npm:2.1.1" diff --git a/.github/workflows/scripts/resource-monitor.sh b/.github/workflows/scripts/resource-monitor.sh index 6e2881bb04..efb2b7d733 100644 --- a/.github/workflows/scripts/resource-monitor.sh +++ b/.github/workflows/scripts/resource-monitor.sh @@ -13,11 +13,27 @@ while true; do echo "--- load ---" uptime echo "--- top (pid pcpu pmem rss command) ---" - ps -arc www -o pid,pcpu,pmem,rss,etime,command 2>/dev/null | head -30 || true + top_ps="$(ps -arc www -o pid,pcpu,pmem,rss,etime,command 2>/dev/null | head -30 || true)" + if [[ -z "$(echo "$top_ps" | sed -n '2,$p' | grep -v '^[[:space:]]*$' | head -1)" ]]; then + echo "[resource-monitor] ps-empty falling back to ps aux + top -l 1" + ps aux 2>/dev/null | head -30 || true + top -l 1 -n 10 -stats pid,cpu,mem,command 2>/dev/null | head -25 || true + else + echo "$top_ps" + fi echo "--- e2e-related ---" - ps -arc www -o pid,pcpu,pmem,rss,etime,command 2>/dev/null \ + e2e_ps="$(ps -arc www -o pid,pcpu,pmem,rss,etime,command 2>/dev/null \ | grep -E 'testing|node |java |Simulator|Metro|simctl|log stream|screencapture' \ - | grep -v grep || true + | grep -v grep || true)" + if [[ -z "$e2e_ps" ]]; then + echo "[resource-monitor] ps-empty falling back to ps aux grep" + ps aux 2>/dev/null \ + | grep -E 'testing|node |java |Simulator|Metro|simctl|log stream|screencapture' \ + | grep -v grep \ + | head -20 || true + else + echo "$e2e_ps" + fi echo "" } >>"$LOG_FILE" 2>&1 sleep "$INTERVAL_SEC" diff --git a/.github/workflows/scripts/simulator-logging.sh b/.github/workflows/scripts/simulator-logging.sh new file mode 100755 index 0000000000..4c5d3e217a --- /dev/null +++ b/.github/workflows/scripts/simulator-logging.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Start or restart iOS simulator focused log stream and resource monitor (sourced by boot-simulator.sh). +set -uo pipefail + +restart_simulator_logging() { + local with_stdout="${RNFB_SIM_LOG_STDOUT:-0}" + local record_screens="${RNFB_RECORD_SCREENS:-0}" + local log_dir="${RNFB_SIM_LOG_DIR:-.}" + local sim_app_log="${log_dir}/sim-app.log" + local resource_log="${log_dir}/resource-monitor.log" + local repo_root="${RNFB_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" + local log_predicate='process == "testing" OR (process == "SpringBoard" AND eventMessage CONTAINS "invertase")' + + if ! xcrun simctl list devices booted 2>/dev/null | grep -q Booted; then + echo "[boot-status] phase=log_streams skipped=no_booted_simulator" + return 1 + fi + + echo "[boot-status] phase=log_streams_restart ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) stdout=${with_stdout} record_screens=${record_screens} dir=${log_dir}" + + pkill -f 'simctl spawn booted log stream' 2>/dev/null || true + pkill -f 'simctl io booted recordVideo' 2>/dev/null || true + pkill -f resource-monitor.sh 2>/dev/null || true + sleep 1 + + { + echo "" + echo "=== log stream restarted $(date -u +%Y-%m-%dT%H:%M:%SZ) predicate=${log_predicate} ===" + } >>"$sim_app_log" 2>/dev/null || : >"$sim_app_log" + + if [[ "$record_screens" == "1" ]]; then + if [[ "$with_stdout" == "1" ]]; then + xcrun simctl io booted recordVideo --codec=h264 -f "${log_dir}/simulator.mp4" 2>&1 & + else + nohup sh -c "xcrun simctl io booted recordVideo --codec=h264 -f '${log_dir}/simulator.mp4' 2>&1 &" >/dev/null 2>&1 & + fi + fi + + if [[ "$with_stdout" == "1" ]]; then + xcrun simctl spawn booted log stream --level debug --style compact \ + --predicate "$log_predicate" 2>&1 | tee -a "$sim_app_log" & + else + nohup sh -c "xcrun simctl spawn booted log stream --level debug --style compact --predicate '${log_predicate}' >>'${sim_app_log}' 2>&1 &" >/dev/null 2>&1 & + fi + + chmod +x "${repo_root}/.github/workflows/scripts/resource-monitor.sh" + RNFB_RESOURCE_MONITOR_LOG="$resource_log" nohup "${repo_root}/.github/workflows/scripts/resource-monitor.sh" >/dev/null 2>&1 & + + echo "[boot-status] phase=log_streams_started sim_app_log=${sim_app_log} video=$([[ "$record_screens" == "1" ]] && echo "${log_dir}/simulator.mp4" || echo disabled) monitor=${resource_log}" +} diff --git a/.github/workflows/scripts/wait-for-load-settle.sh b/.github/workflows/scripts/wait-for-load-settle.sh new file mode 100755 index 0000000000..d70f39802a --- /dev/null +++ b/.github/workflows/scripts/wait-for-load-settle.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Wait for macOS host load average to drop before starting Detox (experimental CI settle step). +set -euo pipefail + +MAX_WAIT_SEC="${RNFB_LOAD_SETTLE_MAX_WAIT_SEC:-1200}" +POLL_SEC="${RNFB_LOAD_SETTLE_POLL_SEC:-5}" +MAX_LOAD="${RNFB_LOAD_SETTLE_MAX_LOAD:-20}" + +parse_load1() { + # uptime: ... load averages: 1.23 4.56 7.89 + uptime 2>/dev/null | sed -n 's/.*load averages: \([0-9.]*\).*/\1/p' | head -1 +} + +elapsed=0 +echo "[load-settle] max_wait=${MAX_WAIT_SEC}s poll=${POLL_SEC}s threshold=${MAX_LOAD}" + +while (( elapsed <= MAX_WAIT_SEC )); do + load1="$(parse_load1)" + if [[ -z "$load1" ]]; then + echo "[load-settle] ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=${elapsed}s load1=unknown (could not parse uptime)" + else + echo "[load-settle] ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=${elapsed}s load1=${load1} threshold=${MAX_LOAD}" + if awk -v load="$load1" -v max="$MAX_LOAD" 'BEGIN { exit (load < max) ? 0 : 1 }'; then + echo "[load-settle] host load ${load1} < ${MAX_LOAD} after ${elapsed}s β€” proceeding" + exit 0 + fi + fi + + if (( elapsed >= MAX_WAIT_SEC )); then + break + fi + + sleep "$POLL_SEC" + elapsed=$((elapsed + POLL_SEC)) +done + +echo "[load-settle] WARN: timed out after ${MAX_WAIT_SEC}s with load1=${load1:-unknown} (threshold=${MAX_LOAD}) β€” proceeding anyway" +exit 0 diff --git a/.github/workflows/tests_e2e_ios.yml b/.github/workflows/tests_e2e_ios.yml index e3b568aa2f..b416f50275 100644 --- a/.github/workflows/tests_e2e_ios.yml +++ b/.github/workflows/tests_e2e_ios.yml @@ -1,6 +1,12 @@ name: Testing E2E iOS on: + workflow_call: + inputs: + record_screens: + description: 'Record host screencapture and simulator video artifacts' + type: boolean + default: false workflow_dispatch: inputs: iterations: @@ -8,6 +14,10 @@ on: required: true default: 1 type: number + record_screens: + description: 'Record host screencapture and simulator video artifacts' + type: boolean + default: false pull_request: branches: - '**' @@ -94,9 +104,10 @@ jobs: runs-on: macos-26 needs: matrix_prep # TODO matrix across APIs, at least 11 and 15 (lowest to highest) - timeout-minutes: 87 + timeout-minutes: 107 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + RNFB_RECORD_SCREENS: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.record_screens && '1' || '0' }} CCACHE_SLOPPINESS: clang_index_store,file_stat_matches,include_file_ctime,include_file_mtime,ivfsoverlay,pch_defines,modules,system_headers,time_macros CCACHE_FILECLONE: true CCACHE_DEPEND: true @@ -112,6 +123,7 @@ jobs: # https://github.com/guidepup/setup-action/releases uses: guidepup/setup-action@5ab89fbb6641406f6f6c91057225f3fb397c2eea # v0.20.0 continue-on-error: true + if: env.RNFB_RECORD_SCREENS == '1' with: record: true @@ -119,7 +131,7 @@ jobs: # https://github.com/actions/upload-artifact/releases uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 continue-on-error: true - if: always() + if: always() && env.RNFB_RECORD_SCREENS == '1' with: name: screenrecording-setup-${{ matrix.buildmode }}-${{ matrix.iteration }}.mov path: ./recordings/ @@ -327,11 +339,11 @@ jobs: curl --output /dev/null --silent --head --fail "http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false&inlineSourceMap=true" echo "...javascript bundle ready" - - name: Start Screen and System Logging + - name: Start host screen recording + if: env.RNFB_RECORD_SCREENS == '1' continue-on-error: true run: | nohup sh -c "sleep 314159265 | screencapture -v -C -k -T0 -g screenrecording.mov > screenrecording.log 2>&1 &" - nohup sh -c "log stream --backtrace --color none --style syslog > syslog.log 2>&1 &" - name: Pre-Boot Simulator # Separate Simulator boot from Detox run. boot-simulator.sh polls bootstatus and logs @@ -342,22 +354,24 @@ jobs: timeout_minutes: 12 retry_wait_seconds: 60 max_attempts: 3 - command: ./.github/workflows/scripts/boot-simulator.sh + command: RNFB_START_SIM_LOGS=0 ./.github/workflows/scripts/boot-simulator.sh + + - name: Start Simulator App Log + # One filtered sim log stream (testing + SpringBoard/invertase); optional sim video when record_screens. + continue-on-error: true + run: | + chmod +x ./.github/workflows/scripts/boot-simulator.sh + RNFB_RECORD_SCREENS="${RNFB_RECORD_SCREENS}" RNFB_SIM_BOOT_MODE=logs ./.github/workflows/scripts/boot-simulator.sh - - name: Start Simulator Recordings and Log - # Start after Pre-Boot so booted exists and logging covers the Detox run (not a fixed delay). + - name: Wait for host load to settle continue-on-error: true run: | - nohup sh -c "xcrun simctl io booted recordVideo --codec=h264 -f simulator.mp4 2>&1 &" - nohup sh -c "xcrun simctl spawn booted log stream --level debug --style compact > simulator.log 2>&1 &" - nohup sh -c "xcrun simctl spawn booted log stream --level debug --style compact --predicate 'process == \"testing\"' > testing.log 2>&1 &" - nohup sh -c "xcrun simctl spawn booted log stream --level debug --style compact --predicate 'process == \"SpringBoard\" AND eventMessage CONTAINS \"invertase\"' > springboard-invertase.log 2>&1 &" - chmod +x ./.github/workflows/scripts/resource-monitor.sh - nohup ./.github/workflows/scripts/resource-monitor.sh & + chmod +x ./.github/workflows/scripts/wait-for-load-settle.sh + ./.github/workflows/scripts/wait-for-load-settle.sh - name: Detox Test Debug if: contains(matrix.buildmode, 'debug') - timeout-minutes: 62 + timeout-minutes: 82 run: | yarn tests:ios:test-cover 2>&1 | tee detox-step.log exit ${PIPESTATUS[0]} @@ -369,7 +383,7 @@ jobs: - name: Detox Test Release if: contains(matrix.buildmode, 'release') - timeout-minutes: 62 + timeout-minutes: 82 run: | yarn tests:ios:test:release 2>&1 | tee detox-step.log exit ${PIPESTATUS[0]} @@ -386,43 +400,27 @@ jobs: continue-on-error: true run: | killall -int simctl - killall -int screencapture - killall -int log + if [[ "${RNFB_RECORD_SCREENS}" == "1" ]]; then + killall -int screencapture + fi pkill -f resource-monitor.sh || true - name: Upload App Video # https://github.com/actions/upload-artifact/releases uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 continue-on-error: true - if: always() + if: always() && env.RNFB_RECORD_SCREENS == '1' with: name: simulator-${{ matrix.buildmode }}-${{ matrix.iteration }}_video path: simulator.mp4 - - name: Upload Simulator Log - # https://github.com/actions/upload-artifact/releases + - name: Upload Simulator App Log uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 continue-on-error: true if: always() with: - name: simulator-${{ matrix.buildmode }}-${{ matrix.iteration }}_log - path: simulator.log - - - name: Upload testing process log - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - continue-on-error: true - if: always() - with: - name: testing-${{ matrix.buildmode }}-${{ matrix.iteration }}_log - path: testing.log - - - name: Upload SpringBoard invertase log - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - continue-on-error: true - if: always() - with: - name: springboard-${{ matrix.buildmode }}-${{ matrix.iteration }}_log - path: springboard-invertase.log + name: sim-app-${{ matrix.buildmode }}-${{ matrix.iteration }}_log + path: sim-app.log - name: Upload resource monitor log uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -461,7 +459,7 @@ jobs: # https://github.com/actions/upload-artifact/releases uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 continue-on-error: true - if: always() + if: always() && env.RNFB_RECORD_SCREENS == '1' with: name: screenrecording-${{ matrix.buildmode }}-${{ matrix.iteration }} path: screenrecording.* diff --git a/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch b/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch index 051c258298..2b049813c0 100644 --- a/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch +++ b/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch @@ -1,17 +1,29 @@ diff --git a/lib/commonjs/cli.js b/lib/commonjs/cli.js -index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d85692ad56 100644 +index 35c04da87c63a79bc26707aaffe3156fb98eca79..3ad2cea4c214e8c3c4f4698acacfff2610902863 100644 --- a/lib/commonjs/cli.js +++ b/lib/commonjs/cli.js -@@ -72,22 +72,64 @@ function cleanup() { +@@ -71,23 +71,77 @@ function cleanup() { + console.log(`[πŸŸ₯] Failed to run a cleanup task: ${err.message}`); }); } - async function startServer(server, config, after) { +-async function startServer(server, config, after) { ++async function startServer(server, config, after, runControl) { + let reconnectGraceTimer = null; + let pendingDisconnectCode = null; + let disconnectStartedAt = 0; ++ let orchestratePhase = 'jet-starting'; ++ let lastTestTitle = ''; ++ let lastSuiteTitle = ''; ++ let coverageUploadInProgress = false; server.on('started', s => { const url = s.url; console.log(`[🟩] Jet remote server listening at "${url}".`); ++ startControlHttpServer(config, { ++ setOrchestratePhase: phase => { ++ orchestratePhase = phase || orchestratePhase; ++ }, ++ onLaunchReady: () => runControl?.beginRun?.(), ++ }); }); server.on('connection', (_, req) => { console.log(`[🟩] Jet client connected from "${req.socket.remoteAddress + ':' + req.socket.remotePort}".`); @@ -34,12 +46,14 @@ index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d8 - cleanup(); + try { + const os = require('os'); ++ const suite = lastSuiteTitle.replace(/"/g, "'"); ++ const test = lastTestTitle.replace(/"/g, "'"); + console.warn( -+ `[jet-ws] disconnect_context code=${code} loadavg=${os.loadavg().join(',')} mem=${JSON.stringify(process.memoryUsage())}`, ++ `[jet-ws] disconnect_context code=${code} loadavg=${os.loadavg().join(',')} orchestratePhase=${orchestratePhase} currentTest="${test}" currentSuite="${suite}" coverageUpload=${coverageUploadInProgress} mem=${JSON.stringify(process.memoryUsage())}`, + ); + } catch (_) { + // Best-effort diagnostics only. - } ++ } + if (code === 1000 || !config.exitOnError) { + return; + } @@ -63,7 +77,7 @@ index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d8 + cleanup(); + }, graceMs); + return; -+ } + } + console.error(`[jet-ws] fatal_disconnect code=${code} reason="${reason || ''}" immediate=true`); + print(`[πŸŸ₯] Exiting after an abnormal disconnect.`); + process.exitCode = 1; @@ -71,22 +85,33 @@ index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d8 }); server.on('error', error => { if (error instanceof _mochaRemoteServer.ClientError) { -@@ -100,6 +142,14 @@ async function startServer(server, config, after) { +@@ -100,6 +154,25 @@ async function startServer(server, config, after) { cleanup(); } }); ++ server.on('coverage-upload-start', () => { ++ coverageUploadInProgress = true; ++ }); + server.on('coverage-data', coverage => { ++ coverageUploadInProgress = false; + const incomingKeys = Object.keys(coverage).length; + console.log(`[jet-coverage] WS received ${incomingKeys} file(s)`); + coverageMap.merge(coverage); + global.__coverage__ = coverageMap.toJSON(); + const totalKeys = Object.keys(global.__coverage__).length; + console.log(`[jet-coverage] WS merged total ${totalKeys} file(s)`); ++ }); ++ server.on('running', runner => { ++ const constants = require('mocha').Runner.constants; ++ runner.on(constants.EVENT_TEST_BEGIN, test => { ++ lastTestTitle = test?.title || ''; ++ lastSuiteTitle = test?.parent?.fullTitle?.() || test?.parent?.title || ''; ++ }); + }); cleanupTasks.add(async () => { if (server.listening) { await server.stop(); -@@ -115,41 +165,6 @@ async function startServer(server, config, after) { +@@ -115,39 +188,48 @@ async function startServer(server, config, after) { process.on('SIGINT', cleanup); process.on('exit', cleanup); } @@ -98,12 +123,25 @@ index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d8 - } - server.on('request', (req, res) => { - if (req.url === '/coverage' && req.method === 'POST') { -- let body = ''; -- req.on('data', chunk => { -- body += chunk.toString(); -- }); -- req.on('end', () => { -- try { ++function startControlHttpServer(config, handlers) { ++ const http = require('http'); ++ const controlPort = parseInt(process.env.RNFB_JET_CONTROL_PORT || String(config.port + 1), 10); ++ const server = http.createServer((req, res) => { ++ const url = req.url?.split('?')[0]; ++ if (url === '/launch-ready' && req.method === 'POST') { ++ console.log('[jet-control] launch-ready received'); ++ handlers.onLaunchReady?.(); ++ res.writeHead(200, { 'Content-Type': 'text/plain' }); ++ res.end('ok'); ++ return; ++ } ++ if (url === '/orchestrate-state' && req.method === 'POST') { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', () => { + try { - coverageMap.merge(JSON.parse(body)); - global.__coverage__ = coverageMap.toJSON(); - res.end(JSON.stringify({ @@ -113,8 +151,17 @@ index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d8 - res.end(JSON.stringify({ - error: 'Invalid JSON' - })); -- } -- }); ++ const parsed = JSON.parse(body || '{}'); ++ if (parsed.phase) { ++ handlers.setOrchestratePhase?.(parsed.phase); ++ console.log(`[jet-control] orchestrate-state=${parsed.phase}`); ++ } ++ } catch (_) { ++ // Best-effort only. + } ++ res.writeHead(200, { 'Content-Type': 'text/plain' }); ++ res.end('ok'); + }); - } else { - res.writeHead(404, { - 'Content-Type': 'application/json' @@ -122,27 +169,67 @@ index 35c04da87c63a79bc26707aaffe3156fb98eca79..cc9243fabdc014bc945dd6e85cbe55d8 - res.end(JSON.stringify({ - error: 'Not Found' - })); -- } -- }); --} ++ return; + } ++ res.writeHead(404, { 'Content-Type': 'application/json' }); ++ res.end(JSON.stringify({ error: 'Not Found' })); ++ }); ++ server.listen(controlPort, config.hostname, () => { ++ console.log(`[jet-control] listening on http://${config.hostname}:${controlPort}`); ++ }); ++ cleanupTasks.add(async () => { ++ await new Promise(resolve => { ++ server.close(() => resolve()); ++ }); + }); + } (0, _yargs.default)((0, _helpers.hideBin)(process.argv)).scriptName('jet').version('version', 'Show version number & exit', jetPackageJson.version).alias('v', 'version').option('target', { - description: 'The name of the test target to run as defined in your "jet" package.json config.', - type: 'string', -@@ -302,12 +317,11 @@ function attachHttpServer(wss) { +@@ -301,17 +383,34 @@ function attachHttpServer(wss) { + timeout: finalConfig.timeout, slow: finalConfig.slow }); - return startServer(server, finalConfig, target.after).then(() => { +- return startServer(server, finalConfig, target.after).then(() => { - if (finalConfig.coverage) { - attachHttpServer(server.wss); - } - if (!finalConfig.watch) { - server.run(async failures => { - global.__coverage__ = coverageMap.toJSON(); -+ const mergedKeys = Object.keys(global.__coverage__).length; -+ console.log(`[jet-coverage] merged ${mergedKeys} file(s) before NYC shutdown`); - await cleanup(); - process.exit(failures > 0 ? 1 : 0); - }); +- if (!finalConfig.watch) { +- server.run(async failures => { +- global.__coverage__ = coverageMap.toJSON(); +- await cleanup(); +- process.exit(failures > 0 ? 1 : 0); +- }); ++ const deferRun = ++ process.env.RNFB_JET_DEFER_RUN === '1' || process.env.RNFB_JET_DEFER_RUN === 'true'; ++ const runControl = { ++ beginRun: null, ++ }; ++ return startServer(server, finalConfig, target.after, runControl).then(() => { ++ let runStarted = false; ++ const beginRun = () => { ++ if (runStarted) { ++ return; ++ } ++ runStarted = true; ++ if (!finalConfig.watch) { ++ server.run(async failures => { ++ global.__coverage__ = coverageMap.toJSON(); ++ const mergedKeys = Object.keys(global.__coverage__).length; ++ console.log(`[jet-coverage] merged ${mergedKeys} file(s) before NYC shutdown`); ++ await cleanup(); ++ process.exit(failures > 0 ? 1 : 0); ++ }); ++ } ++ }; ++ runControl.beginRun = beginRun; ++ if (deferRun) { ++ console.log('[jet-control] deferring server.run until POST /launch-ready'); ++ return; + } ++ beginRun(); + }); + }).help(true).alias('h', 'help').argv; + //# sourceMappingURL=cli.js.map +\ No newline at end of file diff --git a/lib/commonjs/index.js b/lib/commonjs/index.js index a72380ed2ace01b19b028bb8e6f9d29dc3affbcd..030475cff75349b2b7f820cf8a3556fb1d7bea45 100644 --- a/lib/commonjs/index.js diff --git a/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch b/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch index fd88710dd3..f688492301 100644 --- a/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch +++ b/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch @@ -1,5 +1,5 @@ diff --git a/dist/Server.js b/dist/Server.js -index ad9debe2086ab9b96e97a69aec966da8114ad102..51cc18658e11d5aec5d5a76c4eb5f965b32a587d 100644 +index ad9debe2086ab9b96e97a69aec966da8114ad102..93c3f65524d4fa86ecef7045840ed5fc333f66e5 100644 --- a/dist/Server.js +++ b/dist/Server.js @@ -73,38 +73,59 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { @@ -108,12 +108,13 @@ index ad9debe2086ab9b96e97a69aec966da8114ad102..51cc18658e11d5aec5d5a76c4eb5f965 if (typeof msg.action !== "string") { throw new Error("Expected message to have an action property"); } -@@ -130,6 +172,18 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { +@@ -130,6 +172,19 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { throw new Error("Received a message from the client, but server wasn't running"); } } + else if (msg.action === "coverage-ready") { + console.warn(`[jet-coverage] server recv coverage-ready`); ++ this.emit("coverage-upload-start"); + this.send({ action: "pull-coverage" }); + } + else if (msg.action === "coverage-data") { @@ -127,7 +128,7 @@ index ad9debe2086ab9b96e97a69aec966da8114ad102..51cc18658e11d5aec5d5a76c4eb5f965 else if (msg.action === "error") { if (typeof msg.message !== "string") { throw new Error("Expected 'error' action to have an error argument with a message"); -@@ -152,10 +206,45 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { +@@ -152,10 +207,45 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { } } }; @@ -173,7 +174,7 @@ index ad9debe2086ab9b96e97a69aec966da8114ad102..51cc18658e11d5aec5d5a76c4eb5f965 // Forget everything about the runner and the client const { runner, client } = this; delete this.runner; -@@ -263,6 +352,7 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { +@@ -263,6 +353,7 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { // this.runner = new Mocha.Runner(this.suite, this.options.delay || false); // TODO: Stub this to match the Runner's interface even better this.runner = new FakeRunner_1.FakeRunner(); @@ -181,7 +182,7 @@ index ad9debe2086ab9b96e97a69aec966da8114ad102..51cc18658e11d5aec5d5a76c4eb5f965 // Attach event listeners to update stats (0, stats_collector_1.createStatsCollector)(this.runner); // Set the client options, to be passed to the next running client -@@ -370,7 +460,8 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { +@@ -370,7 +461,8 @@ class Server extends ServerEventEmitter_1.ServerEventEmitter { this.client.send(data); } else { diff --git a/okf-bundle/ci-workflows/android.md b/okf-bundle/ci-workflows/android.md index 728b898e16..e38f038b67 100644 --- a/okf-bundle/ci-workflows/android.md +++ b/okf-bundle/ci-workflows/android.md @@ -57,6 +57,7 @@ Under load, Jet may run only a small prefix before mocha-remote desync, often af | Change | Location | |--------|----------| +| Defer `server.run()` until host `POST /launch-ready` on control port **8091** (`RNFB_JET_DEFER_RUN=1`) | Jet patch + `firebase.test.js` β€” [Jet host orchestration](../testing/running-e2e.md#jet-host-orchestration-ports-and-launch-gate) | | Inbound parse buffer + `tryDeserialize` in `serialization.js` / `parse_skip` logging | mocha-remote-server patch | | Outbound queue flushed on reconnect | mocha-remote-client patch | | `JET_PROTOCOL_ERROR_RE` β†’ retryable Jet session (attempt 2) | `tests/e2e/firebase.test.js` | @@ -74,3 +75,4 @@ Under load, Jet may run only a small prefix before mocha-remote desync, often af | `adb reverse --remove` in Detox logs | Expected on 1006; should be warn-only after Detox patch | | Detox red, tests green in log | Pre-patch: teardown adb error; re-run or check patch applied | | `codecov/project/android-native` fail | Jacoco XML not uploaded β€” check post-e2e logs and Codecov Uploads tab for `android-native` flag | +| FIS 503 / `Too many server requests` / RC cascade | Live cloud quota (shared project) β€” not Android-specific; see [cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure) | diff --git a/okf-bundle/ci-workflows/detox-patches.md b/okf-bundle/ci-workflows/detox-patches.md index 72d3d27b0d..09ea994e95 100644 --- a/okf-bundle/ci-workflows/detox-patches.md +++ b/okf-bundle/ci-workflows/detox-patches.md @@ -17,7 +17,7 @@ Patches are in-repo. Prefer direct patch-file edits or headless workflow; `yarn | Ignore missing adb reverse on teardown | `ADB.js` | Android | Jet WS 1006 triggers mid-run `reverse --remove` β†’ adb exit 1 | | **2Γ— device-registry lock stale** | `ExclusiveLockfile.js` | iOS, macOS, Android | `proper-lockfile` `ECOMPROMISED` before tests start | -Related patches: `jet`, `mocha-remote-client`, `mocha-remote-server` β€” WS reconnect grace, coverage handshake, client keepalive, parse buffering, reconnect assignment order; HTTP POST `/coverage` removed. See [coverage design](../testing/coverage-design.md), [iOS issues 6–8](ios.md#6-jet-websocket-disconnect-1006--1001). +Related patches: `jet`, `mocha-remote-client`, `mocha-remote-server` β€” WS reconnect grace, coverage handshake, client keepalive, parse buffering, reconnect assignment order; **host control HTTP on `JET_REMOTE_PORT + 1` (default 8091)** for launch defer + orchestrate phase (`RNFB_JET_DEFER_RUN`); HTTP POST `/coverage` on 8090 removed. See [Jet host orchestration](../testing/running-e2e.md#jet-host-orchestration-ports-and-launch-gate), [coverage design](../testing/coverage-design.md), [iOS issues 6–8](ios.md#6-jet-websocket-disconnect-1006--1001). ## Updating the jet patch (headless) @@ -35,7 +35,7 @@ yarn patch-commit -s "$PATCH_DIR" 3. `yarn install` from repo root β€” confirm updated `yarn.lock` patch hash and applied files in `tests/node_modules/jet/`. -**Touch list:** `lib/commonjs/cli.js` (server: WS `coverage-data`, reconnect grace; **no** `attachHttpServer`), `lib/commonjs/index.js` + `src/index.tsx` (client: `client.uploadCoverage()`). Metro resolves Jet via `"react-native": "src/index"` β€” patching `lib/` alone does not fix macOS bundles. +**Touch list:** `lib/commonjs/cli.js` (server: WS `coverage-data`, reconnect grace, **`startControlHttpServer` on port `JET_REMOTE_PORT + 1`**, defer `server.run()` until `POST /launch-ready` when `RNFB_JET_DEFER_RUN=1`; **no** control HTTP on the 8090 WebSocket stack), `lib/commonjs/index.js` + `src/index.tsx` (client: `client.uploadCoverage()`). Metro resolves Jet via `"react-native": "src/index"` β€” patching `lib/` alone does not fix macOS bundles. ## Device registry lock (`ECOMPROMISED`) diff --git a/okf-bundle/ci-workflows/index.md b/okf-bundle/ci-workflows/index.md index c5287e512e..338fbef17e 100644 --- a/okf-bundle/ci-workflows/index.md +++ b/okf-bundle/ci-workflows/index.md @@ -4,14 +4,16 @@ GitHub Actions job shape, platform reliability, and artifact triage. ## Platforms -* [iOS](ios.md) β€” simulator boot, logging, troubleshooting +* [iOS](ios.md) β€” simulator boot, logging, troubleshooting, [CI baseload policy](ios.md#ci-baseload-policy-instrumentation) * [Android](android.md) β€” idling, adb teardown, native coverage * [Other](other.md) β€” macOS Jet/Metro, Windows/shared ## Shared E2E dependencies +* [Jet host orchestration](../testing/running-e2e.md#jet-host-orchestration-ports-and-launch-gate) β€” ports 8090/8091, defer-run launch gate (canonical) * [Detox patches](detox-patches.md) β€” inventory, `ECOMPROMISED`, patch workflow -* [iOS diagnostics](ios.md#operational-notes) β€” `resource-monitor.sh`, `flake-summary.sh` +* [Cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure) β€” live FIS/RC pressure (all platforms) +* [iOS diagnostics](ios.md#operational-notes) β€” `resource-monitor.sh`, `flake-summary.sh`, [CI baseload / load settle](ios.md#ci-baseload-policy-instrumentation) ## Related diff --git a/okf-bundle/ci-workflows/ios.md b/okf-bundle/ci-workflows/ios.md index 50a992df4a..5272ab0a34 100644 --- a/okf-bundle/ci-workflows/ios.md +++ b/okf-bundle/ci-workflows/ios.md @@ -16,7 +16,7 @@ On GHA macOS runners, `simctl list` can show `Booted` before the simulator is te **Pre-boot step** (`.github/workflows/scripts/boot-simulator.sh`), run via `nick-fields/retry` before Detox: -> **Not for local operators.** `boot-simulator.sh` is **CI-only** (this workflow) or invoked **internally** by `tests/e2e/firebase.test.js` on iOS Jet-level retry. Local e2e uses only [running-e2e.md](../testing/running-e2e.md) β€” Detox boots the simulator via `yarn tests:ios:test-cover`; do not run `boot-simulator.sh` as operator prep. +> **Not for local operators.** `boot-simulator.sh` is **CI-only** (this workflow) or invoked **internally** by `tests/e2e/firebase.test.js` on iOS Jet-level retry. Local e2e uses only [running-e2e.md](../testing/running-e2e.md) β€” [host-clear probes](../testing/running-e2e.md#host-clear-probes) require zero booted simulators; Detox boots `iPhone 17` via `yarn tests:ios:test-cover`. Do not run `boot-simulator.sh` as operator prep. | Phase | What happens | |--------|----------------| @@ -51,42 +51,97 @@ Artifacts upload on every run (`if: always()`). | Artifact | Source | Use when | |----------|--------|----------| -| `simulator--_log` | `xcrun simctl spawn booted log stream` β†’ `simulator.log` | In-simulator system/app logs during Detox | -| `testing--_log` | `log stream --predicate 'process == "testing"'` β†’ `testing.log` | **Filtered** app-process log β€” much smaller than `simulator.log`; use for `[rnfb-lifecycle]`, Detox WS, Metro probes | -| `springboard--_log` | `log stream` with SpringBoard + `invertase` predicate β†’ `springboard-invertase.log` | FrontBoard / LaunchServices launch failures (`unknown to FrontBoard`, install races) | +| `sim-app--_log` | One filtered `log stream` β†’ `sim-app.log` (`testing` + SpringBoard/invertase) | `[rnfb-lifecycle]`, Detox WS, Metro probes, FrontBoard launch failures | | `detox-step--_log` | Detox/Jet step stdout/stderr (`tee detox-step.log`) | `[jet-ws]`, `[rnfb-e2e]`, `[jet-coverage]`, Jest output β€” primary orchestration log | -| `flake-summary--` | `.github/workflows/scripts/flake-summary.sh` β†’ `flake-summary.txt` | Pre-digested `rg` hits across detox/simulator/testing/springboard/metro/resource-monitor logs | +| `flake-summary--` | `.github/workflows/scripts/flake-summary.sh` β†’ `flake-summary.txt` | Pre-digested `rg` hits across detox/sim-app/metro/resource-monitor logs | | `resource-monitor--_log` | `.github/workflows/scripts/resource-monitor.sh` β†’ `resource-monitor.log` | Periodic `uptime` + `ps` snapshots (10s default) to correlate WS drops with CPU/memory pressure | | `metro--_log` | Metro stdout/stderr from `yarn tests:packager:jet-ci` β†’ `metro.log` (debug only) | Metro hung, slow bundle, or unresponsive `/status` during app launch | -| `simulator--_video` | `xcrun simctl io booted recordVideo` β†’ `simulator.mp4` | Visual confirmation of UI state | -| `screenrecording--` | `screencapture` of the Mac desktop | Includes Simulator.app window | -| `screenrecording-setup--.mov` | Guidepup setup recording | Very early environment setup | +| `simulator--_video` | `simctl recordVideo` β†’ `simulator.mp4` | Visual confirmation (**`workflow_dispatch` / `workflow_call` `record_screens: true` only**) | +| `screenrecording--` | `screencapture` of the Mac desktop | Includes Simulator.app window (**`record_screens: true` only**) | +| `screenrecording-setup--.mov` | Guidepup setup recording | Very early environment setup (**`record_screens: true` only**) | | `emulator-scripts-logs--` | `.github/workflows/scripts/*.log` | Script output if redirected | +Video artifacts upload only when **`record_screens: true`** (see [CI baseload policy](#ci-baseload-policy-instrumentation) below). + +### CI baseload policy (instrumentation) + +**Why** β€” macOS GHA runners showed `loadavg` in the hundreds during iOS e2e. Unfiltered simulator debug log streams (~1M+ lines/run before Detox), host syslog with `--backtrace`, dual video capture (host + sim), and three separate log streams kept baseline CPU/disk load high. A pre-Detox gate at load **10** never cleared within the wait budget. + +**What changed** β€” shrink always-on instrumentation; gate load immediately before Jet/Detox orchestration at a realistic threshold (**20**). + +**Workflow order** (`.github/workflows/tests_e2e_ios.yml`, before Detox): + +| Step | Script / action | Notes | +|------|-----------------|-------| +| Pre-Boot Simulator | `RNFB_START_SIM_LOGS=0 ./boot-simulator.sh` | Boot + install only; no log streams | +| Start Simulator App Log | `RNFB_SIM_BOOT_MODE=logs ./boot-simulator.sh` | One filtered stream + `resource-monitor.sh`; sim video only if `RNFB_RECORD_SCREENS=1` | +| Wait for host load to settle | `wait-for-load-settle.sh` | Poll until `load1 < 20` or 1200s timeout; `continue-on-error: true` | +| Detox Test Debug / Release | `yarn tests:ios:test-cover` / `:release` | Jet orchestration begins here | + +**Removed (high I/O / never triaged from artifacts)** + +| Was | Why removed | +|-----|-------------| +| Host `log stream --backtrace` β†’ `syslog.log` | Whole-machine syslog; heavy under Spotlight/indexing | +| Unfiltered `simctl log stream` β†’ `simulator.log` | Millions of debug lines; triage uses filtered app/SpringBoard lines only | +| Separate `testing.log` + `springboard-invertase.log` | Merged into one stream (below) | + +**Single sim log** β€” `simulator-logging.sh` writes **`sim-app.log`** via one predicate: + +```text +process == "testing" OR (process == "SpringBoard" AND eventMessage CONTAINS "invertase") +``` + +Covers `[rnfb-lifecycle]`, Detox/Metro app lines, and FrontBoard/LaunchServices invertase failures. + +**Optional video β€” `record_screens` input** β€” on `workflow_dispatch` and `workflow_call` only; default **`false`**. PR/push runs leave it off. + +| When `record_screens: true` | When false (default) | +|-----------------------------|-------------------------| +| Guidepup setup recording | Skipped | +| Host `screencapture` β†’ `screenrecording.*` | Skipped | +| `simctl recordVideo` β†’ `simulator.mp4` | Skipped | + +Set explicitly for UI regressions: `gh workflow run tests_e2e_ios.yml -f record_screens=true`. + +**Load settle env** (`wait-for-load-settle.sh`): + +| Variable | Default | Role | +|----------|---------|------| +| `RNFB_LOAD_SETTLE_MAX_LOAD` | **20** | Proceed when 1-min loadavg drops below this | +| `RNFB_LOAD_SETTLE_MAX_WAIT_SEC` | 1200 | Stop waiting and proceed anyway | +| `RNFB_LOAD_SETTLE_POLL_SEC` | 5 | Poll interval | + +Log markers: `[load-settle] ts=… elapsed=… load1=… threshold=20` in the workflow step; copied into `flake-summary.txt` when present. + +**Triage** β€” Debug legs often plateau higher than release (Metro still running). Saturated runners may never reach 20; treat `[load-settle] WARN: timed out` as informational β€” Detox still runs. Correlate flakes with `resource-monitor-*_log` and `[jet-ws] disconnect_context loadavg=…` rather than expecting idle hosts. + **When to use which log** -- **Boot / migration / β€œsimulator won’t start”** β€” read the **Pre-Boot Simulator** step log in GitHub Actions first. Look for `[boot-status]` lines and `bootstatus -d` migration output. That captures first-boot migration even though `simulator.log` starts only after pre-boot succeeds. -- **Detox / app / test failures** β€” start with **`detox-step-*_log`** or **`flake-summary-*`** (fast triage). For native app instrumentation, download **`testing-*_log`** instead of searching all of `simulator.log`. Jet WS drops (1006/1001) appear in the Detox step (`[jet-ws]`, `[rnfb-e2e]`). For debug Metro issues, also download `metro-*_log`. -- **FrontBoard / relaunch after terminate** β€” `springboard-*_log` plus `[rnfb-e2e] install-state` / `launchApp failure` lines in `detox-step-*_log`. +- **Boot / migration / β€œsimulator won’t start”** β€” read the **Pre-Boot Simulator** step log in GitHub Actions first. Look for `[boot-status]` lines and `bootstatus -d` migration output. +- **Detox / app / test failures** β€” start with **`detox-step-*_log`** or **`flake-summary-*`** (fast triage). For native app instrumentation, download **`sim-app-*_log`**. Jet WS drops (1006/1001) appear in the Detox step (`[jet-ws]`, `[rnfb-e2e]`). For debug Metro issues, also download `metro-*_log`. +- **FrontBoard / relaunch after terminate** β€” `sim-app-*_log` plus `[rnfb-e2e] install-state` / `launchApp failure` lines in `detox-step-*_log`. - **Runner load during flake** β€” `resource-monitor-*_log` (correlate timestamps with `[jet-ws] disconnect_context` loadavg/mem lines). -- **UI regressions** β€” `simulator-*_video` or `screenrecording-*`. +- **UI regressions** β€” re-run with `record_screens: true` β†’ `simulator-*_video` or `screenrecording-*`. **Downloading artifacts** Workflow page **Artifacts**, or: ```bash -gh run download -n simulator-debug-0_log +gh run download -n sim-app-debug-0_log ``` -**Analyzing `simulator.log`** +**Analyzing `sim-app.log`** Useful patterns: ```bash -rg -i "datamigrator|Telemetry: duration|systemShellWillBootstrap" simulator.log -rg -i "com\.invertase\.testing|installcoordination" simulator.log -rg -i "test daemon not ready|xctest" simulator.log +rg 'testing\[' sim-app.log | rg -i 'waitForActive|waitForActiveDone|com\.wix\.Detox|ready action too early' +rg '\[rnfb-lifecycle\]' sim-app.log +rg '\[rnfb-lifecycle\].*RCTJavaScriptDidFailToLoad|packager-probe|packager-status-fetch' sim-app.log +rg 'No script URL provided|com\.facebook\.react\.log' sim-app.log +rg -i 'invertase|FrontBoard|unknown' sim-app.log ``` Long `datamigrator` activity with no `com.invertase.testing` means migration/pre-boot install not done. @@ -140,6 +195,8 @@ Error: Unable to update lock within the stale threshold | Change | Location | |--------|----------| | Wait for Jet (port 8090) before `launchApp` | `tests/e2e/firebase.test.js` | +| Defer `server.run()` until host `POST /launch-ready` on control port **8091** (`RNFB_JET_DEFER_RUN=1`) | Jet patch + `firebase.test.js` β€” [Jet host orchestration](../testing/running-e2e.md#jet-host-orchestration-ports-and-launch-gate) | +| `POST /orchestrate-state` + `[rnfb-e2e] orchestrate-state=` for launch/retry triage | `firebase.test.js` + Jet patch | | Wait for Metro (`/status`) before `launchApp` | `tests/e2e/firebase.test.js` (debug Detox configs only; release skips Metro wait) | | Bounded `launchApp` timeout (debug default 180s; release default 120s via `RNFB_LAUNCH_APP_RELEASE_TIMEOUT_MS`) | `tests/e2e/firebase.test.js` | | `detoxEnableSynchronization: 'NO'` at launch | `tests/e2e/firebase.test.js` | @@ -188,33 +245,33 @@ Metro can look healthy on the **host** during pre-fetch minutes earlier while be **Known intermittent pattern (community)** β€” RN iOS debug builds commonly hit `No script URL provided` when Metro is down, slow, or reachable from the host but not from the simulator process. Reported causes include: Metro event loop blocked under load (TCP connect succeeds, HTTP hangs β€” matches our `-1001` on `/status`), macOS network proxy intercepting `localhost:8081`, port 8081 contention, and `RCTBundleURLProvider` returning a nil bundle URL despite `isPackagerRunning=YES` ([react-native#49173](https://github.com/facebook/react-native/issues/49173)). On **iOS 26+ simulators**, hostname resolution for `localhost` vs `127.0.0.1` can be unreliable; hardcoding `127.0.0.1` in bundle URL resolution has been reported to fix intermittent Metro disconnects ([react-native#56447](https://github.com/facebook/react-native/issues/56447)). Our test app now pins the debug bundle URL to `127.0.0.1:8081` and the e2e harness retries `launchApp` on retryable launch failures (Metro/bundle timeout, FrontBoard errors) before failing the Jet attempt. Release uses an embedded bundle, shorter launch timeout, and no Metro wait. These are typically **environment/timing** flakes rather than app logic bugs. -#### Diagnosing from `simulator.log` +#### Diagnosing from `sim-app.log` -Download the artifact (`gh run download -n simulator-debug-0_log`), unzip if needed, then search `simulator.log`. +Download the artifact (`gh run download -n sim-app-debug-0_log`), unzip if needed, then search `sim-app.log`. **Quick triage** β€” map Detox step timestamps to simulator log (`testing[]`): ```bash # Detox orchestration inside the app process -rg 'testing\[' simulator.log | rg -i 'waitForActive|waitForActiveDone|com\.wix\.Detox|ready action too early' +rg 'testing\[' sim-app.log | rg -i 'waitForActive|waitForActiveDone|com\.wix\.Detox|ready action too early' # App lifecycle confirmation (AppDelegate instrumentation) -rg '\[rnfb-lifecycle\]' simulator.log +rg '\[rnfb-lifecycle\]' sim-app.log # Metro / JS bundle load failure (issue 5) -rg '\[rnfb-lifecycle\].*RCTJavaScriptDidFailToLoad|packager-probe|packager-status-fetch' simulator.log -rg 'No script URL provided|com\.facebook\.react\.log' simulator.log -rg 'testing\[' simulator.log | rg '8081/status|index\.bundle' +rg '\[rnfb-lifecycle\].*RCTJavaScriptDidFailToLoad|packager-probe|packager-status-fetch' sim-app.log +rg 'No script URL provided|com\.facebook\.react\.log' sim-app.log +rg 'testing\[' sim-app.log | rg '8081/status|index\.bundle' # SpringBoard launch intent vs app scene state -rg 'com\.invertase\.testing' simulator.log | rg -i 'foreground-interactive|visibility.*Foreground|running-active' -rg 'testing\[' simulator.log | rg -i 'Deactivation reason|activationState|UISceneActivationState' +rg 'com\.invertase\.testing' sim-app.log | rg -i 'foreground-interactive|visibility.*Foreground|running-active' +rg 'testing\[' sim-app.log | rg -i 'Deactivation reason|activationState|UISceneActivationState' # WebSocket timing (main-thread block before handshake) -rg 'testing\[' simulator.log | rg 'Connection 1: ready|handshake successful' +rg 'testing\[' sim-app.log | rg 'Connection 1: ready|handshake successful' # Heavy startup on main thread (often precedes WS delay) -rg 'testing\[' simulator.log | rg -i 'FIRApp|RNFB|RCTBridge|configure' +rg 'testing\[' sim-app.log | rg -i 'FIRApp|RNFB|RCTBridge|configure' ``` **Sentinel patterns** @@ -250,8 +307,10 @@ rg -i 'BUNDLE|index\.bundle|8081|error|ECONN|transform' metro.log | Buffer early `ready`, replay after app `login` | `.yarn/patches/detox-npm-20.51.0-*.patch` β†’ `AnonymousConnectionHandler.js` | | 2Γ— device-registry lock stale (20s) | `.yarn/patches/detox-npm-20.51.0-*.patch` β†’ `ExclusiveLockfile.js` | | Jet + Metro wait, bounded `launchApp` with retry, Detox launch args, install-state + retry-eligibility logging | `tests/e2e/firebase.test.js` | +| Cloud quota Jet retry + cooldown + metrics/summary hooks | `tests/e2e/firebase.test.js`, `packages/app/e2e/cloud-metrics.js` β€” see [cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure) | | Lifecycle + JS load / packager probe logging; bundle URL `127.0.0.1` | `tests/ios/testing/AppDelegate.mm` | | Pre-boot + `bootstatus` + shutdown wait before install | `boot-simulator.sh` | +| Baseload shrink: one `sim-app.log` stream, load settle before Detox, optional video | `simulator-logging.sh`, `wait-for-load-settle.sh`, `tests_e2e_ios.yml` β€” [CI baseload policy](#ci-baseload-policy-instrumentation) | | Filtered logs, resource monitor, flake summary, Detox `tee` | `.github/workflows/tests_e2e_ios.yml`, `resource-monitor.sh`, `flake-summary.sh` | | Metro stdout/stderr artifact | `.github/workflows/tests_e2e_ios.yml` | @@ -264,7 +323,7 @@ rg -i 'BUNDLE|index\.bundle|8081|error|ECONN|transform' metro.log [jet-ws] transient_disconnect code=1006 grace_ms=30000 waiting_for_reconnect ``` -The app process (`testing[]`) often stays alive in `testing.log` / `simulator.log` β€” the break is the **simulator β†’ host** mocha-remote WebSocket on port **8090**, not a native crash. +The app process (`testing[]`) often stays alive in `sim-app.log` β€” the break is the **simulator β†’ host** mocha-remote WebSocket on port **8090**, not a native crash. **Cause** β€” Transient abnormal WebSocket closure (1006 = no close frame; 1001 = going away). Common in long debug+coverage runs (live Metro + Istanbul `__coverage__` growth + port 8090 forwarding). mocha-remote-client auto-reconnects in ~1s; Jet and mocha-remote-server now preserve the runner during a grace window instead of exiting immediately. @@ -277,7 +336,7 @@ The app process (`testing[]`) often stays alive in `testing.log` / `simulat | mocha-remote-server preserves runner + sends `pull-coverage` on reconnect | `.yarn/patches/mocha-remote-server-npm-1.13.2-*.patch` | | Assign `this.client` **before** `emit('connection')`; `Server.send()` warns instead of throwing | mocha-remote-server patch β†’ `Server.js` | | Client WS keepalive (`ping` when supported) + `readyState` logging on send failure | `.yarn/patches/mocha-remote-client-npm-1.13.2-*.patch` | -| `disconnect_context` logs `loadavg` + `process.memoryUsage()` on disconnect | Jet patch β†’ `cli.js` | +| `disconnect_context` logs `loadavg`, `process.memoryUsage()`, orchestrate phase, current test/suite, coverage-upload flag | Jet patch β†’ `cli.js` | | One Jet e2e retry when grace expires (`RETRYABLE_DISCONNECT`) or session/coverage retryable | `tests/e2e/firebase.test.js` | | Structured WS / orchestration logging | `[jet-ws]`, `[rnfb-e2e]`, `[mocha-remote-ws]` prefixes | @@ -287,8 +346,8 @@ The app process (`testing[]`) often stays alive in `testing.log` / `simulat # Jet WS lifecycle rg '\[jet-ws\]|\[rnfb-e2e\]|\[mocha-remote-ws\]|Jet client disconnected|RETRYABLE_DISCONNECT' detox-step.log -# Disconnect resource context -rg '\[jet-ws\] disconnect_context' detox-step.log +# Disconnect resource context (+ orchestrate phase when defer-run enabled) +rg '\[jet-ws\] disconnect_context|\[rnfb-e2e\] orchestrate-state' detox-step.log # Grace window recovered (no e2e retry needed) rg '\[jet-ws\] reconnect_recovered|\[mocha-remote-ws\] reconnect_recovered' detox-step.log @@ -378,14 +437,14 @@ Often preceded by `[rnfb-e2e] terminateApp ... elapsed=60000ms` (or similar) in | Slow terminate (β‰₯ `RNFB_SLOW_TERMINATE_MS`) β†’ full `boot-simulator.sh` reboot before relaunch | `tests/e2e/firebase.test.js` | | Exhausted inner launch retries escalate to **Jet attempt 2** (`retryableAtJetLevel`) | `tests/e2e/firebase.test.js` | | `simctl get_app_container` + `listapps` logging (`[rnfb-e2e] install-state`) | `tests/e2e/firebase.test.js` | -| Filtered SpringBoard log artifact | `springboard-invertase.log` | +| Filtered sim app log artifact | `sim-app.log` | | `wait_shutdown` before `simctl install` on reboot | `boot-simulator.sh` | **Diagnosing** ```bash rg 'install-state|launchApp failure|FrontBoard|FBSOpenApplication|terminateApp' detox-step.log -rg -i 'invertase|FrontBoard|unknown' springboard-invertase.log +rg -i 'invertase|FrontBoard|unknown' sim-app.log ``` #### 8. Coverage teardown handshake failure (tests pass, NYC 0/0) @@ -427,16 +486,20 @@ rg '\[rnfb-e2e\] retry-eligibility' detox-step.log See also [coverage design β€” e2e TypeScript coverage](../testing/coverage-design.md#e2e-typescript-coverage-jet--nyc). +**Cloud API quota (Installations / Remote Config)** β€” platform-agnostic; see [firebase testing project β€” CI triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure). + ### Operational notes -- **macOS runner load / Spotlight** β€” GHA macOS hosts may run Spotlight indexing (or other disk-heavy work) at any time and saturate disk I/O. Disabling Spotlight is **not** an option for Xcode CI. Design for tolerance: longer grace windows, non-throwing `Server.send()`, Jet e2e retry on orchestration crashes, `resource-monitor` + `disconnect_context` for triage. Do not treat high `loadavg` alone as a misconfiguration. +- **macOS runner load / Spotlight** β€” GHA macOS hosts may run Spotlight indexing (or other disk-heavy work) at any time and saturate disk I/O. Disabling Spotlight is **not** an option for Xcode CI. Design for tolerance: longer grace windows, non-throwing `Server.send()`, Jet e2e retry on orchestration crashes, trimmed CI instrumentation ([CI baseload policy](#ci-baseload-policy-instrumentation)), `resource-monitor` + `disconnect_context` for triage. Do not treat high `loadavg` alone as a misconfiguration. - **Release vs debug** β€” matrix runs both; each has separate artifacts (`debug` / `release` in the artifact name). -- **Retry** β€” Pre-Boot retries up to 3 times with 60s between attempts (clean shutdown + boot each time). Jet e2e retries once on retryable WS / launch / coverage teardown failures (simulator reboot via `boot-simulator.sh` on iOS). +- **Retry** β€” Pre-Boot retries up to 3 times with 60s between attempts (clean shutdown + boot each time). Jet e2e retries once on retryable WS / launch / coverage teardown / **cloud quota** failures (simulator reboot via `boot-simulator.sh` on iOS; cloud quota adds cooldown β€” [cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure)). - **Do not boot the simulator only inside Detox** β€” historical races where the testee never sent β€œready” to the Detox proxy; pre-boot remains mandatory. - **CI helper scripts** (repo root relative): | Script | Env vars | Role | |--------|----------|------| +| `.github/workflows/scripts/simulator-logging.sh` | `RNFB_RECORD_SCREENS` (0/1), `RNFB_SIM_LOG_DIR`, `RNFB_SIM_LOG_STDOUT` | One filtered `sim-app.log` stream; optional sim video β€” sourced by `boot-simulator.sh` | +| `.github/workflows/scripts/wait-for-load-settle.sh` | `RNFB_LOAD_SETTLE_MAX_LOAD` (default **20**), `RNFB_LOAD_SETTLE_MAX_WAIT_SEC`, `RNFB_LOAD_SETTLE_POLL_SEC` | Poll host load immediately before Detox | | `.github/workflows/scripts/resource-monitor.sh` | `RNFB_RESOURCE_MONITOR_INTERVAL_SEC` (default 10), `RNFB_RESOURCE_MONITOR_LOG` | Background `uptime` + `ps` snapshots during Detox | | `.github/workflows/scripts/flake-summary.sh` | `RNFB_DETOX_LOG`, `RNFB_FLAKE_SUMMARY_OUT` | Post-run `rg` digest β†’ `flake-summary.txt` | @@ -446,7 +509,7 @@ Detox steps use `tee detox-step.log` and `exit ${PIPESTATUS[0]}` so the artifact > **CI/manual mirror only.** The steps below reproduce CI deflake semantics on a developer machine. Local e2e runs must use [running-e2e.md](../testing/running-e2e.md) only β€” do not substitute `boot-simulator.sh`, `resource-monitor.sh`, or `flake-summary.sh` for the canonical `:build && :test-cover` loop. -To deflake without pushing every change, run the same steps as CI on a macOS machine or VM (SSH is fine). Mirror: emulators β†’ build β†’ `boot-simulator.sh` β†’ filtered log streams β†’ `resource-monitor.sh` β†’ `yarn tests:ios:test-cover` (or `:release`) β†’ `flake-summary.sh`. Wrap in a loop over `iterations` and collect `local-e2e-artifacts/iter-N-*` directories. A self-hosted GHA runner on the same VM is optional when you need exact workflow YAML semantics; direct script iteration is faster for day-to-day patch work. +To deflake without pushing every change, run the same steps as CI on a macOS machine or VM (SSH is fine). Mirror: emulators β†’ build β†’ `boot-simulator.sh` (pre-boot, `RNFB_START_SIM_LOGS=0`) β†’ `RNFB_SIM_BOOT_MODE=logs` + `RNFB_RECORD_SCREENS=0` β†’ `wait-for-load-settle.sh` β†’ `resource-monitor.sh` β†’ `yarn tests:ios:test-cover` (or `:release`) β†’ `flake-summary.sh`. Wrap in a loop over `iterations` and collect `local-e2e-artifacts/iter-N-*` directories. A self-hosted GHA runner on the same VM is optional when you need exact workflow YAML semantics; direct script iteration is faster for day-to-day patch work. ### Pinned Homebrew utilities diff --git a/okf-bundle/ci-workflows/other.md b/okf-bundle/ci-workflows/other.md index 61c92e3207..e7619a8325 100644 --- a/okf-bundle/ci-workflows/other.md +++ b/okf-bundle/ci-workflows/other.md @@ -60,6 +60,7 @@ TypeError: (0 , serialization_1.tryDeserialize) is not a function - [iOS CI](ios.md) β€” simulator + Detox; shared Jet / mocha-remote patches - [Coverage design](../testing/coverage-design.md) β€” macOS uploads `e2e-ts-macos` only (no native gate) +- [Cloud API quota triage](../testing/firebase-testing-project.md#ci-triage-cloud-api-quota-pressure) β€” live FIS/RC pressure (macOS loads RC; shared project with all matrix legs) ## Windows / shared diff --git a/okf-bundle/index.md b/okf-bundle/index.md index a11d4053c1..80ff776fe1 100644 --- a/okf-bundle/index.md +++ b/okf-bundle/index.md @@ -16,7 +16,7 @@ okf_version: "0.1" * [Running e2e tests](/testing/running-e2e.md) β€” canonical e2e commands, narrowing, environment, diagnosis * [Validation checklist](/testing/validation-checklist.md) β€” compile, Jest, lint, `compare:types`, e2e, coverage * [Coverage design](/testing/coverage-design.md) β€” unit/e2e coverage policy, native gates, Codecov -* [Firebase testing project](/testing/firebase-testing-project.md) β€” cloud vs emulator, rules/indexes, deploy +* [Firebase testing project](/testing/firebase-testing-project.md) β€” cloud vs emulator, live FIS/RC, helper callables, rules/indexes, deploy # Packages diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 64eb62cb8d..3034722034 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -38,7 +38,7 @@ Ephemeral tracker; see [OKF policy](../../documentation-policy.md). Gate prerequisites before any `:test-cover` ([host rule](../../testing/iteration-vocabulary.md#host-rule)): -1. [Pre-flight](../../testing/running-e2e.md#pre-flight-is-the-host-clear-to-start): host clear, [services ready](../../testing/running-e2e.md#2-services-ready), [harness matches validation tier](../../testing/running-e2e.md#3-harness-matches-validation-tier) ([narrowing gate](../../testing/running-e2e.md#harness-narrowing-gate-blocking) β€” required for **focused** and **area**; not [push harness](#harness)); [serial `:test-cover](../../testing/running-e2e.md#serialized-e2e-dispatch)`; [frozen tree](../../testing/iteration-vocabulary.md#frozen-tree) for `independent-review`. +1. [Pre-flight](../../testing/running-e2e.md#pre-flight-is-the-host-clear-to-start): [host-clear probes](../../testing/running-e2e.md#host-clear-probes), [services ready](../../testing/running-e2e.md#2-services-ready), [harness matches validation tier](../../testing/running-e2e.md#3-harness-matches-validation-tier) ([narrowing gate](../../testing/running-e2e.md#harness-narrowing-gate-blocking) β€” required for **focused** and **area**; not [push harness](#harness)); [serial `:test-cover](../../testing/running-e2e.md#serialized-e2e-dispatch)`; [frozen tree](../../testing/iteration-vocabulary.md#frozen-tree) for `independent-review`. 2. Guard probes: [SDK runtime verification](pipeline-sdk-support-audit.md#6-runtime-verification-authoritative) + [Phase J protocol](#phase-j-iteration-protocol-strict) below. 3. Coverage deltas: full clean cycle; never trust stale `.ec`/profraw ([coverage stale data](../../testing/coverage-design.md#stale-coverage-data)). diff --git a/okf-bundle/packages/firestore/pipeline-implementation-workflow.md b/okf-bundle/packages/firestore/pipeline-implementation-workflow.md index 4f027d3633..a4cfcf6e40 100644 --- a/okf-bundle/packages/firestore/pipeline-implementation-workflow.md +++ b/okf-bundle/packages/firestore/pipeline-implementation-workflow.md @@ -103,7 +103,7 @@ If review fails, return to `implementation`, then repeat `independent-review`. iOS guard probes/bridge fixes use the same split; stricter serial gate: [work queue runtime guard protocol](pipeline-coverage-work-queue.md#phase-j-iteration-protocol-strict) + [running-e2e serial policy](../../testing/running-e2e.md#serialized-e2e-dispatch). -Guard probes: one function per commit; no batching. [Pre-flight](../../testing/running-e2e.md#pre-flight-is-the-host-clear-to-start) includes services ready + harness tier. Commands: [running-e2e.md](../../testing/running-e2e.md). +Guard probes: one function per commit; no batching. [Pre-flight](../../testing/running-e2e.md#pre-flight-is-the-host-clear-to-start) (by reference). Commands: [running-e2e.md](../../testing/running-e2e.md). **Live gate status:** [pipeline-coverage-work-queue](pipeline-coverage-work-queue.md) (ephemeral tracker β€” see [documentation policy](../../documentation-policy.md)). @@ -158,7 +158,7 @@ Skip only when continuing immediately after same-worktree `after-` snaps **Retroactive baseline:** If implementation was already committed without baseline numbers, check out the parent commit, apply harness only if needed for e2e load, run full baseline, snapshot `before-`, return to HEAD. 1. Revert leftover `.only`; area narrowing allowed for pipeline baseline. -2. Start [running e2e pre-flight](../../testing/running-e2e.md#pre-flight-is-the-host-clear-to-start) (services + harness tier) and prerequisites: emulators, packager, rebuild native if needed. +2. [Pre-flight](../../testing/running-e2e.md#pre-flight-is-the-host-clear-to-start) and prerequisites ([Rules Β§1–2](../../testing/running-e2e.md#rules)); rebuild native if needed. 3. Full pipeline e2e + coverage on **macOS, iOS, Android**; no reuse variants. 4. `bash scripts/snapshot-pipeline-coverage.sh before-` β€” paste full stdout into the iteration report. diff --git a/okf-bundle/testing/coverage-design.md b/okf-bundle/testing/coverage-design.md index caa0ca8062..c1131588c6 100644 --- a/okf-bundle/testing/coverage-design.md +++ b/okf-bundle/testing/coverage-design.md @@ -140,7 +140,7 @@ Jet self-wraps under NYC with `--coverage`. - Metro bundles `packages/*/dist/module/**` with inline source maps (`tests/.babelrc`: `useInlineSourceMaps: true`). - NYC (`tests/nyc.config.js`) remaps to `packages/*/lib/**` β†’ **`coverage/lcov.info`** (`cwd: '..'`). - Jet re-invokes under `tests/node_modules/.bin/nyc` (checks `NYC_CONFIG`). Detox/macOS need no extra `nyc` prefix; Jet must run from `tests/`. -- **Transfer:** patched Jet/mocha-remote WS only (`coverage-ready` β†’ `pull-coverage` β†’ `coverage-data` β†’ `coverage-ack`); HTTP POST `/coverage` deleted (`attachHttpServer` removed). Patches: `.yarn/patches/` (`jet`, `mocha-remote-client`, `mocha-remote-server`). See [iOS issues 6–6b](../ci-workflows/ios.md#6-jet-websocket-disconnect-1006--1001), [issue 8](../ci-workflows/ios.md#8-coverage-teardown-handshake-failure-tests-pass-nyc-00), [jet patch workflow](../ci-workflows/detox-patches.md#updating-the-jet-patch-headless). +- **Transfer:** patched Jet/mocha-remote WS only (`coverage-ready` β†’ `pull-coverage` β†’ `coverage-data` β†’ `coverage-ack`); HTTP POST `/coverage` deleted (`attachHttpServer` removed). Host launch/orchestrate control uses a **separate** HTTP server on **8091** (not the 8090 WS stack) β€” see [Jet host orchestration](running-e2e.md#jet-host-orchestration-ports-and-launch-gate). Patches: `.yarn/patches/` (`jet`, `mocha-remote-client`, `mocha-remote-server`). See [iOS issues 6–6b](../ci-workflows/ios.md#6-jet-websocket-disconnect-1006--1001), [issue 8](../ci-workflows/ios.md#8-coverage-teardown-handshake-failure-tests-pass-nyc-00), [jet patch workflow](../ci-workflows/detox-patches.md#updating-the-jet-patch-headless). **NYC settings:** diff --git a/okf-bundle/testing/firebase-testing-project.md b/okf-bundle/testing/firebase-testing-project.md index 84b6847e07..4a3e83f884 100644 --- a/okf-bundle/testing/firebase-testing-project.md +++ b/okf-bundle/testing/firebase-testing-project.md @@ -3,12 +3,12 @@ type: Reference title: Firebase testing project and emulator setup description: How RNFB e2e tests use the react-native-firebase-testing Firebase project, local emulators, cloud databases, and where rules/indexes are configured and deployed. tags: [testing, e2e, firebase, emulator, firestore, pipelines] -timestamp: 2026-06-23T00:00:00Z +timestamp: 2026-06-26T00:00:00Z --- # Overview -RNFB Detox/Jet e2e uses Firebase project **`react-native-firebase-testing`**. Most modules use local emulators; **Firestore Pipelines** uses a cloud Enterprise DB. +RNFB Detox/Jet e2e uses Firebase project **`react-native-firebase-testing`**. Most modules use local emulators; **Firestore Pipelines**, **Installations**, and **Remote Config fetch/activate** hit live Google APIs on the shared project. ```mermaid flowchart TB @@ -31,6 +31,9 @@ flowchart TB C1["(default) Firestore"] C2["second-rnfb database"] C3["pipelines-e2e Enterprise eur3"] + C4["Installations FIS tokens"] + C5["Remote Config + RC admin helpers"] + C6["Deployed e2e helper callables"] end F1 --> FS @@ -41,6 +44,10 @@ flowchart TB S --> AU PE["Pipeline.e2e.js getFirestore('pipelines-e2e')"] --> C3 + INS["installations() e2e"] --> C4 + RC["remoteConfig() fetch/activate"] --> C4 + RC --> C5 + HELP["FirebaseHelpers.callCloudHelperFunction"] --> C6 ``` # Where configuration lives @@ -59,6 +66,7 @@ Config root: [`.github/workflows/scripts/`](../../.github/workflows/scripts/) (` | [`storage.rules`](../../.github/workflows/scripts/storage.rules) | Storage rules (emulator) | | [`functions/`](../../.github/workflows/scripts/functions/) | Cloud Functions used by some e2e (e.g. Vertex AI mock) | | [`deploy-firestore.sh`](../../.github/workflows/scripts/deploy-firestore.sh) | Deploy Firestore rules + indexes to cloud | +| [`deploy-e2e-cloud-functions.sh`](../../.github/workflows/scripts/deploy-e2e-cloud-functions.sh) | Deploy live e2e helper callables (metrics, App Check, RC admin) | | [`sync-firestore-indexes.sh`](../../.github/workflows/scripts/sync-firestore-indexes.sh) | Pull indexes from cloud into repo (non-interactive) | | [`README-firestore.md`](../../.github/workflows/scripts/README-firestore.md) | Short operator cheat sheet | @@ -117,6 +125,94 @@ Pipelines require Enterprise. RNFB does not enable Enterprise emulator mode toda See also [Firestore Pipelines design](/packages/firestore/pipelines.md) for bridge/coercion and coverage notes. +## Live cloud APIs (Installations, Remote Config) + +| Product | Emulator in Jet? | Runtime in e2e | +|---------|------------------|----------------| +| **Installations** (`getId`, `getToken`) | **No** | Live `firebaseinstallations.googleapis.com` on shared project | +| **Remote Config** (`fetch`, `activate`, `getValue`) | **No** | Live RC; depends on FIS token from Installations | +| **App Check debug token** | **No** | Live callable `fetchAppCheckTokenV2` via `FirebaseHelpers` | +| **RC template admin** | **No** | Live callable `testFunctionRemoteConfigUpdateV2` | + +**Parallel CI load** β€” All e2e matrix legs (iOS debug/release, Android, macOS Other when RC/App Check modules load) share one FIS project and the same live helper callables. Duplicate namespaced + modular suites doubled token traffic; prefer **modular-only** coverage while namespaced APIs are removed (`xdescribe` v8 blocks per package). + +**Do not** route cloud-pressure telemetry through the Functions emulator (`connectFunctionsEmulator` in `tests/app.js`); observability for live API pressure uses **deployed** callables (below). + +### CI triage: cloud API quota pressure + +Platform-agnostic β€” can surface on **any** e2e workflow leg that hits live Installations, Remote Config, or cloud helpers while other jobs run concurrently. + +**Symptom** β€” mid/late Jet run (installations / remoteConfig / App Check suites), often with other matrix legs active: + +``` +installations/token-error … HTTP 503 UNAVAILABLE … firebaseinstallations.googleapis.com +Too many server requests +remoteConfig/failure … Failed to get installations token +``` + +RC cases cascade when FIS token fetch fails. Functions emulator tests may still pass in the same run β€” this is **not** an emulator failure. + +**Cause** β€” Live Google APIs on shared project `react-native-firebase-testing` (table above). Parallel GHA jobs multiply FIS and helper-callable traffic. + +**Mitigations in this repo** + +| Change | Location | +|--------|----------| +| Modular-only installations / RC e2e while namespaced APIs are removed | `packages/installations/e2e/`, `packages/remote-config/e2e/` (`xdescribe` v8 blocks) | +| `RETRYABLE_CLOUD_QUOTA_RE` β†’ one Jet e2e retry; cooldown `RNFB_CLOUD_QUOTA_RETRY_COOLDOWN_MS` (default 90s) before attempt 2 | `tests/e2e/firebase.test.js` (all Detox platforms) | +| Record quota-class Jet failure to Cloud Logging via live `e2eCloudMetricsV2` | `packages/app/e2e/cloud-metrics.js`, `tests/globals.js` | +| On FIS/quota Jet failure, log `cloud-pressure-analysis` pointer (Cloud Logging filter + summary callable URL) β€” no per-run summary dump | `tests/e2e/firebase.test.js` | + +Attempt 2 uses the same platform-specific drain/reboot as other Jet retries (iOS simulator reboot, Android emulator reboot, etc.) β€” see [iOS operational notes](../ci-workflows/ios.md#operational-notes). + +**Diagnosing** (any platform Detox step log): + +```bash +rg 'installations/token-error|Too many server requests|remoteConfig/failure|cloudQuota|cloud-pressure-analysis' detox-step.log +rg '\[rnfb-e2e\] retry-eligibility' detox-step.log +``` + +| Pattern | Meaning | +|---------|---------| +| `cloudQuota: true` + `retryable=true` on attempt 1 | Second Jet attempt should run after cooldown + platform reboot | +| `cloud-pressure-analysis` | Pointer to Cloud Logging / `e2eCloudMetricsSummaryV2` for retrospective cross-run pressure β€” metrics are not dumped inline | +| Installations fail, `functions()` green | Live API quota, not Functions emulator | + +Deploy metrics helpers before expecting summary/metrics in CI: [E2e cloud helper callables](#e2e-cloud-helper-callables). + +## E2e cloud helper callables + +Live helpers use `FirebaseHelpers.callCloudHelperFunction` β†’ `https://us-central1-react-native-firebase-testing.cloudfunctions.net/` (same pattern as App Check / RC admin). Emulator functions under `:5001` are a separate surface. + +| Callable | Role | +|----------|------| +| `e2eCloudMetricsV2` | Append `[rnfb-e2e-metrics]` structured events to **Cloud Logging** (cross-run pressure notes) | +| `e2eCloudMetricsSummaryV2` | Query recent metrics for retrospective analysis (callable; not logged per Detox run) | +| `fetchAppCheckTokenV2` | App Check e2e helper (live only) | +| `testFunctionRemoteConfigUpdateV2` | RC template mutations (live only) | + +**Deploy** (after adding/changing helpers): + +```bash +cd .github/workflows/scripts +./deploy-e2e-cloud-functions.sh +``` + +Summary query needs Cloud Logging read on the functions runtime SA (`roles/logging.viewer`). + +**Triage** β€” Cloud Console β†’ Logging, filter `jsonPayload.message="[rnfb-e2e-metrics]"`. CI Detox log on quota failure: `rg 'cloud-pressure-analysis' detox-step.log`. Retrospective aggregate: POST `e2eCloudMetricsSummaryV2` with `{"data":{"lookbackHours":24}}`. + +## Functions emulator timeouts + +Functions **e2e** callables run on the **emulator** (`:5001`). Under CI load, client default ~70s and server default 60s can surface `deadline-exceeded` on slow streaming/emulator-config tests. + +| Layer | Non-timeout-probe tests | Timeout-probe tests (`sleeperV2`) | +|-------|-------------------------|-----------------------------------| +| **Client** (`functions.e2e.js`) | `e2eCallableTimeoutOptions()` β†’ 120s | `{ timeout: 1000 }` only | +| **Server** (`functions/src/*.ts`) | `E2E_TEST_FUNCTION_TIMEOUT_SECONDS` (120) via `e2eCallOptions.ts` | `sleeperV2` unchanged (intentional hang) | + +Restart emulator after rebuilding `functions/` (`yarn tests:emulator:start`). + # Cloud project: deploy rules and indexes Firebase CLI must be authenticated for `react-native-firebase-testing`. Scripts use repo `firebase-tools`; global install optional. @@ -206,6 +302,10 @@ Pipeline-only debugging may temporarily scope `tests/app.js` to `Pipeline.e2e.js | Vector search | Index in `firestore.pipelines-e2e.indexes.json`; deploy to cloud; not emulator rules | | Firestore cache | `clearIndexedDbPersistence` in `tests/app.js` for non-macOS platforms between runs | | Native coverage | iOS profraw pulled in `finally` even when Jet fails β€” see [Coverage design](/testing/coverage-design.md) | +| Installations / RC | Live FIS + RC on shared project; not emulated β€” parallel matrix can 503 / β€œToo many server requests” | +| Cloud metrics | `e2eCloudMetricsV2` + summary callable; deploy via `deploy-e2e-cloud-functions.sh` | +| Functions `deadline-exceeded` | Extend client + server timeouts for non-`sleeperV2` callables; see [Functions emulator timeouts](#functions-emulator-timeouts) | +| Storage seed flake | `packages/storage/e2e/helpers.js` `seed()` retries with exponential backoff (emulator) | # Related diff --git a/okf-bundle/testing/index.md b/okf-bundle/testing/index.md index 31460026ff..9b963324c3 100644 --- a/okf-bundle/testing/index.md +++ b/okf-bundle/testing/index.md @@ -5,4 +5,4 @@ * [Running e2e tests](running-e2e.md) β€” canonical e2e commands; start here * [Validation checklist](validation-checklist.md) β€” handoff command sequence * [Coverage design](coverage-design.md) β€” coverage policy, Codecov/native gates -* [Firebase testing project](firebase-testing-project.md) β€” cloud vs emulator, rules/indexes, deploy +* [Firebase testing project](firebase-testing-project.md) β€” cloud vs emulator, live FIS/RC, helper callables, rules/indexes, deploy diff --git a/okf-bundle/testing/iteration-vocabulary.md b/okf-bundle/testing/iteration-vocabulary.md index dce1223d2b..8df7e1e605 100644 --- a/okf-bundle/testing/iteration-vocabulary.md +++ b/okf-bundle/testing/iteration-vocabulary.md @@ -64,7 +64,7 @@ See also: [running e2e rule 7](running-e2e.md#rules) and [host rule](running-e2e On a shared dev host: - One `:test-cover` at a time β€” never overlap focused-tier and area-tier runs. -- [Pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) before every run: host clear, [services ready](running-e2e.md#2-services-ready), [harness matches `validation_tier`](running-e2e.md#3-harness-matches-validation-tier) ([narrowing gate](running-e2e.md#harness-narrowing-gate-blocking) for `focused` and `area`). +- [Pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) before every run: [host-clear probes](running-e2e.md#host-clear-probes), [services ready](running-e2e.md#2-services-ready), [harness matches `validation_tier`](running-e2e.md#3-harness-matches-validation-tier) ([narrowing gate](running-e2e.md#harness-narrowing-gate-blocking) for `focused` and `area`). - Canonical commands only β€” [running e2e](running-e2e.md). Stalled runs β†’ [stalled run detection](running-e2e.md#stalled-run-detection). ## Work-queue fields diff --git a/okf-bundle/testing/running-e2e.md b/okf-bundle/testing/running-e2e.md index 0e075da1ab..bcdb105698 100644 --- a/okf-bundle/testing/running-e2e.md +++ b/okf-bundle/testing/running-e2e.md @@ -49,7 +49,7 @@ yarn tests:macos:test-cover 5. **Report locations** β€” [Coverage design](coverage-design.md). -6. **One e2e at a time** β€” never overlap `:test-cover`/Detox/Jet on one host. Jet uses `:8090`; all platforms share Jet + Metro `:8081`; parallel runs race on coverage/device/emulator state. Every run starts after [clean pre-flight](#pre-flight-is-the-host-clear-to-start). +6. **One e2e at a time** β€” never overlap `:test-cover`/Detox/Jet on one host. Jet uses **8090** (WebSocket) plus **8091** (host control HTTP when defer-run is enabled); all platforms share Jet + Metro `:8081`; parallel runs race on coverage/device/emulator state. Every run starts after [clean pre-flight](#pre-flight-is-the-host-clear-to-start). See [Jet host orchestration](#jet-host-orchestration-ports-and-launch-gate). 7. **No source edits during e2e** β€” wait/cancel cleanly before editing `packages/**`, `tests/**`, or bundle-affecting OKF docs. Saves can hot reload/rebundle and invalidate tests/coverage. @@ -70,13 +70,43 @@ yarn tests:android:test-cover └─ app on emulator/simulator ``` -macOS: `yarn tests:macos:test-cover` β†’ `jet --target=macos` (same `:8090`). +macOS: `yarn tests:macos:test-cover` β†’ `jet --target=macos` (same `:8090` WS). **Do not poll `pgrep`, `detox`, `jet.js`, or `:8090` for completion.** They match stale wrappers, orphans, zombies, and contention. +#### Jet host orchestration (ports and launch gate) + +**Canonical owner** for Detox↔Jet↔app sequencing in `tests/e2e/firebase.test.js` and the patched Jet CLI. CI platform pages link here; do not duplicate the full port/protocol story elsewhere. + +| Port | Protocol | Role | +|------|----------|------| +| **8090** (default `JET_REMOTE_PORT`) | WebSocket (`mocha-remote-*`) | App ↔ Jet test transport; `server.run()` drives Mocha in the app | +| **8091** (default `JET_REMOTE_PORT + 1`, override `RNFB_JET_CONTROL_PORT`) | HTTP POST only | Host ↔ Jet **control plane** β€” not used by the app | + +**Why two ports** β€” Port 8090 is a WebSocket server (`ws` library). Plain HTTP `POST` to that socket (e.g. `/launch-ready`) gets **426 Upgrade Required** and can crash Jet with `ERR_HTTP_HEADERS_SENT` if a control handler shares the same HTTP stack. Control endpoints therefore live on a **separate** small HTTP server (`startControlHttpServer` in the Jet patch). + +**Launch gate (orchestration race fix)** β€” `firebase.test.js` spawns Jet with `RNFB_JET_DEFER_RUN=1`. Jet listens on 8090 and **defers** `server.run()` until the host signals launch success: + +1. Host waits for TCP **8090**, then Metro (debug) if needed, then `launchAppWithRetry`. +2. Host `POST`s **`/orchestrate-state`** (`{ "phase": "launch-pending" | "launch-ok" | … }`) to the control port (best-effort diagnostics). +3. After `launchApp` succeeds, host `POST`s **`/launch-ready`** β†’ Jet calls `server.run()` and the app may receive the mocha-remote `run` action. +4. Mocha tests must not start during a stuck or retried `launchApp`; on inner launch retry the host may kill and respawn Jet before `terminateApp`/simulator reboot. + +**Log markers** β€” `[rnfb-e2e] orchestrate-state=…`, `[jet-control] deferring server.run until POST /launch-ready`, `[jet-control] launch-ready received`, `[jet-control] listening on http://…:8091`. + +**Pre-flight** β€” [Host-clear probes](#host-clear-probes) check **8090 only** (stray Jet WS listener). **8091** may be open while Jet is running; do not treat it as a stale-process signal. + +**Patches / code** β€” Jet patch (`cli.js`: defer run, control HTTP, enriched `disconnect_context`); `tests/e2e/firebase.test.js` (`postJetControl`, `createJetSession`). Patch workflow: [detox-patches.md](../ci-workflows/detox-patches.md#updating-the-jet-patch-headless). CI triage: [iOS orchestration](../ci-workflows/ios.md#e2e-test-app-orchestration-detox--jet). + +#### CI iOS instrumentation (not local) + +GitHub Actions **Testing E2E iOS** adds CI-only steps local `:test-cover` does not run: pre-boot (`boot-simulator.sh`), one filtered **`sim-app.log`** stream, **`wait-for-load-settle.sh`** (threshold **20**) immediately before Detox, and optional video when `record_screens: true`. Host syslog and unfiltered simulator logs are **disabled** to reduce runner baseload. + +**Canonical owner:** [iOS CI baseload policy](../ci-workflows/ios.md#ci-baseload-policy-instrumentation). Artifact names and triage: [simulator logging and video](../ci-workflows/ios.md#simulator-logging-and-video-troubleshooting). + ### Running one iteration -1. [Pre-flight](#pre-flight-is-the-host-clear-to-start); if busy/recovering, wait or clean. +1. [Pre-flight](#pre-flight-is-the-host-clear-to-start); if [host-clear probes](#host-clear-probes) fail, [pre-flight recovery](#pre-flight-recovery) first. 2. One foreground Shell command; set `block_until_ms` large enough (~15m macOS, ~45–60m iOS/Android). Do **not** background/poll. 3. From repo root, tee canonical command: @@ -97,26 +127,62 @@ rg '^\s+\d+\)' /tmp/rnfb-e2e-.log # failure blocks, if any rg 'Tests Complete|jet-coverage.*merged' /tmp/rnfb-e2e-.log | tail -3 ``` -Markers: `✨ Tests Complete ✨`, Jest `N passing` / `N failing`, `[jet-coverage] merged … before NYC shutdown`. +Markers: `✨ Tests Complete ✨`, Jest `N passing` / `N failing`, `[jet-coverage] merged … before NYC shutdown`, `[rnfb-e2e] orchestrate-state=`, `[jet-control] launch-ready received`. 6. Return only platform, exit code, pass/fail line, failing tests, log path, optional coverage-gap line. No full log upstream. ### Pre-flight: is the host clear to start? -Run **all three** checks before every `:test-cover`. +**Canonical owner** for host-clear probes, recovery after abort, and service checks. Other OKF docs link here by reference β€” do not duplicate commands or probes elsewhere. + +Run **all three** steps before every `:test-cover`. After an [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090), run [pre-flight recovery](#pre-flight-recovery) and re-run the probes. #### 1. Host clear No in-flight test run on the target platform: -| Platform | Active e2e signal | Clear to start | -|----------|-------------------|----------------| -| **Android** | `adb -s emulator-5554 shell pidof com.invertase.testing.test` returns a PID | command prints nothing / empty | -| **iOS** | `xcrun simctl spawn booted pgrep -x testing` returns a PID | no output | -| **macOS** | `pgrep -x io.invertase.testing` returns a PID | no output | +| Platform | Clear when | +|----------|------------| +| **Android** | [Host-clear probes](#host-clear-probes) pass (no instrumentation PID) | +| **iOS** | [Host-clear probes](#host-clear-probes) pass β€” **zero booted simulators** and no stray Jet on `:8090`. Detox boots `iPhone 17` from `tests/.detoxrc.js`; do not pre-boot or leave simulators running. | +| **macOS** | [Host-clear probes](#host-clear-probes) pass (no `io.invertase.testing` process) | Also wait for any visible unfinished `yarn tests:*:test-cover`. + + +**Host-clear probes** β€” run the block for your platform; **exit 0 = clear** (chain with `&&`): + +```bash +# iOS β€” booted-device count must be 0 +test "$(xcrun simctl list devices booted | grep -c '(Booted)' || true)" -eq 0 +test -z "$(lsof -nP -iTCP:8090 -sTCP:LISTEN -t 2>/dev/null || true)" + +# Android +! adb -s emulator-5554 shell pidof com.invertase.testing.test >/dev/null 2>&1 + +# macOS +! pgrep -x io.invertase.testing >/dev/null 2>&1 +``` + + + +**Pre-flight recovery** β€” when probes fail after abort, kill, or `EADDRINUSE` on `:8090`. Then re-run [host-clear probes](#host-clear-probes). + +```bash +# Android +adb -s emulator-5554 shell am force-stop com.invertase.testing +adb -s emulator-5554 shell am force-stop com.invertase.testing.test + +# iOS β€” Detox re-boots iPhone 17 after shutdown booted +lsof -nP -iTCP:8090 -sTCP:LISTEN -t | xargs kill 2>/dev/null || true +pkill -f 'detox test --configuration ios' 2>/dev/null || true +pkill -f 'jet.js --target=ios' 2>/dev/null || true +xcrun simctl shutdown booted +``` + +Do **not** use `boot-simulator.sh` or `simctl shutdown all` as routine prep ([what not to do](#what-not-to-do)). + #### 2. Services ready Metro and emulators must be **running and responsive** β€” do not assume from a prior session or background start. @@ -153,7 +219,7 @@ Completion = shell exit code + log markers β€” not open-ended log tailing. | **macOS** | `Jet client connected` | `✨ Tests Complete ✨`, Jest `N passing` | | **iOS/Android** | Detox launch done, Jet connected | Same | -**If stalled** β€” no new markers for **5 minutes**, or past tier budget (~15m macOS, ~45–60m iOS/Android) without `Tests Complete`: treat as [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090). Re-check [services ready](#2-services-ready), diagnose (`curl` Metro `/status`, platform active-signal commands above, `lsof -nP -iTCP:8090`), retry. Do not keep watching flat tee output. +**If stalled** β€” no new markers for **5 minutes**, or past tier budget (~15m macOS, ~45–60m iOS/Android) without `Tests Complete`: treat as [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090). Run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#host-clear-probes) and [services ready](#2-services-ready), retry. Do not keep watching flat tee output. - macOS bundle/Metro hangs β†’ [ci-workflows/other.md Β§ bundle load hang](../ci-workflows/other.md#ci-failure-bundle-load-hang--could-not-connect-to-development-server) - iOS Metro at launch β†’ [ci-workflows/ios.md Β§ Metro unresponsive](../ci-workflows/ios.md) @@ -184,7 +250,7 @@ Do not poll `pgrep` / `jet.js` / `:8090` for *completion* ([above](#how-a-platfo For `implementation` work type ([focused tier](#e2e-validation-tiers-focused-area-full)): -1. [Pre-flight](#pre-flight-is-the-host-clear-to-start) β€” host clear, services ready, **harness narrowed** (step 3); if not, wait or clean per [interrupted run](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090). +1. [Pre-flight](#pre-flight-is-the-host-clear-to-start) β€” [host-clear probes](#host-clear-probes), services ready, **harness narrowed** (step 3); if probes fail, [pre-flight recovery](#pre-flight-recovery) first. 2. Edit e2e/spec; add `.only` if needed; never commit narrowing. 3. macOS first when TS-only: `yarn tests:macos:test-cover 2>&1 | tee /tmp/rnfb-e2e-macos.log` β€” wait for exit code ([stalled run](#stalled-run-detection) if markers stop). 4. If macOS green and native touched: `yarn tests::build && yarn tests::test-cover 2>&1 | tee /tmp/rnfb-e2e-.log`; one platform at a time. @@ -199,7 +265,7 @@ Never overlap runs that use `:test-cover`. See [host rule](iteration-vocabulary. |------|-------------| | **One e2e run at a time** | Wait for prior shell exit code + short log summary | | **No overlapping tiers** | Never run focused-tier and area-tier `:test-cover` concurrently on one host | -| **Clean pre-flight every run** | [Pre-flight](#pre-flight-is-the-host-clear-to-start) β€” host, services, harness tier | +| **Clean pre-flight every run** | [Pre-flight](#pre-flight-is-the-host-clear-to-start) β€” [host-clear probes](#host-clear-probes), services, harness tier | | **iOS guard probe loop** | `implementation` (Jest + **focused**) β†’ `independent-review` (**area**, frozen tree) β†’ `commit` β€” [work queue protocol](../packages/firestore/pipeline-coverage-work-queue.md#phase-j-iteration-protocol-strict) | | Validation tier | E2e scope | Narrowing allowed | Typical work type | @@ -212,21 +278,7 @@ Each run owns its blocking `:test-cover` and returns summaries only. ### Interrupted run (abort, killed terminal, EADDRINUSE on :8090) -Before next canonical command: - -```bash -# Android β€” stop app + instrumentation -adb -s emulator-5554 shell am force-stop com.invertase.testing -adb -s emulator-5554 shell am force-stop com.invertase.testing.test - -# Stray Jet from an aborted run (note PID, kill only jet.js for this repo) -lsof -nP -iTCP:8090 -sTCP:LISTEN - -# Confirm clear -adb -s emulator-5554 shell pidof com.invertase.testing.test || echo "instrumentation clear" -``` - -Then rerun from repo root: `yarn tests::build && yarn tests::test-cover` (foreground; tee if logging). +Run [pre-flight recovery](#pre-flight-recovery), confirm [host-clear probes](#host-clear-probes) pass, then rerun from repo root: `yarn tests::build && yarn tests::test-cover` (foreground; tee if logging). ### What not to do @@ -284,7 +336,7 @@ All tiers use [canonical commands](#rules), [host rule](iteration-vocabulary.md# **Universal rules:** - E2e is **always serial** β€” one `:test-cover` at a time on the host. -- Every run starts from **verified pre-flight** (host clear + services ready + harness matches tier); if not, wait or follow [interrupted-run cleanup](#interrupted-run-abort-killed-terminal-eaddrinuse-on-8090) β€” do not start another run. +- Every run starts from **verified [pre-flight](#pre-flight-is-the-host-clear-to-start)**; if probes fail, [pre-flight recovery](#pre-flight-recovery) before another run. - Use **only** canonical commands from this doc. - Never overlap focused-tier and area-tier `:test-cover` on one host. @@ -292,9 +344,9 @@ See also: [focused-tier loop](#focused-tier-iteration-loop), [dispatch](#seriali ## Environment -- **Devices** β€” Detox boots simulator/emulator (`TestingAVD`); macOS auto-starts app. +- **Devices** β€” Detox boots simulator/emulator (`iPhone 17` on iOS, `TestingAVD` on Android); [host-clear probes](#host-clear-probes) require zero booted iOS simulators before `:test-cover`. macOS auto-starts app. - **adb empty** β€” `adb kill-server && adb start-server && adb devices` -- **Stale processes** β€” one Metro (`:8081`), one emulator set (`:8080`, `:9099`, `:9000`, `:4400`, …). Jet `:8090` is per-`:test-cover`, not background; listener after run usually means stray Jet. Check: `lsof -nP -iTCP -sTCP:LISTEN | rg ':8081|:8080|:9099|:4400'`. Kill Metro/emulators: `pkill -f "react-native start"`, `pkill -f "firebase emulators"`. +- **Stale processes** β€” one Metro (`:8081`), one emulator set (`:8080`, `:9099`, `:9000`, `:4400`, …). Stray Jet on `:8090` after a run β†’ [pre-flight recovery](#pre-flight-recovery). Restart Metro/emulators: `pkill -f "react-native start"`, `pkill -f "firebase emulators"`, then [Rules Β§1–2](#rules). ## Diagnosing hangs @@ -308,12 +360,14 @@ See also: [focused-tier loop](#focused-tier-iteration-loop), [dispatch](#seriali **Benign noise:** iOS Detox `EXEC_FAIL "xcrun simctl terminate … com.invertase.testing" … found nothing to terminate` β€” app wasn't running; ignore. +**Cloud API pressure** β€” Installations / Remote Config failures with FIS 503 or β€œToo many server requests” are live-project quota on **any** platform, not emulator issues. See [Firebase testing project β€” CI triage](firebase-testing-project.md#ci-triage-cloud-api-quota-pressure). + ## Before merge (PR handoff) Pre-merge applies once to the branch commit stream before merge/push intended for merge, not after every commit. 1. Revert all narrowing ([full tier](#e2e-validation-tiers-focused-area-full)): restore `tests/app.js` (`platformSupportedModules` + `require.context`), default `RNFBDebug` in `tests/globals.js`, remove all `.only`, remove native instrumentation. -2. [Pre-flight](#pre-flight-is-the-host-clear-to-start) β€” verified clean host before each platform run. +2. [Pre-flight](#pre-flight-is-the-host-clear-to-start) β€” [host-clear probes](#host-clear-probes) pass before each platform run. 3. Rebuild if needed (`tests::build`; `yarn lerna:prepare` for `lib/**`). 4. Full unfocused suite with coverage on **iOS, Android, macOS** β€” one platform at a time, all green. diff --git a/okf-bundle/testing/validation-checklist.md b/okf-bundle/testing/validation-checklist.md index e329c216a2..42f26ca355 100644 --- a/okf-bundle/testing/validation-checklist.md +++ b/okf-bundle/testing/validation-checklist.md @@ -71,7 +71,7 @@ Native: `yarn lint:android`, `yarn lint:ios:check`. `lint:android` can flake; re ## E2e with coverage -[Pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) (host + services + harness tier) before every run. Match harness to work type β€” **focused**/**area** never use full app load ([running e2e Β§ harness](running-e2e.md#3-harness-matches-validation-tier)). +[Pre-flight](running-e2e.md#pre-flight-is-the-host-clear-to-start) (host-clear probes + services + harness tier) before every run. Match harness to work type β€” **focused**/**area** never use full app load ([running e2e Β§ harness](running-e2e.md#3-harness-matches-validation-tier)). Commands: [Running e2e tests](running-e2e.md). Post-process: [Coverage design](coverage-design.md) (iOS `tests:ios:test:process-coverage`, Android `tests:android:post-e2e-coverage`). diff --git a/packages/app/e2e/cloud-metrics.js b/packages/app/e2e/cloud-metrics.js new file mode 100644 index 0000000000..5a71c45dee --- /dev/null +++ b/packages/app/e2e/cloud-metrics.js @@ -0,0 +1,57 @@ +const { getE2eTestProject } = require('./helpers'); + +const DEFAULT_LOOKBACK_HOURS = 24; + +function getCloudHelperUrl(functionName) { + return `https://us-central1-${getE2eTestProject()}.cloudfunctions.net/${functionName}`; +} + +async function postCloudHelperFunction(functionName, data) { + const response = await fetch(getCloudHelperUrl(functionName), { + method: 'POST', + body: JSON.stringify({ data }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`cloud helper ${functionName} status=${response.status}`); + } + + const body = await response.json(); + return body?.result ?? body; +} + +/** + * Live-project metric sink (not the Functions emulator). + * Fire-and-forget; non-fatal when the helper is unavailable. + */ +exports.recordE2eCloudMetricFromHost = async function recordE2eCloudMetricFromHost(payload) { + if (!payload || typeof payload !== 'object') { + return; + } + + const metric = { + source: 'detox-host', + ...payload, + }; + + try { + await postCloudHelperFunction('e2eCloudMetricsV2', metric); + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + '[rnfb-e2e-metrics] record-host-fallback', + JSON.stringify({ ...metric, error: String(error) }), + ); + } +}; + +/** + * Pull recent cross-run pressure summary from Cloud Logging via deployed helper. + * For retrospective analysis only β€” not emitted inline during Detox runs. + */ +exports.fetchE2eCloudMetricsSummaryFromHost = async function fetchE2eCloudMetricsSummaryFromHost({ + lookbackHours = DEFAULT_LOOKBACK_HOURS, +} = {}) { + return postCloudHelperFunction('e2eCloudMetricsSummaryV2', { lookbackHours }); +}; diff --git a/packages/functions/e2e/functions.e2e.js b/packages/functions/e2e/functions.e2e.js index 85e892eeeb..f8c1d2874c 100644 --- a/packages/functions/e2e/functions.e2e.js +++ b/packages/functions/e2e/functions.e2e.js @@ -88,6 +88,12 @@ const SAMPLE_DATA = { ], }; +const E2E_CALLABLE_TIMEOUT_MS = 120000; + +function e2eCallableTimeoutOptions(extra = {}) { + return { timeout: E2E_CALLABLE_TIMEOUT_MS, ...extra }; +} + describe('functions() modular', function () { describe('firebase v8 compatibility', function () { beforeEach(async function beforeEachTest() { @@ -128,7 +134,10 @@ describe('functions() modular', function () { firebase.app().functions(region)._customUrlOrRegion.should.equal(region); - const functionRunner = functionsForRegion.httpsCallable('testFunctionCustomRegion'); + const functionRunner = functionsForRegion.httpsCallable( + 'testFunctionCustomRegion', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(); response.data.should.equal(region); @@ -146,7 +155,10 @@ describe('functions() modular', function () { functionsForCustomUrl._customUrlOrRegion.should.equal(customUrl); - const functionRunner = functionsForCustomUrl.httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = functionsForCustomUrl.httpsCallable( + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(); response.data.should.equal('null'); @@ -159,7 +171,7 @@ describe('functions() modular', function () { const fnName = 'helloWorldV2'; const functions = firebase.app().functions(region); functions.useFunctionsEmulator('http://localhost'); - const response = await functions.httpsCallable(fnName)(); + const response = await functions.httpsCallable(fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); @@ -168,7 +180,7 @@ describe('functions() modular', function () { const fnName = 'helloWorldV2'; const functions = firebase.app().functions(region); functions.useFunctionsEmulator('http://localhost:5001'); - const response = await functions.httpsCallable(fnName)(); + const response = await functions.httpsCallable(fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); @@ -177,7 +189,7 @@ describe('functions() modular', function () { const fnName = 'helloWorldV2'; const functions = firebase.app().functions(region); functions.useEmulator('localhost', 5001); - const response = await functions.httpsCallable(fnName)(); + const response = await functions.httpsCallable(fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); }); @@ -192,6 +204,7 @@ describe('functions() modular', function () { .functions() .httpsCallableFromUrl( `http://${hostname}:5001/react-native-firebase-testing/us-central1/helloWorldV2`, + e2eCallableTimeoutOptions(), ); const response = await functionRunner(); response.data.should.equal('Hello from Firebase!'); @@ -200,37 +213,49 @@ describe('functions() modular', function () { describe('httpsCallable(fnName)(args)', function () { it('accepts primitive args: undefined', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const response = await functionRunner(); response.data.should.equal('null'); }); it('accepts primitive args: string', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const response = await functionRunner('hello'); response.data.should.equal('string'); }); it('accepts primitive args: number', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const response = await functionRunner(123); response.data.should.equal('number'); }); it('accepts primitive args: boolean', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const response = await functionRunner(true); response.data.should.equal('boolean'); }); it('accepts primitive args: null', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const response = await functionRunner(null); response.data.should.equal('null'); }); it('accepts array args', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const response = await functionRunner([1, 2, 3, 4]); response.data.should.equal('array'); }); @@ -238,7 +263,9 @@ describe('functions() modular', function () { it('accepts object args', async function () { const type = 'object'; const inputData = SAMPLE_DATA[type]; - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const { data: outputData } = await functionRunner({ type, inputData, @@ -249,7 +276,9 @@ describe('functions() modular', function () { it('accepts complex nested objects', async function () { const type = 'deepObject'; const inputData = SAMPLE_DATA[type]; - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const { data: outputData } = await functionRunner({ type, inputData, @@ -260,7 +289,9 @@ describe('functions() modular', function () { it('accepts complex nested arrays', async function () { const type = 'deepArray'; const inputData = SAMPLE_DATA[type]; - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); const { data: outputData } = await functionRunner({ type, inputData, @@ -271,7 +302,9 @@ describe('functions() modular', function () { describe('HttpsError', function () { it('errors return instance of HttpsError', async function () { - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); try { await functionRunner({}); @@ -288,7 +321,9 @@ describe('functions() modular', function () { it('HttpsError.details -> allows returning complex data', async function () { let type = 'deepObject'; let inputData = SAMPLE_DATA[type]; - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); try { await functionRunner({ type, @@ -327,7 +362,9 @@ describe('functions() modular', function () { it('HttpsError.details -> allows returning primitives', async function () { let type = 'number'; let inputData = SAMPLE_DATA[type]; - const functionRunner = firebase.functions().httpsCallable('testFunctionDefaultRegionV2'); + const functionRunner = firebase + .functions() + .httpsCallable('testFunctionDefaultRegionV2', e2eCallableTimeoutOptions()); try { await functionRunner({ type, @@ -461,7 +498,11 @@ describe('functions() modular', function () { getApp().functions(region)._customUrlOrRegion.should.equal(region); - const functionRunner = httpsCallable(functionsForRegion, 'testFunctionCustomRegion'); + const functionRunner = httpsCallable( + functionsForRegion, + 'testFunctionCustomRegion', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(); response.data.should.equal(region); @@ -482,7 +523,11 @@ describe('functions() modular', function () { functionsForCustomUrl._customUrlOrRegion.should.equal(customUrl); - const functionRunner = httpsCallable(functionsForCustomUrl, 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + functionsForCustomUrl, + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(); response.data.should.equal('null'); @@ -497,7 +542,7 @@ describe('functions() modular', function () { // const functions = firebase.app().functions(region); const functions = getFunctions(getApp(), region); connectFunctionsEmulator(functions, 'localhost', 5001); - const response = await httpsCallable(functions, fnName)(); + const response = await httpsCallable(functions, fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); @@ -508,7 +553,7 @@ describe('functions() modular', function () { const fnName = 'helloWorldV2'; const functions = getFunctions(getApp(), region); connectFunctionsEmulator(functions, 'localhost', 5001); - const response = await httpsCallable(functions, fnName)(); + const response = await httpsCallable(functions, fnName, e2eCallableTimeoutOptions())(); response.data.should.equal('Hello from Firebase!'); }); @@ -538,6 +583,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallableFromUrl( functions, `http://${hostname}:5001/react-native-firebase-testing/us-central1/helloWorldV2`, + e2eCallableTimeoutOptions(), ); const response = await functionRunner(); response.data.should.equal('Hello from Firebase!'); @@ -548,7 +594,11 @@ describe('functions() modular', function () { it('accepts primitive args: undefined', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(); response.data.should.equal('null'); }); @@ -556,7 +606,11 @@ describe('functions() modular', function () { it('accepts primitive args: string', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner('hello'); response.data.should.equal('string'); }); @@ -564,7 +618,11 @@ describe('functions() modular', function () { it('accepts primitive args: number', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(123); response.data.should.equal('number'); }); @@ -572,7 +630,11 @@ describe('functions() modular', function () { it('accepts primitive args: boolean', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(true); response.data.should.equal('boolean'); }); @@ -580,7 +642,11 @@ describe('functions() modular', function () { it('accepts primitive args: null', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner(null); response.data.should.equal('null'); }); @@ -588,7 +654,11 @@ describe('functions() modular', function () { it('accepts array args', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const response = await functionRunner([1, 2, 3, 4]); response.data.should.equal('array'); }); @@ -598,7 +668,11 @@ describe('functions() modular', function () { const { getFunctions, httpsCallable } = functionsModular; const type = 'object'; const inputData = SAMPLE_DATA[type]; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const { data: outputData } = await functionRunner({ type, inputData, @@ -611,7 +685,11 @@ describe('functions() modular', function () { const { getFunctions, httpsCallable } = functionsModular; const type = 'deepObject'; const inputData = SAMPLE_DATA[type]; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const { data: outputData } = await functionRunner({ type, inputData, @@ -624,7 +702,11 @@ describe('functions() modular', function () { const { getFunctions, httpsCallable } = functionsModular; const type = 'deepArray'; const inputData = SAMPLE_DATA[type]; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); const { data: outputData } = await functionRunner({ type, inputData, @@ -637,7 +719,11 @@ describe('functions() modular', function () { it('errors return instance of HttpsError', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); try { await functionRunner({}); @@ -656,7 +742,11 @@ describe('functions() modular', function () { let inputData = SAMPLE_DATA[type]; const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); try { await functionRunner({ type, @@ -697,7 +787,11 @@ describe('functions() modular', function () { const { getFunctions, httpsCallable } = functionsModular; let type = 'number'; let inputData = SAMPLE_DATA[type]; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testFunctionDefaultRegionV2'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testFunctionDefaultRegionV2', + e2eCallableTimeoutOptions(), + ); try { await functionRunner({ type, @@ -791,7 +885,11 @@ describe('functions() modular', function () { it('should stream data chunks from a basic streaming function', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testStreamingCallable'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testStreamingCallable', + e2eCallableTimeoutOptions(), + ); const { stream, data } = await functionRunner.stream({ count: 5, delay: 500 }); const chunks = []; @@ -819,7 +917,11 @@ describe('functions() modular', function () { it('should stream progress updates', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testProgressStream'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testProgressStream', + e2eCallableTimeoutOptions(), + ); const { stream, data } = await functionRunner.stream({ task: 'TestTask' }); const chunks = []; @@ -843,7 +945,11 @@ describe('functions() modular', function () { it('should handle complex data structures in stream', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testComplexDataStream'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testComplexDataStream', + e2eCallableTimeoutOptions(), + ); const { stream, data } = await functionRunner.stream({}); const complexChunks = []; @@ -872,9 +978,11 @@ describe('functions() modular', function () { it('should work with HttpsCallableOptions.timeout', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testStreamingCallable', { - timeout: 10000, - }); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testStreamingCallable', + e2eCallableTimeoutOptions(), + ); const { stream, data } = await functionRunner.stream({ count: 3, delay: 300 }); const chunks = []; @@ -896,7 +1004,11 @@ describe('functions() modular', function () { it('should accept stream options as second parameter', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testStreamingCallable'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testStreamingCallable', + e2eCallableTimeoutOptions(), + ); const { stream, data } = await functionRunner.stream( { count: 3, delay: 300 }, { limitedUseAppCheckTokens: false }, @@ -921,7 +1033,11 @@ describe('functions() modular', function () { it('should handle empty data parameter', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testComplexDataStream'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testComplexDataStream', + e2eCallableTimeoutOptions(), + ); const { stream, data } = await functionRunner.stream(); const chunks = []; @@ -946,6 +1062,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallable( getFunctions(getApp()), 'testStreamingCallableWithNull', + e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream(null); @@ -968,7 +1085,11 @@ describe('functions() modular', function () { it('should return both stream and data promise', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testStreamingCallable'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testStreamingCallable', + e2eCallableTimeoutOptions(), + ); const result = await functionRunner.stream({ count: 2, delay: 200 }); result.should.have.property('stream'); @@ -994,8 +1115,16 @@ describe('functions() modular', function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; const functions = getFunctions(getApp()); - const functionRunner1 = httpsCallable(functions, 'testStreamingCallable'); - const functionRunner2 = httpsCallable(functions, 'testStreamingCallable'); + const functionRunner1 = httpsCallable( + functions, + 'testStreamingCallable', + e2eCallableTimeoutOptions(), + ); + const functionRunner2 = httpsCallable( + functions, + 'testStreamingCallable', + e2eCallableTimeoutOptions(), + ); const [result1, result2] = await Promise.all([ functionRunner1.stream({ count: 2, delay: 200 }), @@ -1036,7 +1165,11 @@ describe('functions() modular', function () { it('HttpsError when calling stream by name', async function () { const { getApp } = modular; const { getFunctions, httpsCallable } = functionsModular; - const functionRunner = httpsCallable(getFunctions(getApp()), 'testStreamWithHttpsError'); + const functionRunner = httpsCallable( + getFunctions(getApp()), + 'testStreamWithHttpsError', + e2eCallableTimeoutOptions(), + ); try { const { stream } = await functionRunner.stream(null); @@ -1115,6 +1248,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamWithHttpsErrorFromUrl`, + e2eCallableTimeoutOptions(), ); try { @@ -1197,6 +1331,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream({ count: 3, delay: 400 }); @@ -1226,7 +1361,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, - { timeout: 10000 }, + e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream({ count: 2, delay: 300 }); @@ -1256,6 +1391,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + e2eCallableTimeoutOptions(), ); const { stream, data } = await functionRunner.stream( { count: 2, delay: 300 }, @@ -1288,6 +1424,7 @@ describe('functions() modular', function () { const functionRunner = httpsCallableFromUrl( getFunctions(getApp()), `http://${hostname}:5001/react-native-firebase-testing/us-central1/testStreamingCallable`, + e2eCallableTimeoutOptions(), ); const result = await functionRunner.stream({ count: 2, delay: 200 }); diff --git a/packages/installations/e2e/installations.e2e.js b/packages/installations/e2e/installations.e2e.js index dcd076faea..017c0ef016 100644 --- a/packages/installations/e2e/installations.e2e.js +++ b/packages/installations/e2e/installations.e2e.js @@ -61,7 +61,8 @@ const ID_LENGTH = 22; const PROJECT_ID = 448618578101; // this is "magic", it's the react-native-firebase-testing project ID describe('installations() modular', function () { - describe('firebase v8 compatibility', function () { + // Namespaced API is being removed; modular coverage is sufficient and halves FIS load. + xdescribe('firebase v8 compatibility', function () { beforeEach(async function beforeEachTest() { // @ts-ignore globalThis.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = true; diff --git a/packages/remote-config/e2e/config.e2e.js b/packages/remote-config/e2e/config.e2e.js index 4102e6b9d1..4621848b7f 100644 --- a/packages/remote-config/e2e/config.e2e.js +++ b/packages/remote-config/e2e/config.e2e.js @@ -16,7 +16,8 @@ */ describe('remoteConfig()', function () { - describe('firebase v8 compatibility', function () { + // Namespaced API is being removed; modular coverage is sufficient and halves FRC/FIS load. + xdescribe('firebase v8 compatibility', function () { beforeEach(async function beforeEachTest() { // @ts-ignore globalThis.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = true; diff --git a/packages/storage/e2e/helpers.js b/packages/storage/e2e/helpers.js index 96871b8ee5..f4d41d3ebf 100644 --- a/packages/storage/e2e/helpers.js +++ b/packages/storage/e2e/helpers.js @@ -5,27 +5,49 @@ const PATH_ROOT = 'playground'; const PATH = `${PATH_ROOT}/${ID}`; const WRITE_ONLY_NAME = 'writeOnly.jpeg'; +const SEED_MAX_ATTEMPTS = 4; +const SEED_INITIAL_BACKOFF_MS = 250; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function uploadSeedFixtures(path) { + const { getStorage, ref, uploadString, StringFormat } = storageModular; + + await uploadString(ref(getStorage(), WRITE_ONLY_NAME), 'Write Only'); + + await uploadString(ref(getStorage(), `${path}/list/file1.txt`), 'File 1', StringFormat.RAW, { + contentType: 'text/plain', + }); + + await uploadString(ref(getStorage(), `${path}/list/file2.txt`), 'File 2'); + await uploadString(ref(getStorage(), `${path}/list/file3.txt`), 'File 3'); + await uploadString(ref(getStorage(), `${path}/list/file4.txt`), 'File 4'); + await uploadString(ref(getStorage(), `${path}/list/nested/file5.txt`), 'File 5'); +} + exports.seed = async function seed(path) { let leakDetectCurrent = globalThis.RNFBDebugInTestLeakDetection; globalThis.RNFBDebugInTestLeakDetection = false; - const { getStorage, ref, uploadString, StringFormat } = storageModular; + let lastError; try { - // Add a write only file - await uploadString(ref(getStorage(), WRITE_ONLY_NAME), 'Write Only'); - - await uploadString(ref(getStorage(), `${path}/list/file1.txt`), 'File 1', StringFormat.RAW, { - contentType: 'text/plain', - }); - - await uploadString(ref(getStorage(), `${path}/list/file2.txt`), 'File 2'); - await uploadString(ref(getStorage(), `${path}/list/file3.txt`), 'File 3'); - await uploadString(ref(getStorage(), `${path}/list/file4.txt`), 'File 4'); - await uploadString(ref(getStorage(), `${path}/list/nested/file5.txt`), 'File 5'); - } catch (e) { + for (let attempt = 1; attempt <= SEED_MAX_ATTEMPTS; attempt++) { + try { + await uploadSeedFixtures(path); + return; + } catch (error) { + lastError = error; + if (attempt >= SEED_MAX_ATTEMPTS) { + break; + } + const backoffMs = SEED_INITIAL_BACKOFF_MS * 2 ** (attempt - 1); + await sleep(backoffMs); + } + } + // eslint-disable-next-line no-console console.error('unable to seed storage service with test fixtures'); - throw e; + throw lastError; } finally { globalThis.RNFBDebugInTestLeakDetection = leakDetectCurrent; } diff --git a/scripts/tart/lib/boot-simulator.sh b/scripts/tart/lib/boot-simulator.sh index 96947e965f..a7fc8a4e4a 100755 --- a/scripts/tart/lib/boot-simulator.sh +++ b/scripts/tart/lib/boot-simulator.sh @@ -187,6 +187,15 @@ pushd "${REPO_ROOT}/tests" >/dev/null || exit 1 SIM="$(grep iPhone .detoxrc.js | head -1 | cut -d"'" -f2)" popd >/dev/null || exit 1 +BOOT_MODE="${RNFB_SIM_BOOT_MODE:-full}" +# shellcheck source=../../../../.github/workflows/scripts/simulator-logging.sh +source "${REPO_ROOT}/.github/workflows/scripts/simulator-logging.sh" + +if [[ "$BOOT_MODE" == "logs" ]]; then + restart_simulator_logging || true + exit 0 +fi + log_boot_status "phase=resolve_device name=\"${SIM}\" (from tests/.detoxrc.js)" kill_resolved_simulator "$SIM" @@ -245,3 +254,7 @@ log_boot_status "install complete in $((SECONDS - install_start))s" popd >/dev/null || exit 1 log_boot_status "phase=complete device=\"${SIM}\" ready with test app installed" + +if [[ "${RNFB_START_SIM_LOGS:-1}" == "1" && "$BOOT_MODE" == "full" ]]; then + restart_simulator_logging || true +fi diff --git a/tests/e2e/firebase.test.js b/tests/e2e/firebase.test.js index b5149dbbb8..4005ea5069 100644 --- a/tests/e2e/firebase.test.js +++ b/tests/e2e/firebase.test.js @@ -20,8 +20,29 @@ const net = require('net'); const path = require('path'); const { pullIosCoverage } = require('../scripts/pull-native-coverage'); +const { recordE2eCloudMetricFromHost } = require('../../packages/app/e2e/cloud-metrics'); + +const E2E_TEST_PROJECT = 'react-native-firebase-testing'; +const E2E_CLOUD_PRESSURE_LOG_FILTER = 'jsonPayload.message="[rnfb-e2e-metrics]"'; +const E2E_CLOUD_PRESSURE_LOG_CONSOLE_URL = `https://console.cloud.google.com/logs/query;query=${encodeURIComponent( + E2E_CLOUD_PRESSURE_LOG_FILTER, +)};storageScope=project;project=${E2E_TEST_PROJECT}`; +const E2E_CLOUD_PRESSURE_SUMMARY_CALLABLE = `https://us-central1-${E2E_TEST_PROJECT}.cloudfunctions.net/e2eCloudMetricsSummaryV2`; + +function logCloudPressureAnalysisPointer(context) { + console.warn( + `[rnfb-e2e] cloud-pressure-analysis (${context}): retrospective pressure data is in Cloud Logging ` + + `(project=${E2E_TEST_PROJECT}, filter ${E2E_CLOUD_PRESSURE_LOG_FILTER}) or via POST ` + + `${E2E_CLOUD_PRESSURE_SUMMARY_CALLABLE} with {"data":{"lookbackHours":24}} β€” ` + + `console: ${E2E_CLOUD_PRESSURE_LOG_CONSOLE_URL}`, + ); +} const JET_REMOTE_PORT = parseInt(process.env.JET_REMOTE_PORT || '8090', 10); +const JET_CONTROL_PORT = parseInt( + process.env.RNFB_JET_CONTROL_PORT || String(JET_REMOTE_PORT + 1), + 10, +); const METRO_PORT = parseInt(process.env.JET_METRO_PORT || process.env.RCT_METRO_PORT || '8081', 10); const LAUNCH_APP_TIMEOUT_MS = parseInt(process.env.RNFB_LAUNCH_APP_TIMEOUT_MS || '180000', 10); const LAUNCH_APP_RELEASE_TIMEOUT_MS = parseInt( @@ -50,6 +71,10 @@ const DRAIN_ORCHESTRATE_TIMEOUT_MS = parseInt( process.env.RNFB_DRAIN_ORCHESTRATE_TIMEOUT_MS || '30000', 10, ); +const KILL_JET_FOR_LAUNCH_RETRY_TIMEOUT_MS = parseInt( + process.env.RNFB_KILL_JET_LAUNCH_RETRY_TIMEOUT_MS || '30000', + 10, +); const JET_RETRYABLE_WS_RE = /\[jet-ws\] RETRYABLE_DISCONNECT code=(1006|1001)\b/; const JET_RECONNECT_RECOVERED_RE = /\[jet-ws\] reconnect_recovered code=(1006|1001)\b/; const JET_SERVER_NOT_RUNNING_RE = /server wasn't running/i; @@ -58,6 +83,12 @@ const JET_COVERAGE_TEARDOWN_RE = /Failed to send 'coverage-ready' message: WebSocket is closed|coverage upload timed out waiting for coverage-ack/i; const JET_PROTOCOL_ERROR_RE = /\[πŸŸ₯\] Unexpected end of JSON input/i; const JET_NO_CLIENT_CONNECTED_RE = /Error: No client connected/i; +const RETRYABLE_CLOUD_QUOTA_RE = + /installations\/token-error|Too many server requests|firebaseinstallations\.googleapis\.com|remoteConfig\/failure.*fetch\(\)|Failed to get installations token|\[remoteConfig\/unknown\].*installations/i; +const CLOUD_QUOTA_RETRY_COOLDOWN_MS = parseInt( + process.env.RNFB_CLOUD_QUOTA_RETRY_COOLDOWN_MS || '90000', + 10, +); const RETRYABLE_LAUNCH_RE = /launchApp timed out|RCTJavaScriptDidFailToLoad|packager-probe|Metro not responding|Unknown application display identifier|Simulator device failed to launch|unknown to FrontBoard|FBSOpenApplicationServiceErrorDomain/i; const PORT_CLOSED_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'EPIPE']); @@ -80,13 +111,28 @@ function resolveDetoxConfigurationName() { } function resolveAppBinaryPath() { - if (typeof detox === 'undefined' || !detox?.config?.apps) { - return ''; + if (typeof detox !== 'undefined' && detox?.config?.apps) { + const apps = detox.config.apps; + const appConfig = apps.default || apps[Object.keys(apps)[0]]; + if (appConfig?.binaryPath) { + return appConfig.binaryPath; + } + } + + const fs = require('node:fs'); + const debugIosApp = path.resolve(__dirname, '../ios/build/Build/Products/Debug-iphonesimulator/testing.app'); + if (fs.existsSync(debugIosApp)) { + return debugIosApp; + } + const releaseIosApp = path.resolve( + __dirname, + '../ios/build/Build/Products/Release-iphonesimulator/testing.app', + ); + if (fs.existsSync(releaseIosApp)) { + return releaseIosApp; } - const apps = detox.config.apps; - const appConfig = apps.default || apps[Object.keys(apps)[0]]; - return appConfig?.binaryPath || ''; + return ''; } function usesLiveMetro() { @@ -499,6 +545,10 @@ function isRetryableJetSessionFailure(output) { return JET_COVERAGE_LOST_RE.test(output) || /\[πŸŸ₯\] Stopped the server/i.test(output); } +function isRetryableCloudQuotaFailure(jetOutput) { + return RETRYABLE_CLOUD_QUOTA_RE.test(jetOutput); +} + function isRetryableLaunchFailure(err) { const message = err?.message || ''; if (err?.retryableAtJetLevel) { @@ -519,6 +569,7 @@ function isRetryableE2eFailure(err) { return ( isRetryableJetDisconnect(jetOutput) || isRetryableJetSessionFailure(jetOutput) || + isRetryableCloudQuotaFailure(jetOutput) || isRetryableLaunchFailure(err) ); } @@ -531,6 +582,7 @@ function logRetryEligibility(err, attempt) { jetProtocol: JET_PROTOCOL_ERROR_RE.test(jetOutput), jetNoClient: JET_NO_CLIENT_CONNECTED_RE.test(jetOutput), coverageTeardown: JET_COVERAGE_TEARDOWN_RE.test(jetOutput), + cloudQuota: isRetryableCloudQuotaFailure(jetOutput), launchFailure: isRetryableLaunchFailure(err), launchJetLevel: Boolean(err?.retryableAtJetLevel), }; @@ -598,7 +650,7 @@ async function launchAppWithTimeout(launchArgs, { deleteApp = true, timeoutMs } new Promise((_, reject) => { timer = setTimeout(() => { const err = new Error( - `[rnfb-e2e] launchApp timed out after ${effectiveTimeout}ms β€” check simulator.log for ` + + `[rnfb-e2e] launchApp timed out after ${effectiveTimeout}ms β€” check sim-app.log for ` + `[rnfb-lifecycle] packager-probe / RCTJavaScriptDidFailToLoad and Detox waitForActive`, ); err.retryableLaunchFailure = true; @@ -613,7 +665,53 @@ async function launchAppWithTimeout(launchArgs, { deleteApp = true, timeoutMs } } } -async function launchAppWithRetry(launchArgs, { testsDir } = {}) { +async function postJetControl(path, body) { + const url = `http://127.0.0.1:${JET_CONTROL_PORT}${path}`; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + console.warn(`[rnfb-e2e] jet-control POST ${path} status=${response.status}`); + } + } catch (err) { + console.warn(`[rnfb-e2e] jet-control POST ${path} failed: ${err?.message || err}`); + } +} + +function logOrchestrateState(state) { + console.log(`[rnfb-e2e] orchestrate-state=${state} ts=${new Date().toISOString()}`); + postJetControl('/orchestrate-state', { phase: state }); +} + +async function signalJetLaunchReady() { + console.log('[rnfb-e2e] signaling Jet launch-ready (permit test run)'); + await postJetControl('/launch-ready', {}); +} + +async function killJetForLaunchRetry(jetProcess) { + if (!jetProcess || jetProcess.killed) { + return; + } + + logOrchestrateState('launch-retry-kill-jet'); + console.warn('[rnfb-e2e] launch-retry: killing Jet before terminateApp/reboot'); + jetProcess.kill('SIGTERM'); + await sleep(500); + if (!jetProcess.killed) { + jetProcess.kill('SIGKILL'); + } + + try { + await waitForTcpPortClosed(JET_REMOTE_PORT, '127.0.0.1', KILL_JET_FOR_LAUNCH_RETRY_TIMEOUT_MS); + } catch (err) { + console.warn(`[rnfb-e2e] launch-retry: Jet port still open after kill: ${err?.message || err}`); + } +} + +async function launchAppWithRetry(launchArgs, { testsDir, onBeforeRelaunch } = {}) { const liveMetro = usesLiveMetro(); for (let launchAttempt = 1; launchAttempt <= LAUNCH_APP_MAX_ATTEMPTS; launchAttempt++) { @@ -622,6 +720,9 @@ async function launchAppWithRetry(launchArgs, { testsDir } = {}) { console.warn( `[rnfb-e2e] Retrying launchApp after launch failure (attempt ${launchAttempt}/${LAUNCH_APP_MAX_ATTEMPTS}) liveMetro=${liveMetro}`, ); + if (onBeforeRelaunch) { + await onBeforeRelaunch(); + } const terminateMs = await terminateAppWithTiming(`retry-${launchAttempt}`); if (terminateMs >= SLOW_TERMINATE_MS && testsDir && process.platform === 'darwin') { console.warn( @@ -652,51 +753,110 @@ async function launchAppWithRetry(launchArgs, { testsDir } = {}) { } } -function runJetE2eAttempt(attempt) { - const platform = detox.device.getPlatform(); - const testsDir = path.resolve(__dirname, '..'); - const jetArgs = - process.platform === 'win32' - ? ['jet', `--target=${platform}`] - : ['jet', `--target=${platform}`, '--coverage']; - +function createJetSession(jetArgs, testsDir) { let output = ''; - const jetProcess = spawn('yarn', jetArgs, { - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - cwd: testsDir, - }); + let jetProcess; + let ignoreExit = false; + let resolveExit; + let rejectExit; - jetProcess.stdout.on('data', chunk => { - const text = chunk.toString(); - output += text; - process.stdout.write(text); - }); - jetProcess.stderr.on('data', chunk => { - const text = chunk.toString(); - output += text; - process.stderr.write(text); + const exitPromise = new Promise((resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; }); - const exitPromise = new Promise((resolve, reject) => { - jetProcess.on('error', err => { + const bindProcess = proc => { + jetProcess = proc; + proc.stdout.on('data', chunk => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }); + proc.stderr.on('data', chunk => { + const text = chunk.toString(); + output += text; + process.stderr.write(text); + }); + proc.on('error', err => { + if (ignoreExit) { + console.warn(`[rnfb-e2e] ignoring Jet error during launch-retry: ${err?.message || err}`); + return; + } err.jetOutput = output; - reject(err); + rejectExit(err); }); - jetProcess.on('close', code => { + proc.on('close', code => { + if (ignoreExit) { + console.warn(`[rnfb-e2e] ignoring Jet exit code=${code} during launch-retry`); + return; + } if (code !== 0) { const err = new Error('Jet tests failed!'); err.jetOutput = output; err.jetExitCode = code; - reject(err); + rejectExit(err); return; } - resolve({ output }); + resolveExit({ output }); }); - }); + }; + + const spawnJet = () => { + const proc = spawn('yarn', jetArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + cwd: testsDir, + env: { + ...process.env, + RNFB_JET_DEFER_RUN: '1', + }, + }); + bindProcess(proc); + return proc; + }; + + spawnJet(); + + return { + get process() { + return jetProcess; + }, + get output() { + return output; + }, + exitPromise, + async respawnAfterLaunchRetry() { + ignoreExit = true; + try { + await killJetForLaunchRetry(jetProcess); + output += '\n[rnfb-e2e] --- jet respawn after launch retry ---\n'; + spawnJet(); + await waitForTcpPort(JET_REMOTE_PORT); + logOrchestrateState('launch-retry-jet-respawned'); + } finally { + ignoreExit = false; + } + }, + }; +} + +function runJetE2eAttempt(attempt) { + cachedUsesLiveMetro = undefined; + cacheUsesLiveMetro(); + + const platform = detox.device.getPlatform(); + const testsDir = path.resolve(__dirname, '..'); + const jetArgs = + process.platform === 'win32' + ? ['jet', `--target=${platform}`] + : ['jet', `--target=${platform}`, '--coverage']; + + const jetSession = createJetSession(jetArgs, testsDir); const orchestrate = async () => { + logOrchestrateState('jet-spawned'); console.log(`[rnfb-e2e] Jet attempt ${attempt}: waiting for port ${JET_REMOTE_PORT}`); + logOrchestrateState('port-wait'); await waitForTcpPort(JET_REMOTE_PORT); if (usesLiveMetro()) { console.log(`[rnfb-e2e] Jet attempt ${attempt}: waiting for Metro on port ${METRO_PORT}`); @@ -707,31 +867,45 @@ function runJetE2eAttempt(attempt) { ); } console.log(`[rnfb-e2e] Jet attempt ${attempt}: launching app`); + logOrchestrateState('launch-pending'); await launchAppWithRetry( { detoxURLBlacklistRegex: `.*`, // Avoid sync/idling blocking the main queue while Detox WS login is pending. detoxEnableSynchronization: 'NO', }, - { testsDir }, + { + testsDir, + onBeforeRelaunch: async () => { + await jetSession.respawnAfterLaunchRetry(); + }, + }, ); + logOrchestrateState('launch-ok'); + await signalJetLaunchReady(); + logOrchestrateState('tests-run-permitted'); }; const orchestratePromise = orchestrate(); - lastJetAttemptContext = { jetProcess, orchestratePromise }; + lastJetAttemptContext = { jetProcess: jetSession.process, orchestratePromise }; return Promise.race([ orchestratePromise - .then(() => exitPromise) + .then(() => { + logOrchestrateState('awaiting-jet-exit'); + return jetSession.exitPromise; + }) .catch(err => { - if (!jetProcess.killed) { - jetProcess.kill(); + logOrchestrateState('orchestrate-failed'); + if (!jetSession.process.killed) { + jetSession.process.kill(); } - err.jetOutput = output; + err.jetOutput = jetSession.output; throw err; }), - exitPromise.catch(err => { - err.jetOutput = output; + jetSession.exitPromise.catch(err => { + logOrchestrateState('jet-exited'); + err.jetOutput = jetSession.output; throw err; }), ]); @@ -772,6 +946,12 @@ describe('Jet Tests', function () { } } else if (isRetryableLaunchFailure(lastFailure)) { console.warn('[rnfb-e2e] Retrying after launch failure (Metro/bundle/FrontBoard)'); + } else if (isRetryableCloudQuotaFailure(lastOutput)) { + console.warn( + `[rnfb-e2e] Retrying after cloud quota pressure; cooling down ${CLOUD_QUOTA_RETRY_COOLDOWN_MS}ms`, + ); + logCloudPressureAnalysisPointer('jet-retry'); + await sleep(CLOUD_QUOTA_RETRY_COOLDOWN_MS); } await drainJetAttempt(platform); if (platform === 'ios' && process.platform === 'darwin') { @@ -793,6 +973,18 @@ describe('Jet Tests', function () { } catch (err) { lastFailure = err; logRetryEligibility(err, attempt); + if (isRetryableCloudQuotaFailure(err?.jetOutput || '')) { + await recordE2eCloudMetricFromHost({ + category: 'cloud-quota', + event: 'jet-failure', + attempt, + error: String(err?.message || err), + metadata: { + jetExitCode: err?.jetExitCode ?? null, + }, + }); + logCloudPressureAnalysisPointer('jet-failure-recorded'); + } const retryable = attempt === 1 && isRetryableE2eFailure(err); console.warn( `[rnfb-e2e] Jet attempt ${attempt} failed (retryable=${retryable}, exit=${err.jetExitCode ?? 'n/a'})`, diff --git a/tests/globals.js b/tests/globals.js index d3994d1dcf..8003dfb2f8 100644 --- a/tests/globals.js +++ b/tests/globals.js @@ -46,7 +46,7 @@ import shouldMatchers from 'should'; // [RNFB<--Event][πŸ“£] storage_event <- {...} // [RNFB<-Native][🟒] RNFBStorageModule.putString <- {...} // [TEST->Finish][βœ…] uploads a base64url string -globalThis.RNFBDebug = true; +globalThis.RNFBDebug = false; // this may be used to locate modular API errors quickly globalThis.RNFB_MODULAR_DEPRECATION_STRICT_MODE = true; @@ -226,6 +226,29 @@ global.FirebaseHelpers = { async updateRemoteConfigTemplate(operations) { return await this.callCloudHelperFunction('testFunctionRemoteConfigUpdateV2', operations); }, + async recordE2eCloudMetric(payload) { + if (!payload || typeof payload !== 'object') { + return; + } + + const metric = { + ...payload, + platform: global.Platform?.ios ? 'ios' : global.Platform?.android ? 'android' : 'other', + }; + + try { + await this.callCloudHelperFunction('e2eCloudMetricsV2', metric); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('[rnfb-e2e-metrics] record failed', String(error?.message || error)); + } + }, + async fetchE2eCloudMetricsSummary(lookbackHours = 24) { + const response = await this.callCloudHelperFunction('e2eCloudMetricsSummaryV2', { + lookbackHours, + }); + return response?.result ?? response; + }, }; global.android = { @@ -492,7 +515,8 @@ global.jet = { }, }; -// some tests flake in CI but we still run them locally -global.isCI = process.env.CI === 'true' || process.env.CI === true; // Used to tell our internals that we are running tests. globalThis.RNFBTest = true; +globalThis.recordE2eCloudMetric = payload => global.FirebaseHelpers.recordE2eCloudMetric(payload); +// some tests flake in CI but we still run them locally +global.isCI = process.env.CI === 'true' || process.env.CI === true; diff --git a/yarn.lock b/yarn.lock index 0a896764ca..c90e2f87d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16348,7 +16348,7 @@ __metadata: "jet@patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch": version: 0.9.0-dev.13 - resolution: "jet@patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch::version=0.9.0-dev.13&hash=7deef5" + resolution: "jet@patch:jet@npm%3A0.9.0-dev.13#~/.yarn/patches/jet-npm-0.9.0-dev.13-3321aeea6e.patch::version=0.9.0-dev.13&hash=da22b8" dependencies: "@types/mocha": "npm:^10.0.10" babel-plugin-istanbul: "npm:^7.0.0" @@ -16365,7 +16365,7 @@ __metadata: react-native: "*" bin: jet: jet.js - checksum: 10/0af938d9a3a409e90a97c0c6e3fe9296a3701fd0e1a973af9bc9ccc564ce5da8e83dead4d2a6019da2914846c85edf0b8cbff8bc5292d0831514dc4a5a947a31 + checksum: 10/aa8a2bf53d96324b584f97d8c33bf4c655c8bd9b8191ca0067c8a42fd0bc2627b35894d927c6fbd477cdd5a51ceec46bdb9cf1735af4242a372c86b101b80680 languageName: node linkType: hard @@ -19562,13 +19562,13 @@ __metadata: "mocha-remote-server@patch:mocha-remote-server@npm%3A1.13.2#~/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch": version: 1.13.2 - resolution: "mocha-remote-server@patch:mocha-remote-server@npm%3A1.13.2#~/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch::version=1.13.2&hash=04ee13" + resolution: "mocha-remote-server@patch:mocha-remote-server@npm%3A1.13.2#~/.yarn/patches/mocha-remote-server-npm-1.13.2-619a29d2e3.patch::version=1.13.2&hash=1e0a15" dependencies: debug: "npm:^4.3.4" flatted: "npm:^3.3.1" mocha-remote-common: "npm:1.13.2" ws: "npm:^8.17.1" - checksum: 10/cd9f2a1d15889e1fb7f3c63ae196ef8eea08ad9f0d3abc8ee7ff58f80edbddb7379c5fb322e710d5e6ebbce943157d683d806f1e207a7d7a2326852aa8666bfd + checksum: 10/c73f1cf7ea3c64be68f55ca42c79c26ccdbbc3d34d76b25ce355c822493e4f6f9069c905bd9f736e031c59610b8ff9fb2d621f312fb9f72059c995fb4a3f6e45 languageName: node linkType: hard