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');