diff --git a/.github/workflow-scripts/__tests__/notifyDiscord-test.js b/.github/workflow-scripts/__tests__/notifyDiscord-test.js new file mode 100644 index 00000000000..c87a293e20c --- /dev/null +++ b/.github/workflow-scripts/__tests__/notifyDiscord-test.js @@ -0,0 +1,189 @@ +/** + * 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 { + prepareFailurePayload, + sendMessageToDiscord, +} = require('../notifyDiscord'); + +describe('prepareFailurePayload', () => { + it('should handle undefined failures', () => { + const message = prepareFailurePayload(undefined); + expect(message).toEqual({ + content: + '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nNo failures to report.', + }); + }); + + it('should handle empty failures array', () => { + const message = prepareFailurePayload([]); + expect(message).toEqual({ + content: + '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nNo failures to report.', + }); + }); + + it('should format a single failure correctly', () => { + const failures = [ + { + library: 'react-native-reanimated', + platform: 'iOS', + }, + ]; + + const message = prepareFailurePayload(failures); + expect(message).toEqual({ + content: + '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nThe integration of libraries with React Native nightly failed for the following libraries:\n\n❌ [iOS] react-native-reanimated', + }); + }); + + it('should sort multiple failures by platform and library name', () => { + const failures = [ + { + library: 'react-native-reanimated', + platform: 'iOS', + }, + { + library: 'react-native-gesture-handler', + platform: 'Android', + }, + { + library: 'react-native-screens', + platform: 'iOS', + }, + { + library: 'react-native-svg', + platform: 'Android', + }, + ]; + + const message = prepareFailurePayload(failures); + + // The failures should be sorted: first Android (alphabetically), then iOS + // Within each platform, libraries should be sorted alphabetically + expect(message).toEqual({ + content: + '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nThe integration of libraries with React Native nightly failed for the following libraries:\n\n❌ [Android] react-native-gesture-handler\n❌ [Android] react-native-svg\n❌ [iOS] react-native-reanimated\n❌ [iOS] react-native-screens', + }); + }); + + it('should handle failures with missing properties', () => { + const failures = [ + { + // Missing library + platform: 'iOS', + }, + { + library: 'react-native-gesture-handler', + // Missing platform + }, + { + // Both missing + }, + ]; + + const message = prepareFailurePayload(failures); + + expect(message).toEqual({ + content: + '⚠️ **React Native Nightly Integration Failures** ⚠️\n\nThe integration of libraries with React Native nightly failed for the following libraries:\n\n❌ [iOS] Unknown\n❌ [Unknown] react-native-gesture-handler\n❌ [Unknown] Unknown', + }); + }); +}); + +describe('sendMessageToDiscord', () => { + // Store the original fetch function + const originalFetch = global.fetch; + + // Setup and teardown for each test + beforeEach(() => { + // Mock the global fetch function + global.fetch = jest.fn(); + // Silence console logs during tests + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restore the original fetch function + global.fetch = originalFetch; + // Restore console functions + jest.restoreAllMocks(); + }); + + it('should throw an error if webhook URL is missing', async () => { + await expect(sendMessageToDiscord(null, {})).rejects.toThrow( + 'Discord webhook URL is missing', + ); + }); + + it('should send a message successfully', async () => { + // Mock a successful response + global.fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const webhook = 'https://discord.com/api/webhooks/123/abc'; + const message = {content: 'Test message'}; + + await expect(sendMessageToDiscord(webhook, message)).resolves.not.toThrow(); + + // Verify fetch was called with the right arguments + expect(global.fetch).toHaveBeenCalledWith(webhook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); + + // Verify console.log was called + expect(console.log).toHaveBeenCalledWith( + 'Successfully sent message to Discord', + ); + }); + + it('should throw an error if the response is not ok', async () => { + // Mock a failed response + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: jest.fn().mockResolvedValueOnce('Bad Request'), + }); + + const webhook = 'https://discord.com/api/webhooks/123/abc'; + const message = {content: 'Test message'}; + + await expect(sendMessageToDiscord(webhook, message)).rejects.toThrow( + 'HTTP status code: 400', + ); + + // Verify console.error was called + expect(console.error).toHaveBeenCalledWith( + 'Failed to send message to Discord: 400 Bad Request', + ); + }); + + it('should throw an error if fetch fails', async () => { + // Mock a network error + const networkError = new Error('Network error'); + global.fetch.mockRejectedValueOnce(networkError); + + const webhook = 'https://discord.com/api/webhooks/123/abc'; + const message = {content: 'Test message'}; + + await expect(sendMessageToDiscord(webhook, message)).rejects.toThrow( + 'Network error', + ); + }); +}); diff --git a/.github/workflow-scripts/collectNightlyOutcomes.js b/.github/workflow-scripts/collectNightlyOutcomes.js index 9fca62a7ba0..1c811c1c1ed 100644 --- a/.github/workflow-scripts/collectNightlyOutcomes.js +++ b/.github/workflow-scripts/collectNightlyOutcomes.js @@ -9,6 +9,10 @@ const fs = require('fs'); const path = require('path'); +const { + prepareFailurePayload, + sendMessageToDiscord, +} = require('./notifyDiscord'); function readOutcomes() { const baseDir = '/tmp'; @@ -51,26 +55,67 @@ function readOutcomes() { function printFailures(outcomes) { console.log('Printing failures...'); - let failures = 0; + let failedLibraries = []; outcomes.forEach(entry => { if (entry.status !== 'success') { console.log( `❌ [${entry.platform}] ${entry.library} failed with status ${entry.status}`, ); - failures++; + failedLibraries.push({ + library: entry.library, + platform: entry.platform, + }); } }); - return failures > 0; + return failedLibraries; } -function collectResults() { +/** + * Sends a message to Discord with the list of failures. + * @param {string} webHook - The Discord webhook URL + * @param {Array} failures - List of failures to report + * @returns {Promise} - A promise that resolves when the message is sent + */ +async function notifyDiscord(webHook, failures) { + if (!webHook) { + console.error('Discord webhook URL is missing'); + return; + } + + if (!failures || failures.length === 0) { + console.log('No failures to report to Discord'); + return; + } + + try { + // Use the prepareFailurePayload function to format the message + const message = prepareFailurePayload(failures); + + // Use the sendMessageToDiscord function to send the message + await sendMessageToDiscord(webHook, message); + } catch (error) { + console.error('Error in notifyDiscord function:', error); + throw error; + } +} + +async function collectResults(discordWebHook) { const outcomes = readOutcomes(); const failures = printFailures(outcomes); - if (failures) { + + if (failures.length > 0) { + if (discordWebHook) { + console.log('Sending to discord'); + await notifyDiscord(discordWebHook, failures); + } else { + console.log('Web hook not set'); + } process.exit(1); } console.log('✅ All tests passed!'); } + module.exports = { collectResults, + notifyDiscord, }; diff --git a/.github/workflow-scripts/notifyDiscord.js b/.github/workflow-scripts/notifyDiscord.js new file mode 100644 index 00000000000..d7a84f25390 --- /dev/null +++ b/.github/workflow-scripts/notifyDiscord.js @@ -0,0 +1,90 @@ +/** + * 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 + */ + +/** + * Sends a message to Discord using the webhook URL. + * @param {string} webHook - The Discord webhook URL + * @param {Object} message - The message to send + * @returns {Promise} - A promise that resolves when the message is sent + */ +async function sendMessageToDiscord(webHook, message) { + if (!webHook) { + throw new Error('Discord webhook URL is missing'); + } + + // Send the request using fetch + const response = await fetch(webHook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); + + // Handle the response + if (response.ok) { + console.log('Successfully sent message to Discord'); + return; + } else { + const errorText = await response.text(); + console.error( + `Failed to send message to Discord: ${response.status} ${errorText}`, + ); + throw new Error(`HTTP status code: ${response.status}`); + } +} + +/** + * 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 = [...failures].sort((a, b) => { + // First sort by platform + const platformA = a.platform || 'Unknown'; + const platformB = b.platform || 'Unknown'; + + if (platformA !== platformB) { + return platformA.localeCompare(platformB); + } + + // Then sort by library name + const libraryA = a.library || 'Unknown'; + const libraryB = b.library || 'Unknown'; + return libraryA.localeCompare(libraryB); + }); + + // Format the failures into a message + const formattedFailures = sortedFailures + .map(failure => { + const library = failure.library || 'Unknown'; + const platform = failure.platform || 'Unknown'; + return `❌ [${platform}] ${library}`; + }) + .join('\n'); + + return { + content: `⚠️ **React Native Nightly Integration Failures** ⚠️\n\nThe integration of libraries with React Native nightly failed for the following libraries:\n\n${formattedFailures}`, + }; +} + +// Export the functions using CommonJS syntax +module.exports = { + prepareFailurePayload, + sendMessageToDiscord, +}; diff --git a/.github/workflows/check-nightly.yml b/.github/workflows/check-nightly.yml index 95974e96570..2998bcf99c9 100644 --- a/.github/workflows/check-nightly.yml +++ b/.github/workflows/check-nightly.yml @@ -30,3 +30,5 @@ jobs: test-libraries: uses: ./.github/workflows/test-libraries-on-nightlies.yml needs: check-nightly + secrets: + discord_webhook_url: ${{ secrets.NIGHTLY_DISCORD_WEBHOOK }} diff --git a/.github/workflows/test-libraries-on-nightlies.yml b/.github/workflows/test-libraries-on-nightlies.yml index 2951dbc2005..930fb7388ee 100644 --- a/.github/workflows/test-libraries-on-nightlies.yml +++ b/.github/workflows/test-libraries-on-nightlies.yml @@ -2,6 +2,9 @@ name: Test Libraries on Nightlies on: workflow_call: + secrets: + discord_webhook_url: + required: true # We use the matrix.library entry to specify the dependency we want to use @@ -93,4 +96,4 @@ jobs: with: script: | const {collectResults} = require('./.github/workflow-scripts/collectNightlyOutcomes.js'); - collectResults(); + await collectResults('${{secrets.discord_webhook_url}}');