diff --git a/change/@azure-msal-browser-cc7b4b74-d9b1-4614-ab89-ac8dfb792a3f.json b/change/@azure-msal-browser-cc7b4b74-d9b1-4614-ab89-ac8dfb792a3f.json new file mode 100644 index 0000000000..a424e7c7cd --- /dev/null +++ b/change/@azure-msal-browser-cc7b4b74-d9b1-4614-ab89-ac8dfb792a3f.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Clean up BroadcastChannel resources to fix hanging tests #8045", + "packageName": "@azure/msal-browser", + "email": "kshabelko@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/lib/msal-browser/package.json b/lib/msal-browser/package.json index 600dd5af35..bc1c609c05 100644 --- a/lib/msal-browser/package.json +++ b/lib/msal-browser/package.json @@ -68,7 +68,7 @@ "clean:coverage": "rimraf ../../.nyc_output/*", "lint": "eslint src --ext .ts", "lint:fix": "npm run lint -- --fix", - "test": "jest --forceExit", + "test": "jest", "test:coverage": "jest --coverage", "test:coverage:only": "npm run clean:coverage && npm run test:coverage", "build:all": "cd ../.. && npm run build --workspace=@azure/msal-common --workspace=@azure/msal-browser", diff --git a/lib/msal-browser/test/utils/BridgeSetup.ts b/lib/msal-browser/test/utils/BridgeSetup.ts index a68a66e66f..15b5d240c7 100644 --- a/lib/msal-browser/test/utils/BridgeSetup.ts +++ b/lib/msal-browser/test/utils/BridgeSetup.ts @@ -23,4 +23,21 @@ beforeAll(() => { window.nestedAppAuthBridge = new MockBridge(); } }); -afterAll(() => {}); + +afterEach(() => { + // Clean up BroadcastChannels after each test + if ((global as any).forceCleanupMsalBroadcastChannels) { + (global as any).forceCleanupMsalBroadcastChannels(); + } else if ((global as any).cleanupMsalBroadcastChannels) { + (global as any).cleanupMsalBroadcastChannels(); + } +}); + +afterAll(() => { + // Final aggressive cleanup + if ((global as any).forceCleanupMsalBroadcastChannels) { + (global as any).forceCleanupMsalBroadcastChannels(); + } else if ((global as any).cleanupMsalBroadcastChannels) { + (global as any).cleanupMsalBroadcastChannels(); + } +}); diff --git a/shared-configs/jest-config/BroadcastChannelTracker.js b/shared-configs/jest-config/BroadcastChannelTracker.js new file mode 100644 index 0000000000..1650d7a0ed --- /dev/null +++ b/shared-configs/jest-config/BroadcastChannelTracker.js @@ -0,0 +1,94 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Shared BroadcastChannel tracking utility for test environments. + * This module provides consistent tracking and cleanup functionality + * across different test environments (browser/jsdom and Node.js). + */ + +/** + * Creates a BroadcastChannel tracker for a given environment + * @param {Function} getBroadcastChannel - Function that returns the BroadcastChannel constructor for the environment + * @returns {Object} Object containing TrackedBroadcastChannel class and cleanup functions + */ +function createBroadcastChannelTracker(getBroadcastChannel) { + const activeBroadcastChannels = new Set(); + const OriginalBroadcastChannel = getBroadcastChannel(); + + class TrackedBroadcastChannel extends OriginalBroadcastChannel { + constructor(name) { + super(name); + activeBroadcastChannels.add(this); + } + + close() { + super.close(); + activeBroadcastChannels.delete(this); + } + } + + // Basic cleanup function + function cleanupBroadcastChannels() { + activeBroadcastChannels.forEach((channel) => { + try { + channel.close(); + } catch (error) { + // Ignore cleanup errors + } + }); + activeBroadcastChannels.clear(); + } + + // Enhanced cleanup function with additional reset logic + function forceCleanupBroadcastChannels() { + // First try graceful cleanup + cleanupBroadcastChannels(); + + // Add more aggressive cleanup if needed for browser environments + if (typeof window !== "undefined" && window.BroadcastChannel) { + // Reset to original BroadcastChannel to prevent further leaks + window.BroadcastChannel = OriginalBroadcastChannel; + // Then restore tracked version + window.BroadcastChannel = TrackedBroadcastChannel; + } + + // Clear any remaining instances in case some weren't tracked + activeBroadcastChannels.clear(); + } + + return { + TrackedBroadcastChannel, + cleanupBroadcastChannels, + forceCleanupBroadcastChannels, + OriginalBroadcastChannel + }; +} + +// Node.js-specific setup for @jest-environment node tests +// This automatically sets up BroadcastChannel tracking when this module is imported +if (typeof window === "undefined" && typeof global !== "undefined") { + // Create BroadcastChannel tracker for Node.js environment + const { + TrackedBroadcastChannel, + cleanupBroadcastChannels + } = createBroadcastChannelTracker(() => { + const { BroadcastChannel } = require('worker_threads'); + return BroadcastChannel; + }); + + // Replace global BroadcastChannel with tracked version + global.BroadcastChannel = TrackedBroadcastChannel; + + // Add cleanup hooks for Node.js test environment + if (typeof afterEach !== "undefined" && typeof afterAll !== "undefined") { + afterEach(cleanupBroadcastChannels); + afterAll(cleanupBroadcastChannels); + } +} + +module.exports = { + createBroadcastChannelTracker +}; diff --git a/shared-configs/jest-config/setupGlobals.cjs b/shared-configs/jest-config/setupGlobals.cjs index a4a36f9ed6..ea3cc1ceda 100644 --- a/shared-configs/jest-config/setupGlobals.cjs +++ b/shared-configs/jest-config/setupGlobals.cjs @@ -6,10 +6,22 @@ const crypto = require("crypto"); const { TextDecoder, TextEncoder } = require("util"); const { BroadcastChannel, MessageChannel } = require("worker_threads"); +const { createBroadcastChannelTracker } = require("./BroadcastChannelTracker"); + +// Create BroadcastChannel tracker for browser/jsdom environment +const { + TrackedBroadcastChannel, + cleanupBroadcastChannels, + forceCleanupBroadcastChannels +} = createBroadcastChannelTracker(() => BroadcastChannel); + +// Add cleanup functions to global for use in test setup files +global.cleanupMsalBroadcastChannels = cleanupBroadcastChannels; +global.forceCleanupMsalBroadcastChannels = forceCleanupBroadcastChannels; try { Object?.defineProperties(global.self, { - "crypto": { + crypto: { value: { subtle: crypto.webcrypto.subtle, getRandomValues(dataBuffer) { @@ -18,20 +30,20 @@ try { randomUUID() { return crypto.randomUUID(); }, - } + }, + }, + TextDecoder: { + value: TextDecoder, }, - "TextDecoder": { - value: TextDecoder + TextEncoder: { + value: TextEncoder, }, - "TextEncoder": { - value: TextEncoder + BroadcastChannel: { + value: TrackedBroadcastChannel, }, - "BroadcastChannel": { - value: BroadcastChannel + MessageChannel: { + value: MessageChannel, }, - "MessageChannel": { - value: MessageChannel - } }); } catch (e) { // catch silently for non-browser tests