From f80685187553c7ec15cfd876f055e4228944f5e8 Mon Sep 17 00:00:00 2001 From: Nicola Corti Date: Wed, 6 Aug 2025 03:04:02 -0700 Subject: [PATCH] Store nightly outcome and compute daily broken/recovered from Firebase (#53066) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/53066 This diff introduces the mechanism for us to store the result of nightly 3p library integration on Firebase. Having the result store, we can now query the result from the previous day and report if the build is newly broken or recovered overnight. Changelog: [Internal] [Changed] - Reviewed By: cipolleschi Differential Revision: D79565536 fbshipit-source-id: ba839b2950462a7ca6186a163f93f062719304fb --- .../__tests__/firebaseUtils-test.js | 617 ++++++++++++++++++ .../collectNightlyOutcomes.js | 48 +- .github/workflow-scripts/firebaseUtils.js | 219 +++++++ .github/workflow-scripts/notifyDiscord.js | 72 +- .github/workflows/check-nightly.yml | 4 + .../workflows/test-libraries-on-nightlies.yml | 13 + 6 files changed, 957 insertions(+), 16 deletions(-) create mode 100644 .github/workflow-scripts/__tests__/firebaseUtils-test.js create mode 100644 .github/workflow-scripts/firebaseUtils.js diff --git a/.github/workflow-scripts/__tests__/firebaseUtils-test.js b/.github/workflow-scripts/__tests__/firebaseUtils-test.js new file mode 100644 index 00000000000..4b1c5f67e3f --- /dev/null +++ b/.github/workflow-scripts/__tests__/firebaseUtils-test.js @@ -0,0 +1,617 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +const { + FirebaseClient, + compareResults, + getYesterdayDate, + getTodayDate, +} = require('../firebaseUtils'); + +describe('FirebaseClient', () => { + const originalFetch = global.fetch; + const originalEnv = process.env; + + beforeEach(() => { + global.fetch = jest.fn(); + process.env = { + ...originalEnv, + FIREBASE_APP_EMAIL: 'test@example.com', + FIREBASE_APP_PASS: 'testpassword', + FIREBASE_APP_APIKEY: 'test-api-key', + FIREBASE_APP_PROJECTNAME: 'test-project', + }; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + global.fetch = originalFetch; + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with environment variables', () => { + const client = new FirebaseClient(); + expect(client.email).toBe('test@example.com'); + expect(client.password).toBe('testpassword'); + expect(client.apiKey).toBe('test-api-key'); + expect(client.projectId).toBe('test-project'); + expect(client.databaseUrl).toBe( + 'test-project-default-rtdb.firebaseio.com', + ); + expect(client.idToken).toBeNull(); + }); + }); + + describe('authenticate', () => { + it('should authenticate successfully', async () => { + const mockResponse = { + idToken: 'mock-id-token', + refreshToken: 'mock-refresh-token', + }; + + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(mockResponse)), + }); + + const client = new FirebaseClient(); + await client.authenticate(); + + expect(client.idToken).toBe('mock-id-token'); + expect(global.fetch).toHaveBeenCalledWith( + 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=test-api-key', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: 'test@example.com', + password: 'testpassword', + returnSecureToken: true, + }), + }, + ); + }); + + it('should throw error when email is missing', async () => { + delete process.env.FIREBASE_APP_EMAIL; + const client = new FirebaseClient(); + + await expect(client.authenticate()).rejects.toThrow( + 'Firebase credentials not found in environment variables', + ); + }); + + it('should throw error when password is missing', async () => { + delete process.env.FIREBASE_APP_PASS; + const client = new FirebaseClient(); + + await expect(client.authenticate()).rejects.toThrow( + 'Firebase credentials not found in environment variables', + ); + }); + + it('should handle authentication failure', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: jest.fn().mockResolvedValueOnce( + JSON.stringify({ + error: {message: 'Invalid credentials'}, + }), + ), + }); + + const client = new FirebaseClient(); + + await expect(client.authenticate()).rejects.toThrow( + 'HTTP 400: Invalid credentials', + ); + }); + }); + + describe('makeRequest', () => { + it('should make successful GET request', async () => { + const mockData = {test: 'data'}; + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(mockData)), + }); + + const client = new FirebaseClient(); + const result = await client.makeRequest('example.com', '/test', 'GET'); + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith('https://example.com/test', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('should make successful POST request with data', async () => { + const mockData = {success: true}; + const postData = {test: 'post data'}; + + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(mockData)), + }); + + const client = new FirebaseClient(); + const result = await client.makeRequest( + 'example.com', + '/test', + 'POST', + postData, + ); + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith('https://example.com/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postData), + }); + }); + + it('should handle non-JSON response', async () => { + const textResponse = 'plain text response'; + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(textResponse), + }); + + const client = new FirebaseClient(); + const result = await client.makeRequest('example.com', '/test', 'GET'); + + expect(result).toBe(textResponse); + }); + + it('should handle HTTP error with JSON error message', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValueOnce( + JSON.stringify({ + error: {message: 'Not found'}, + }), + ), + }); + + const client = new FirebaseClient(); + + await expect( + client.makeRequest('example.com', '/test', 'GET'), + ).rejects.toThrow('HTTP 404: Not found'); + }); + + it('should handle HTTP error with plain text error message', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValueOnce('Internal Server Error'), + }); + + const client = new FirebaseClient(); + + await expect( + client.makeRequest('example.com', '/test', 'GET'), + ).rejects.toThrow('HTTP 500: Internal Server Error'); + }); + }); + + describe('makeDatabaseRequest', () => { + it('should make database request with existing token', async () => { + const mockData = {test: 'data'}; + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(mockData)), + }); + + const client = new FirebaseClient(); + client.idToken = 'existing-token'; + + const result = await client.makeDatabaseRequest('2023-12-01', 'GET'); + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith( + 'https://test-project-default-rtdb.firebaseio.com/nightly-results/2023-12-01.json?auth=existing-token', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + }); + + it('should authenticate before making request if no token exists', async () => { + const authResponse = {idToken: 'new-token'}; + const dataResponse = {test: 'data'}; + + global.fetch + .mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(authResponse)), + }) + .mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(dataResponse)), + }); + + const client = new FirebaseClient(); + const result = await client.makeDatabaseRequest('2023-12-01', 'GET'); + + expect(result).toEqual(dataResponse); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(client.idToken).toBe('new-token'); + }); + + it('should make PUT request with data', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce('null'), + }); + + const client = new FirebaseClient(); + client.idToken = 'existing-token'; + const testData = [{library: 'test', status: 'success'}]; + + await client.makeDatabaseRequest('2023-12-01', 'PUT', testData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://test-project-default-rtdb.firebaseio.com/nightly-results/2023-12-01.json?auth=existing-token', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(testData), + }, + ); + }); + }); + + describe('storeResults', () => { + it('should store results successfully', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce('null'), + }); + + const client = new FirebaseClient(); + client.idToken = 'existing-token'; + const results = [{library: 'test', status: 'success'}]; + + await client.storeResults('2023-12-01', results); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://test-project-default-rtdb.firebaseio.com/nightly-results/2023-12-01.json?auth=existing-token', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(results), + }, + ); + expect(console.log).toHaveBeenCalledWith( + 'Successfully stored results for 2023-12-01', + ); + }); + }); + + describe('getResults', () => { + it('should retrieve results successfully', async () => { + const mockResults = [{library: 'test', status: 'success'}]; + global.fetch.mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValueOnce(JSON.stringify(mockResults)), + }); + + const client = new FirebaseClient(); + client.idToken = 'existing-token'; + + const results = await client.getResults('2023-12-01'); + + expect(results).toEqual(mockResults); + }); + + it('should return null for 404 errors', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValueOnce('Not Found'), + }); + + const client = new FirebaseClient(); + client.idToken = 'existing-token'; + + const results = await client.getResults('2023-12-01'); + + expect(results).toBeNull(); + }); + + it('should throw error for non-404 HTTP errors', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValueOnce('Internal Server Error'), + }); + + const client = new FirebaseClient(); + client.idToken = 'existing-token'; + + await expect(client.getResults('2023-12-01')).rejects.toThrow( + 'HTTP 500: Internal Server Error', + ); + }); + }); +}); + +describe('compareResults', () => { + it('should handle null previous results', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const result = compareResults(currentResults, null); + + expect(result).toEqual({ + broken: [], + recovered: [], + newFailures: [{library: 'lib1', platform: 'iOS', status: 'failed'}], + }); + }); + + it('should handle undefined previous results', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + ]; + + const result = compareResults(currentResults, undefined); + + expect(result).toEqual({ + broken: [], + recovered: [], + newFailures: [{library: 'lib1', platform: 'iOS', status: 'failed'}], + }); + }); + + it('should identify broken tests', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const previousResults = [ + {library: 'lib1', platform: 'iOS', status: 'success'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([ + { + library: 'lib1', + platform: 'iOS', + previousStatus: 'success', + currentStatus: 'failed', + }, + ]); + expect(result.recovered).toEqual([]); + }); + + it('should identify recovered tests', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'success'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const previousResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([]); + expect(result.recovered).toEqual([ + { + library: 'lib1', + platform: 'iOS', + previousStatus: 'failed', + currentStatus: 'success', + }, + ]); + }); + + it('should identify both broken and recovered tests', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + {library: 'lib3', platform: 'iOS', status: 'success'}, + ]; + + const previousResults = [ + {library: 'lib1', platform: 'iOS', status: 'success'}, + {library: 'lib2', platform: 'Android', status: 'failed'}, + {library: 'lib3', platform: 'iOS', status: 'success'}, + ]; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([ + { + library: 'lib1', + platform: 'iOS', + previousStatus: 'success', + currentStatus: 'failed', + }, + ]); + expect(result.recovered).toEqual([ + { + library: 'lib2', + platform: 'Android', + previousStatus: 'failed', + currentStatus: 'success', + }, + ]); + }); + + it('should handle tests that are not in previous results', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const previousResults = [ + {library: 'lib1', platform: 'iOS', status: 'success'}, + ]; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([ + { + library: 'lib1', + platform: 'iOS', + previousStatus: 'success', + currentStatus: 'failed', + }, + ]); + expect(result.recovered).toEqual([]); + }); + + it('should handle empty current results', () => { + const currentResults = []; + const previousResults = [ + {library: 'lib1', platform: 'iOS', status: 'success'}, + ]; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([]); + expect(result.recovered).toEqual([]); + }); + + it('should handle empty previous results', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'failed'}, + ]; + const previousResults = []; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([]); + expect(result.recovered).toEqual([]); + // When previousResults is an empty array (not null/undefined), + // the function doesn't return newFailures property + expect(result.newFailures).toBeUndefined(); + }); + + it('should handle different status values', () => { + const currentResults = [ + {library: 'lib1', platform: 'iOS', status: 'timeout'}, + {library: 'lib2', platform: 'Android', status: 'success'}, + ]; + + const previousResults = [ + {library: 'lib1', platform: 'iOS', status: 'success'}, + {library: 'lib2', platform: 'Android', status: 'error'}, + ]; + + const result = compareResults(currentResults, previousResults); + + expect(result.broken).toEqual([ + { + library: 'lib1', + platform: 'iOS', + previousStatus: 'success', + currentStatus: 'timeout', + }, + ]); + expect(result.recovered).toEqual([ + { + library: 'lib2', + platform: 'Android', + previousStatus: 'error', + currentStatus: 'success', + }, + ]); + }); +}); + +describe('getYesterdayDate', () => { + it("should return yesterday's date in YYYY-MM-DD format", () => { + const mockDate = new Date('2023-12-15T10:30:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + + const result = getYesterdayDate(); + + expect(result).toBe('2023-12-14'); + + global.Date.mockRestore(); + }); + + it('should handle month boundary correctly', () => { + const mockDate = new Date('2023-12-01T10:30:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + + const result = getYesterdayDate(); + + expect(result).toBe('2023-11-30'); + + global.Date.mockRestore(); + }); + + it('should handle year boundary correctly', () => { + const mockDate = new Date('2024-01-01T10:30:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + + const result = getYesterdayDate(); + + expect(result).toBe('2023-12-31'); + + global.Date.mockRestore(); + }); +}); + +describe('getTodayDate', () => { + it("should return today's date in YYYY-MM-DD format", () => { + const mockDate = new Date('2023-12-15T10:30:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + + const result = getTodayDate(); + + expect(result).toBe('2023-12-15'); + + global.Date.mockRestore(); + }); + + it('should handle different times of day correctly', () => { + const mockDate = new Date('2023-12-15T23:59:59Z'); + jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + + const result = getTodayDate(); + + expect(result).toBe('2023-12-15'); + + global.Date.mockRestore(); + }); +}); diff --git a/.github/workflow-scripts/collectNightlyOutcomes.js b/.github/workflow-scripts/collectNightlyOutcomes.js index 1c811c1c1ed..8f83d07ff03 100644 --- a/.github/workflow-scripts/collectNightlyOutcomes.js +++ b/.github/workflow-scripts/collectNightlyOutcomes.js @@ -11,8 +11,15 @@ const fs = require('fs'); const path = require('path'); const { prepareFailurePayload, + prepareComparisonPayload, sendMessageToDiscord, } = require('./notifyDiscord'); +const { + FirebaseClient, + compareResults, + getYesterdayDate, + getTodayDate, +} = require('./firebaseUtils'); function readOutcomes() { const baseDir = '/tmp'; @@ -103,16 +110,51 @@ async function collectResults(discordWebHook) { const outcomes = readOutcomes(); const failures = printFailures(outcomes); + // Send failure notification if there are current failures if (failures.length > 0) { if (discordWebHook) { - console.log('Sending to discord'); + console.log('Sending current failures to Discord...'); await notifyDiscord(discordWebHook, failures); } else { - console.log('Web hook not set'); + console.log('Discord webhook not set'); } process.exit(1); } - console.log('✅ All tests passed!'); + + // Initialize Firebase client + const firebaseClient = new FirebaseClient(); + const today = getTodayDate(); + const yesterday = getYesterdayDate(); + + try { + // Store today's results in Firebase + console.log(`Storing results for ${today} in Firebase...`); + await firebaseClient.storeResults(today, outcomes); + + // Get yesterday's results for comparison + console.log(`Retrieving results for ${yesterday} from Firebase...`); + const yesterdayResults = await firebaseClient.getResults(yesterday); + + // Compare results and identify broken/recovered tests + const {broken, recovered} = compareResults(outcomes, yesterdayResults); + + console.log( + `Found ${broken.length} newly broken tests and ${recovered.length} recovered tests`, + ); + + // Send comparison message to Discord if there are changes + if (discordWebHook && (broken.length > 0 || recovered.length > 0)) { + console.log('Sending comparison results to Discord...'); + const comparisonMessage = prepareComparisonPayload(broken, recovered); + await sendMessageToDiscord(discordWebHook, comparisonMessage); + } + + console.log('✅ All tests passed!'); + } catch (error) { + console.error('Error in collectResults:', error); + // If Firebase fails but there are no test failures, don't fail the workflow + console.log('⚠️ Firebase operations failed, but all tests passed'); + } } module.exports = { diff --git a/.github/workflow-scripts/firebaseUtils.js b/.github/workflow-scripts/firebaseUtils.js new file mode 100644 index 00000000000..9340da9f21c --- /dev/null +++ b/.github/workflow-scripts/firebaseUtils.js @@ -0,0 +1,219 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// We connect to firebase using a plain HTTP request because we don't want to +// add yet another devDependency to the react-native monorepo. +class FirebaseClient { + constructor() { + this.email = process.env.FIREBASE_APP_EMAIL; + this.password = process.env.FIREBASE_APP_PASS; + this.apiKey = process.env.FIREBASE_APP_APIKEY; + this.projectId = process.env.FIREBASE_APP_PROJECTNAME; + this.databaseUrl = `${this.projectId}-default-rtdb.firebaseio.com`; + this.idToken = null; + } + + async authenticate() { + if (!this.email || !this.password) { + throw new Error( + 'Firebase credentials not found in environment variables', + ); + } + + const authData = { + email: this.email, + password: this.password, + returnSecureToken: true, + }; + + const response = await this.makeRequest( + 'identitytoolkit.googleapis.com', + `/v1/accounts:signInWithPassword?key=${this.apiKey}`, + 'POST', + authData, + ); + + this.idToken = response.idToken; + return; + } + + /** + * Make a database request for a specific date + * @param {string} date - Date in YYYY-MM-DD format + * @param {string} method - HTTP method + * @param {*} data - Data to send (optional) + * @returns {Promise<*>} - Response data + */ + async makeDatabaseRequest(date, method, data = null) { + if (!this.idToken) { + await this.authenticate(); + } + + const path = `/nightly-results/${date}.json?auth=${this.idToken}`; + return this.makeRequest(this.databaseUrl, path, method, data); + } + + /** + * Store test results for a specific date + * @param {string} date - Date in YYYY-MM-DD format + * @param {Array} results - Array of test results + * @returns {Promise} + */ + async storeResults(date, results) { + await this.makeDatabaseRequest(date, 'PUT', results); + console.log(`Successfully stored results for ${date}`); + } + + /** + * Retrieve test results for a specific date + * @param {string} date - Date in YYYY-MM-DD format + * @returns {Promise|null>} - Array of test results or null if not found + */ + async getResults(date) { + try { + return await this.makeDatabaseRequest(date, 'GET'); + } catch (error) { + if (error.message.includes('404')) { + return null; // No results found for this specific date. + } + throw error; + } + } + + async makeRequest(hostname, path, method, data = null) { + const url = `https://${hostname}${path}`; + const options = { + method, + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (data) { + options.body = JSON.stringify(data); + } + + const response = await fetch(url, options); + const responseText = await response.text(); + + if (!response.ok) { + let errorMessage; + try { + const parsedError = JSON.parse(responseText); + errorMessage = parsedError.error?.message || responseText; + } catch { + errorMessage = responseText; + } + throw new Error(`HTTP ${response.status}: ${errorMessage}`); + } + + try { + return JSON.parse(responseText); + } catch { + return responseText; + } + } +} + +/** + * Compare current results with previous day's results + * @param {Array} currentResults - Today's test results + * @param {Array} previousResults - Yesterday's test results + * @returns {Object} - Object containing broken and recovered tests + */ +function compareResults(currentResults, previousResults) { + if (!previousResults) { + return { + broken: [], + recovered: [], + newFailures: currentResults.filter(result => result.status !== 'success'), + }; + } + + // Create maps for easier lookup + const currentMap = new Map(); + const previousMap = new Map(); + + currentResults.forEach(result => { + const key = `${result.library}-${result.platform}`; + currentMap.set(key, result); + }); + + previousResults.forEach(result => { + const key = `${result.library}-${result.platform}`; + previousMap.set(key, result); + }); + + const broken = []; + const recovered = []; + + // Check for broken tests (was success, now failed) + for (const [key, currentResult] of currentMap) { + const previousResult = previousMap.get(key); + if (previousResult) { + if ( + previousResult.status === 'success' && + currentResult.status !== 'success' + ) { + broken.push({ + library: currentResult.library, + platform: currentResult.platform, + previousStatus: previousResult.status, + currentStatus: currentResult.status, + }); + } + } + } + + // Check for recovered tests (was failed, now success) + for (const [key, currentResult] of currentMap) { + const previousResult = previousMap.get(key); + if (previousResult) { + if ( + previousResult.status !== 'success' && + currentResult.status === 'success' + ) { + recovered.push({ + library: currentResult.library, + platform: currentResult.platform, + previousStatus: previousResult.status, + currentStatus: currentResult.status, + }); + } + } + } + + return {broken, recovered}; +} + +/** + * Get yesterday's date in YYYY-MM-DD format + * @returns {string} - Yesterday's date + */ +function getYesterdayDate() { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + return yesterday.toISOString().split('T')[0]; +} + +/** + * Get today's date in YYYY-MM-DD format + * @returns {string} - Today's date + */ +function getTodayDate() { + const today = new Date(); + return today.toISOString().split('T')[0]; +} + +module.exports = { + FirebaseClient, + compareResults, + getYesterdayDate, + getTodayDate, +}; diff --git a/.github/workflow-scripts/notifyDiscord.js b/.github/workflow-scripts/notifyDiscord.js index d7a84f25390..618dbb996e7 100644 --- a/.github/workflow-scripts/notifyDiscord.js +++ b/.github/workflow-scripts/notifyDiscord.js @@ -41,20 +41,12 @@ async function sendMessageToDiscord(webHook, message) { } /** - * Prepares a formatted Discord message payload from a list of failures. - * @param {Array} failures - List of failures to format - * @returns {Object} - The formatted Discord message payload + * Sorts jobs by platform first, then by library name. + * @param {Array} jobs - Array of jobs with platform and library properties + * @returns {Array} - Sorted array of jobs */ -function prepareFailurePayload(failures) { - if (!failures || failures.length === 0) { - return { - content: - '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nNo failures to report.', - }; - } - - // Sort failures by platform and then by library name - const sortedFailures = [...failures].sort((a, b) => { +function sortResultsByPlatformAndLibrary(jobs) { + return [...jobs].sort((a, b) => { // First sort by platform const platformA = a.platform || 'Unknown'; const platformB = b.platform || 'Unknown'; @@ -68,6 +60,23 @@ function prepareFailurePayload(failures) { const libraryB = b.library || 'Unknown'; return libraryA.localeCompare(libraryB); }); +} + +/** + * Prepares a formatted Discord message payload from a list of failures. + * @param {Array} failures - List of failures to format + * @returns {Object} - The formatted Discord message payload + */ +function prepareFailurePayload(failures) { + if (!failures || failures.length === 0) { + return { + content: + '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nNo failures to report.', + }; + } + + // Sort failures by platform and then by library name + const sortedFailures = sortResultsByPlatformAndLibrary(failures); // Format the failures into a message const formattedFailures = sortedFailures @@ -83,8 +92,45 @@ function prepareFailurePayload(failures) { }; } +/** + * Prepares a formatted Discord message payload for broken and recovered nightly jobs. + * @param {Array} broken - List of newly broken jobs + * @param {Array} recovered - List of recovered jobs + * @returns {Object} - The formatted Discord message payload + */ +function prepareComparisonPayload(broken, recovered) { + let content = '📊 **React Native Nightly Integration Status Update** 📊\n\n'; + + if (broken.length === 0 && recovered.length === 0) { + content += + 'No changes from yesterday - all nightly jobs maintained their previous status.'; + } else { + if (broken.length > 0) { + content += '🔴 **Newly Broken Jobs:**\n'; + const sortedBroken = sortResultsByPlatformAndLibrary(broken); + + sortedBroken.forEach(job => { + content += `❌ [${job.platform}] ${job.library} (was ${job.previousStatus}, now ${job.currentStatus})\n`; + }); + content += '\n'; + } + + if (recovered.length > 0) { + content += '🟢 **Recovered Jobs:**\n'; + const sortedRecovered = sortResultsByPlatformAndLibrary(recovered); + + sortedRecovered.forEach(job => { + content += `✅ [${job.platform}] ${job.library} (was ${job.previousStatus}, now ${job.currentStatus})\n`; + }); + } + } + + return {content}; +} + // Export the functions using CommonJS syntax module.exports = { prepareFailurePayload, + prepareComparisonPayload, sendMessageToDiscord, }; diff --git a/.github/workflows/check-nightly.yml b/.github/workflows/check-nightly.yml index ccabad1e722..183b21cb662 100644 --- a/.github/workflows/check-nightly.yml +++ b/.github/workflows/check-nightly.yml @@ -32,3 +32,7 @@ jobs: needs: check-nightly secrets: discord_webhook_url: ${{ secrets.NIGHTLY_DISCORD_WEBHOOK }} + firebase_app_email: ${{ secrets.FIREBASE_APP_EMAIL }} + firebase_app_pass: ${{ secrets.FIREBASE_APP_PASS }} + firebase_app_projectname: ${{ secrets.FIREBASE_APP_PROJECTNAME }} + firebase_app_apikey: ${{ secrets.FIREBASE_APP_APIKEY }} diff --git a/.github/workflows/test-libraries-on-nightlies.yml b/.github/workflows/test-libraries-on-nightlies.yml index 59c61a0754e..b827ebd21f4 100644 --- a/.github/workflows/test-libraries-on-nightlies.yml +++ b/.github/workflows/test-libraries-on-nightlies.yml @@ -5,6 +5,14 @@ on: secrets: discord_webhook_url: required: true + firebase_app_email: + required: true + firebase_app_pass: + required: true + firebase_app_apikey: + required: true + firebase_app_projectname: + required: true # We use the matrix.library entry to specify the dependency we want to use @@ -94,6 +102,11 @@ jobs: path: /tmp - name: Collect failures uses: actions/github-script@v6 + env: + FIREBASE_APP_EMAIL: ${{ secrets.firebase_app_email }} + FIREBASE_APP_PASS: ${{ secrets.firebase_app_pass }} + FIREBASE_APP_APIKEY: ${{ secrets.firebase_app_apikey }} + FIREBASE_APP_PROJECTNAME: ${{ secrets.firebase_app_projectname }} with: script: | const {collectResults} = require('./.github/workflow-scripts/collectNightlyOutcomes.js');