From 45a1bdab58e124751d6dad0121f4a2537e14fd94 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Wed, 12 Aug 2026 14:49:43 -0400 Subject: [PATCH 01/13] fix: contain cookie sync pixel errors so public API calls cannot throw --- src/cookieSyncManager.ts | 36 ++++++++++++++------- test/jest/cookieSyncManager.spec.ts | 50 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/cookieSyncManager.ts b/src/cookieSyncManager.ts index 7a4ea9b63..7d2a59786 100644 --- a/src/cookieSyncManager.ts +++ b/src/cookieSyncManager.ts @@ -156,18 +156,32 @@ export default function CookieSyncManager( mpid: MPID, cookieSyncDates: CookieSyncDates, ): void => { - const img = document.createElement('img'); - - mpInstance.Logger.verbose(InformationMessages.CookieSync); - img.onload = function() { - cookieSyncDates[moduleId] = new Date().getTime(); - - mpInstance._Persistence.saveUserCookieSyncDatesToPersistence( - mpid, - cookieSyncDates + try { + const img = document.createElement('img'); + + mpInstance.Logger.verbose(InformationMessages.CookieSync); + img.onload = function() { + cookieSyncDates[moduleId] = new Date().getTime(); + + mpInstance._Persistence.saveUserCookieSyncDatesToPersistence( + mpid, + cookieSyncDates + ); + }; + img.src = url; + } catch (error) { + // Pixel URLs come from server-side configuration, and some browsers + // (e.g. legacy EdgeHTML) throw synchronously when an invalid URL is + // assigned to img.src. Contain the error so a single bad pixel cannot + // break public API calls (identify, setConsentState) or prevent the + // remaining pixels from syncing. + mpInstance.Logger.error( + 'Error performing cookie sync for module ID ' + + moduleId + + ': ' + + ((error as Error).message || error) ); - }; - img.src = url; + } }; } diff --git a/test/jest/cookieSyncManager.spec.ts b/test/jest/cookieSyncManager.spec.ts index 71139ca7f..c4dc9ef95 100644 --- a/test/jest/cookieSyncManager.spec.ts +++ b/test/jest/cookieSyncManager.spec.ts @@ -897,6 +897,56 @@ describe('CookieSyncManager', () => { expect(loggerSpy).toHaveBeenCalledWith('Performing cookie sync'); }); + + it('should log an error instead of throwing when the tracking pixel cannot be created', () => { + // Legacy EdgeHTML throws synchronously when an invalid URL is + // assigned to img.src. performCookieSync must contain the error so + // public API calls (identify, setConsentState) do not throw. + const mockImage = { + onload: jest.fn(), + }; + Object.defineProperty(mockImage, 'src', { + set() { + throw new Error('Invalid argument.'); + }, + }); + jest.spyOn(document, 'createElement').mockReturnValue( + mockImage as unknown as HTMLImageElement + ); + + const errorSpy = jest.fn(); + const saveSpy = jest.fn(); + + const mockMPInstance = ({ + _Persistence: { + saveUserCookieSyncDatesToPersistence: saveSpy, + }, + Logger: { + verbose: jest.fn(), + error: errorSpy, + }, + } as unknown) as IMParticleWebSDKInstance; + + const cookieSyncManager = new CookieSyncManager(mockMPInstance); + + const cookieSyncDates: CookieSyncDates = {}; + expect(() => + cookieSyncManager.performCookieSync( + 'https://test.com%3Fredirect%3Dhttps%3A%2F%2Fredirect.com', + 42, + '1234', + cookieSyncDates, + ) + ).not.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Error performing cookie sync for module ID 42: Invalid argument.' + ); + + // A failed pixel must not record a sync date + expect(cookieSyncDates[42]).toBeUndefined(); + expect(saveSpy).not.toHaveBeenCalled(); + }); }); describe('#isLastSyncDateExpired', () => { From 4f11a88671bb54a202d286b4645bb5f847fc3582 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Wed, 12 Aug 2026 14:49:52 -0400 Subject: [PATCH 02/13] test: skip window.screen delete test on EdgeHTML and restore screen safely --- test/src/tests-runtimeToBatchEventsDTO.ts | 106 ++++++++++++---------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/test/src/tests-runtimeToBatchEventsDTO.ts b/test/src/tests-runtimeToBatchEventsDTO.ts index 3a9455c14..590b2a220 100644 --- a/test/src/tests-runtimeToBatchEventsDTO.ts +++ b/test/src/tests-runtimeToBatchEventsDTO.ts @@ -459,56 +459,70 @@ describe('Old model to batch model conversion', () => { expect(event.data.custom_event_type).to.equal('media') }); - it('Set width and height to 0 when window is defined but screen is not defined', () => { - const originalScreen = window.screen; - delete window.screen; - - const sdkEvent: SDKEvent = { - EventName: "Pause Event", - EventCategory: 8, - ExpandedEventCount: 0, - EventDataType: 4, - EventAttributes: { - content_duration: '120000', - content_id: "1234567", - content_title: "My sweet sweet media", - content_type: "Video", - media_session_id: "07be2e14-7e05-4053-bcb5-94950365822d", - playhead_position: '7023.335999999999', - stream_type: "OnDemand", - }, - ConsentState: null, - CurrencyCode: null, - CustomFlags: {}, - DataPlan: {}, - Debug: true, - DeviceId: "0edd580e-d887-44e4-89ae-cd65aa0ee933", - Location: null, - MPID: "-8433569646818451201", - OptOut: null, - SDKVersion: "2.11.15", - SourceMessageId: 'testSMID', - SessionId: "64102C03-592F-440D-8BCC-1D27AAA6B188", - SessionStartDate: 1603211322698, - Timestamp: 1603212299414, - ActiveTimeOnSite: 10, - UserAttributes: {}, - UserIdentities: [], - IsFirstRun: true, + it('Set width and height to 0 when window is defined but screen is not defined', function(this: Mocha.Context) { + // On EdgeHTML and IE, window.screen is a non-configurable host object. + // Deleting it throws mid-test and leaves window.screen access broken for + // the remainder of the run, which silently breaks every subsequent batch + // upload (convertEvents reads window.screen). This test covers a pure JS + // branch of convertEvents rather than browser behavior, so skipping it on + // EdgeHTML/IE does not lose browser-specific coverage. + if (/(?:Edge|Trident)\//.test(window.navigator.userAgent)) { + this.skip(); } - const batch = Converter.convertEvents( - '-8433569646818451201', - [sdkEvent], - window.mParticle.getInstance() - ); + const originalScreen = window.screen; - expect(batch).to.be.ok; - expect(batch.device_info.screen_height).to.equal(0); - expect(batch.device_info.screen_width).to.equal(0); + try { + delete window.screen; + + const sdkEvent: SDKEvent = { + EventName: "Pause Event", + EventCategory: 8, + ExpandedEventCount: 0, + EventDataType: 4, + EventAttributes: { + content_duration: '120000', + content_id: "1234567", + content_title: "My sweet sweet media", + content_type: "Video", + media_session_id: "07be2e14-7e05-4053-bcb5-94950365822d", + playhead_position: '7023.335999999999', + stream_type: "OnDemand", + }, + ConsentState: null, + CurrencyCode: null, + CustomFlags: {}, + DataPlan: {}, + Debug: true, + DeviceId: "0edd580e-d887-44e4-89ae-cd65aa0ee933", + Location: null, + MPID: "-8433569646818451201", + OptOut: null, + SDKVersion: "2.11.15", + SourceMessageId: 'testSMID', + SessionId: "64102C03-592F-440D-8BCC-1D27AAA6B188", + SessionStartDate: 1603211322698, + Timestamp: 1603212299414, + ActiveTimeOnSite: 10, + UserAttributes: {}, + UserIdentities: [], + IsFirstRun: true, + } + + const batch = Converter.convertEvents( + '-8433569646818451201', + [sdkEvent], + window.mParticle.getInstance() + ); - // set screen back on - window.screen = originalScreen; + expect(batch).to.be.ok; + expect(batch.device_info.screen_height).to.equal(0); + expect(batch.device_info.screen_width).to.equal(0); + } finally { + // set screen back on even if an assertion fails so that later + // suites are unaffected + window.screen = originalScreen; + } }); it('propagates PageUrl to page_url on the converted event', () => { From e124420a9ac6cbc98d875a965ca24646744f2b7c Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Wed, 12 Aug 2026 14:49:52 -0400 Subject: [PATCH 03/13] ci: add BrowserStack job timeout, run concurrency, and karma capture timeouts --- .github/workflows/cross-browser-testing.yml | 11 +++++++++++ .../browserstack.karma.config.js | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/cross-browser-testing.yml b/.github/workflows/cross-browser-testing.yml index f899e7a6b..b30bf5785 100644 --- a/.github/workflows/cross-browser-testing.yml +++ b/.github/workflows/cross-browser-testing.yml @@ -4,10 +4,21 @@ name: 'BrowserStack Test' on: [push, pull_request, workflow_dispatch] +# Cancel superseded runs for the same ref to reduce pressure on the limited +# pool of BrowserStack parallel sessions, which causes queueing, tunnel +# disconnects, and browser capture timeouts. +concurrency: + group: browserstack-test-${{ github.ref }} + cancel-in-progress: true + jobs: browserstack-test: name: 'BrowserStack Test' runs-on: ubuntu-latest + # A healthy run takes < 15 minutes. Without a timeout, karma occasionally + # waits on half-open BrowserStack tunnel sockets until GitHub's 6 hour + # default cancels the job. + timeout-minutes: 30 steps: - name: 'BrowserStack Env Setup' # Invokes the setup-env action diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index 556f09986..1d7e79a9b 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -95,6 +95,12 @@ module.exports = function(config) { // 2000 ms (default) timeout browserDisconnectTimeout: 50000, browserDisconnectTolerance: 5, + // BrowserStack can queue sessions when parallel slots are busy, so allow + // browsers longer than the 60s default to connect before killing the run + captureTimeout: 300000, + // Tolerate mid-run stalls on slow BrowserStack VMs without treating + // them as disconnects (default is 30s of silence) + browserNoActivityTimeout: 120000, concurrency: 5, }); }; From 9263000f4f6b96339024bfc024e26f94a483a5c2 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Wed, 12 Aug 2026 15:29:49 -0400 Subject: [PATCH 04/13] test: raise waitForCondition and BrowserStack mocha timeouts to deflake slow VMs --- test/cross-browser-testing/browserstack.karma.config.js | 5 +++++ test/src/config/utils.js | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index 1d7e79a9b..224d957e6 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -85,6 +85,11 @@ module.exports = function(config) { browserConsoleLogOptions, client: { captureConsole, + mocha: { + // Increase from the 2 second default: tests on loaded BrowserStack + // VMs run slower than locally and time out spuriously + timeout: 10000, + }, }, junitReporter: { outputDir: 'reports/', diff --git a/test/src/config/utils.js b/test/src/config/utils.js index 7e2ea6470..8db180803 100644 --- a/test/src/config/utils.js +++ b/test/src/config/utils.js @@ -604,7 +604,9 @@ var pluses = /\+/g, }, waitForCondition = function async( conditionFn, - timeout = 200, + // 200ms is enough on local machines, but loaded BrowserStack VMs + // regularly need longer for mocked async work to settle + timeout = 3000, interval = 10 ) { return new Promise((resolve, reject) => { From b1bdd7156748704ca78fec26c19a3d204e7d8c54 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Wed, 12 Aug 2026 15:54:47 -0400 Subject: [PATCH 05/13] ci: pin BrowserStack sessions to the workflow tunnel via local identifier --- .../browserstack.karma.config.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index 224d957e6..1b7521be3 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -70,7 +70,23 @@ module.exports = function(config) { config.set({ browserStack: { username: process.env.BS_USERNAME, - accessKey: process.env.BS_ACCESS_KEY + accessKey: process.env.BS_ACCESS_KEY, + // In CI, the BrowserStackLocal tunnel is started by the + // browserstack/github-actions setup-local step with a unique + // local-identifier. Sessions must be pinned to that identifier; + // otherwise BrowserStack routes their localhost traffic through an + // arbitrary active tunnel for the account, so concurrent workflow + // runs hijack each other's tunnels (browsers that never capture, + // "ghost" browsers from other runs, and mid-run transport errors). + // Locally (no BROWSERSTACK_LOCAL_IDENTIFIER), karma still starts and + // owns its own tunnel as before. + ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER + ? { + startTunnel: false, + 'browserstack.localIdentifier': + process.env.BROWSERSTACK_LOCAL_IDENTIFIER, + } + : {}), }, autoWatch: false, customLaunchers, From b4e4eb320768afa13bed1523d54a3c818e91e7c5 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Thu, 13 Aug 2026 10:28:58 -0400 Subject: [PATCH 06/13] test: wait for in-flight identify before retrying failed-config core SDK spec Firefox 51 on BrowserStack timed out because init's 400 identify was still in flight when the spec issued a second identify. Raise mocha timeout so several waitForCondition polls fit in one spec. --- test/cross-browser-testing/browserstack.karma.config.js | 5 +++-- test/src/tests-core-sdk.js | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index 1b7521be3..5c5f40cec 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -103,8 +103,9 @@ module.exports = function(config) { captureConsole, mocha: { // Increase from the 2 second default: tests on loaded BrowserStack - // VMs run slower than locally and time out spuriously - timeout: 10000, + // VMs run slower than locally and time out spuriously. Must exceed + // several waitForCondition polls (3s each) in a single spec. + timeout: 20000, }, }, junitReporter: { diff --git a/test/src/tests-core-sdk.js b/test/src/tests-core-sdk.js index 6eb263fb4..037bbcb51 100644 --- a/test/src/tests-core-sdk.js +++ b/test/src/tests-core-sdk.js @@ -975,6 +975,12 @@ describe('core SDK', function() { // fetching the config is async and we need to wait for it to finish mParticle.getInstance()._Store.isInitialized.should.equal(true); + // The identify call made during init (mocked as a 400 above) must + // finish before another identity request is made, otherwise the SDK + // rejects the new request as already in flight and this test flakes + // on slow browsers (Firefox 51 on BrowserStack). + await waitForCondition(hasIdentityCallInflightReturned); + // have to manually call identify although it was called as part of init because we can only mock the server response once fetchMockSuccess(urls.identify, { mpid: 'MPID1', From baf751edbcbcb6b7dfeb49a27683fd8c6bdc3689 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Thu, 13 Aug 2026 10:30:31 -0400 Subject: [PATCH 07/13] ci: share BrowserStack concurrency group across push and pull_request github.ref differs for those events, so the previous group still allowed two matrices for the same branch to contend for BrowserStack sessions. --- .github/workflows/cross-browser-testing.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cross-browser-testing.yml b/.github/workflows/cross-browser-testing.yml index b30bf5785..ffbd955c2 100644 --- a/.github/workflows/cross-browser-testing.yml +++ b/.github/workflows/cross-browser-testing.yml @@ -4,11 +4,13 @@ name: 'BrowserStack Test' on: [push, pull_request, workflow_dispatch] -# Cancel superseded runs for the same ref to reduce pressure on the limited -# pool of BrowserStack parallel sessions, which causes queueing, tunnel -# disconnects, and browser capture timeouts. +# Cancel superseded runs for the same branch to reduce pressure on the +# limited pool of BrowserStack parallel sessions, which causes queueing, +# tunnel disconnects, and browser capture timeouts. Use head_ref (PR) or +# ref_name (push) so the pull_request and push events for one branch share +# a group instead of running two matrices at once. concurrency: - group: browserstack-test-${{ github.ref }} + group: browserstack-test-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: From 8c62eb66ef11507332eea5de5c7bc946022bd00a Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Thu, 13 Aug 2026 10:37:33 -0400 Subject: [PATCH 08/13] style: shorten BrowserStack and EdgeHTML comments to 1-2 lines --- .github/workflows/cross-browser-testing.yml | 11 +++------- src/cookieSyncManager.ts | 7 ++----- .../browserstack.karma.config.js | 21 +++++-------------- test/jest/cookieSyncManager.spec.ts | 5 ++--- test/src/config/utils.js | 3 +-- test/src/tests-core-sdk.js | 6 ++---- test/src/tests-runtimeToBatchEventsDTO.ts | 11 +++------- 7 files changed, 18 insertions(+), 46 deletions(-) diff --git a/.github/workflows/cross-browser-testing.yml b/.github/workflows/cross-browser-testing.yml index ffbd955c2..66df83a04 100644 --- a/.github/workflows/cross-browser-testing.yml +++ b/.github/workflows/cross-browser-testing.yml @@ -4,11 +4,8 @@ name: 'BrowserStack Test' on: [push, pull_request, workflow_dispatch] -# Cancel superseded runs for the same branch to reduce pressure on the -# limited pool of BrowserStack parallel sessions, which causes queueing, -# tunnel disconnects, and browser capture timeouts. Use head_ref (PR) or -# ref_name (push) so the pull_request and push events for one branch share -# a group instead of running two matrices at once. +# Share a group across push and pull_request for the same branch so they +# do not run two BrowserStack matrices at once. concurrency: group: browserstack-test-${{ github.head_ref || github.ref_name }} cancel-in-progress: true @@ -17,9 +14,7 @@ jobs: browserstack-test: name: 'BrowserStack Test' runs-on: ubuntu-latest - # A healthy run takes < 15 minutes. Without a timeout, karma occasionally - # waits on half-open BrowserStack tunnel sockets until GitHub's 6 hour - # default cancels the job. + # Cap hangs from half-open BrowserStack tunnels (GitHub default is 6h). timeout-minutes: 30 steps: diff --git a/src/cookieSyncManager.ts b/src/cookieSyncManager.ts index 7d2a59786..1247b201f 100644 --- a/src/cookieSyncManager.ts +++ b/src/cookieSyncManager.ts @@ -170,11 +170,8 @@ export default function CookieSyncManager( }; img.src = url; } catch (error) { - // Pixel URLs come from server-side configuration, and some browsers - // (e.g. legacy EdgeHTML) throw synchronously when an invalid URL is - // assigned to img.src. Contain the error so a single bad pixel cannot - // break public API calls (identify, setConsentState) or prevent the - // remaining pixels from syncing. + // EdgeHTML throws on invalid img.src; contain so one bad pixel + // cannot break identify/setConsentState or later pixels. mpInstance.Logger.error( 'Error performing cookie sync for module ID ' + moduleId + diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index 5c5f40cec..c4d6fded5 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -71,15 +71,8 @@ module.exports = function(config) { browserStack: { username: process.env.BS_USERNAME, accessKey: process.env.BS_ACCESS_KEY, - // In CI, the BrowserStackLocal tunnel is started by the - // browserstack/github-actions setup-local step with a unique - // local-identifier. Sessions must be pinned to that identifier; - // otherwise BrowserStack routes their localhost traffic through an - // arbitrary active tunnel for the account, so concurrent workflow - // runs hijack each other's tunnels (browsers that never capture, - // "ghost" browsers from other runs, and mid-run transport errors). - // Locally (no BROWSERSTACK_LOCAL_IDENTIFIER), karma still starts and - // owns its own tunnel as before. + // Pin CI sessions to the workflow tunnel; locally karma still + // starts its own when BROWSERSTACK_LOCAL_IDENTIFIER is unset. ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER ? { startTunnel: false, @@ -102,9 +95,7 @@ module.exports = function(config) { client: { captureConsole, mocha: { - // Increase from the 2 second default: tests on loaded BrowserStack - // VMs run slower than locally and time out spuriously. Must exceed - // several waitForCondition polls (3s each) in a single spec. + // Slow BrowserStack VMs need more than mocha's 2s default. timeout: 20000, }, }, @@ -117,11 +108,9 @@ module.exports = function(config) { // 2000 ms (default) timeout browserDisconnectTimeout: 50000, browserDisconnectTolerance: 5, - // BrowserStack can queue sessions when parallel slots are busy, so allow - // browsers longer than the 60s default to connect before killing the run + // Session queueing often exceeds karma's 60s capture default. captureTimeout: 300000, - // Tolerate mid-run stalls on slow BrowserStack VMs without treating - // them as disconnects (default is 30s of silence) + // Slow VMs can stall longer than the 30s no-activity default. browserNoActivityTimeout: 120000, concurrency: 5, }); diff --git a/test/jest/cookieSyncManager.spec.ts b/test/jest/cookieSyncManager.spec.ts index c4dc9ef95..64e3a486b 100644 --- a/test/jest/cookieSyncManager.spec.ts +++ b/test/jest/cookieSyncManager.spec.ts @@ -899,9 +899,8 @@ describe('CookieSyncManager', () => { }); it('should log an error instead of throwing when the tracking pixel cannot be created', () => { - // Legacy EdgeHTML throws synchronously when an invalid URL is - // assigned to img.src. performCookieSync must contain the error so - // public API calls (identify, setConsentState) do not throw. + // EdgeHTML throws on invalid img.src; performCookieSync must + // contain that so identify/setConsentState do not throw. const mockImage = { onload: jest.fn(), }; diff --git a/test/src/config/utils.js b/test/src/config/utils.js index 8db180803..3387cf1de 100644 --- a/test/src/config/utils.js +++ b/test/src/config/utils.js @@ -604,8 +604,7 @@ var pluses = /\+/g, }, waitForCondition = function async( conditionFn, - // 200ms is enough on local machines, but loaded BrowserStack VMs - // regularly need longer for mocked async work to settle + // BrowserStack VMs need more than 200ms for mocked async work. timeout = 3000, interval = 10 ) { diff --git a/test/src/tests-core-sdk.js b/test/src/tests-core-sdk.js index 037bbcb51..5c1c1e4db 100644 --- a/test/src/tests-core-sdk.js +++ b/test/src/tests-core-sdk.js @@ -975,10 +975,8 @@ describe('core SDK', function() { // fetching the config is async and we need to wait for it to finish mParticle.getInstance()._Store.isInitialized.should.equal(true); - // The identify call made during init (mocked as a 400 above) must - // finish before another identity request is made, otherwise the SDK - // rejects the new request as already in flight and this test flakes - // on slow browsers (Firefox 51 on BrowserStack). + // Init's identify (mocked 400) must finish or the next identify + // is rejected as already in flight on slow browsers. await waitForCondition(hasIdentityCallInflightReturned); // have to manually call identify although it was called as part of init because we can only mock the server response once diff --git a/test/src/tests-runtimeToBatchEventsDTO.ts b/test/src/tests-runtimeToBatchEventsDTO.ts index 590b2a220..723896c47 100644 --- a/test/src/tests-runtimeToBatchEventsDTO.ts +++ b/test/src/tests-runtimeToBatchEventsDTO.ts @@ -460,12 +460,8 @@ describe('Old model to batch model conversion', () => { }); it('Set width and height to 0 when window is defined but screen is not defined', function(this: Mocha.Context) { - // On EdgeHTML and IE, window.screen is a non-configurable host object. - // Deleting it throws mid-test and leaves window.screen access broken for - // the remainder of the run, which silently breaks every subsequent batch - // upload (convertEvents reads window.screen). This test covers a pure JS - // branch of convertEvents rather than browser behavior, so skipping it on - // EdgeHTML/IE does not lose browser-specific coverage. + // EdgeHTML/IE: window.screen cannot be deleted; doing so poisons + // convertEvents for the rest of the run. Skip on those UAs only. if (/(?:Edge|Trident)\//.test(window.navigator.userAgent)) { this.skip(); } @@ -519,8 +515,7 @@ describe('Old model to batch model conversion', () => { expect(batch.device_info.screen_height).to.equal(0); expect(batch.device_info.screen_width).to.equal(0); } finally { - // set screen back on even if an assertion fails so that later - // suites are unaffected + // Restore even if an assertion fails so later suites are unaffected. window.screen = originalScreen; } }); From de0e4486c2fad13b0ec3c9cf82791f30af75cc67 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Thu, 13 Aug 2026 10:40:07 -0400 Subject: [PATCH 09/13] fix(ci): use karma-browserstack-launcher localIdentifier key The dotted browserstack.localIdentifier key is ignored by the launcher, so CI sessions were not pinned to the workflow tunnel. --- test/cross-browser-testing/browserstack.karma.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index c4d6fded5..e142fe535 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -76,7 +76,7 @@ module.exports = function(config) { ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER ? { startTunnel: false, - 'browserstack.localIdentifier': + localIdentifier: process.env.BROWSERSTACK_LOCAL_IDENTIFIER, } : {}), From 2e5f396731aa789530a3ac4f5dd0f5ae15983c5a Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Fri, 14 Aug 2026 14:46:50 -0400 Subject: [PATCH 10/13] ci: drop legacy EdgeHTML 15 from the BrowserStack matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge 15–18 is ~0.02% share. Keep Chromium Edge in the beta workflow. --- .../browserstack.karma.config.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index e142fe535..5d91d1d0a 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -24,11 +24,8 @@ if (DEBUG === 'true') { } const customLaunchers = { - // Full list of supported browsers - https://www.browserstack.com/list-of-browsers-and-platforms/live - // https://www.w3schools.com/js/js_versions.asp shows a list of browsers that support ES6. - // The below list is primarily the version just before that, or if that version was not available on Browserstack to test, the next version was - // All versions below, including earlier versions of each browser, have a combined ~0.37% market share according to - // www.browserslist.dev. Query for "opera < 38, safari < 12, chrome < 51, firefox <52, edge < 15" + // Last pre-ES6 versions on BrowserStack. Legacy EdgeHTML (15–18) is + // omitted (~0.02% share). bs_chrome_mac_50: { base: 'BrowserStack', browser: 'chrome', @@ -43,13 +40,6 @@ const customLaunchers = { os: 'OS X', os_version: 'Mojave' }, - bs_edge_windows_15: { - base: 'BrowserStack', - browser: 'edge', - browser_version: '15.0', - os: 'Windows', - os_version: '10' - }, bs_safari_mac_11: { base: 'BrowserStack', browser: 'safari', From eab02dff8b38e2b2bb5568227d0391f4fea8e99e Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Fri, 14 Aug 2026 14:51:35 -0400 Subject: [PATCH 11/13] test: wait for all instance cookies in multi-instance BrowserStack spec Firefox 153 on Windows failed because the spec only waited on the default instance. Cap the beta workflow at 30 minutes and pin its tunnel. --- .../workflows/cross-browser-testing-beta.yml | 1 + .../browserstack.karma.beta.config.js | 15 +++++++++++++- test/src/tests-mparticle-instance-manager.ts | 20 +++++++++++-------- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cross-browser-testing-beta.yml b/.github/workflows/cross-browser-testing-beta.yml index 073249d52..1063efe8e 100644 --- a/.github/workflows/cross-browser-testing-beta.yml +++ b/.github/workflows/cross-browser-testing-beta.yml @@ -5,6 +5,7 @@ jobs: browserstack-beta-test: name: 'BrowserStack Beta Browsers Test' runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: 'BrowserStack Env Setup' uses: browserstack/github-actions/setup-env@master diff --git a/test/cross-browser-testing/browserstack.karma.beta.config.js b/test/cross-browser-testing/browserstack.karma.beta.config.js index 9887d171e..bea587d36 100644 --- a/test/cross-browser-testing/browserstack.karma.beta.config.js +++ b/test/cross-browser-testing/browserstack.karma.beta.config.js @@ -99,7 +99,16 @@ module.exports = function(config) { config.set({ browserStack: { username: process.env.BS_USERNAME, - accessKey: process.env.BS_ACCESS_KEY + accessKey: process.env.BS_ACCESS_KEY, + // Pin CI sessions to the workflow tunnel; locally karma still + // starts its own when BROWSERSTACK_LOCAL_IDENTIFIER is unset. + ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER + ? { + startTunnel: false, + localIdentifier: + process.env.BROWSERSTACK_LOCAL_IDENTIFIER, + } + : {}), }, autoWatch: false, customLaunchers, @@ -114,6 +123,10 @@ module.exports = function(config) { browserConsoleLogOptions, client: { captureConsole, + mocha: { + // Slow BrowserStack VMs need more than mocha's 2s default. + timeout: 20000, + }, }, junitReporter: { outputDir: 'reports/', diff --git a/test/src/tests-mparticle-instance-manager.ts b/test/src/tests-mparticle-instance-manager.ts index 980d6baae..c3f0160d7 100644 --- a/test/src/tests-mparticle-instance-manager.ts +++ b/test/src/tests-mparticle-instance-manager.ts @@ -320,14 +320,18 @@ describe('mParticle instance manager', () => { }); it('creates multiple instances with their own cookies', async () => { - await waitForCondition(hasConfigurationReturned); - const cookies1 = window.localStorage.getItem('mprtcl-v4_wtTest1'); - const cookies2 = window.localStorage.getItem('mprtcl-v4_wtTest2'); - const cookies3 = window.localStorage.getItem('mprtcl-v4_wtTest3'); - - cookies1.includes('apiKey1').should.equal(true); - cookies2.includes('apiKey2').should.equal(true); - cookies3.includes('apiKey3').should.equal(true); + // hasConfigurationReturned only covers the default instance; + // instance 2/3 cookies can still be unset on slow VMs. + await waitForCondition(() => { + const cookies1 = window.localStorage.getItem('mprtcl-v4_wtTest1'); + const cookies2 = window.localStorage.getItem('mprtcl-v4_wtTest2'); + const cookies3 = window.localStorage.getItem('mprtcl-v4_wtTest3'); + return ( + cookies1?.includes('apiKey1') && + cookies2?.includes('apiKey2') && + cookies3?.includes('apiKey3') + ); + }); }); it('logs events to their own instances', async () => { From 07fd78611648fa87589129445a24c076e61acca5 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Fri, 14 Aug 2026 15:26:18 -0400 Subject: [PATCH 12/13] refactor(ci): share BrowserStack karma settings to clear sonar duplication --- .../browserstack.karma.beta.config.js | 60 ++------------- .../browserstack.karma.config.js | 73 ++++--------------- .../browserstack.karma.shared.js | 73 +++++++++++++++++++ 3 files changed, 93 insertions(+), 113 deletions(-) create mode 100644 test/cross-browser-testing/browserstack.karma.shared.js diff --git a/test/cross-browser-testing/browserstack.karma.beta.config.js b/test/cross-browser-testing/browserstack.karma.beta.config.js index bea587d36..8fbef3930 100644 --- a/test/cross-browser-testing/browserstack.karma.beta.config.js +++ b/test/cross-browser-testing/browserstack.karma.beta.config.js @@ -1,4 +1,6 @@ -const { DEBUG } = process.env; +const { + getSharedKarmaSettings, +} = require('./browserstack.karma.shared'); const files = [ '../lib/geomock.js', @@ -6,22 +8,6 @@ const files = [ '../test-bundle.js', ]; -let captureConsole = false; -let browserConsoleLogOptions = {}; - -if (DEBUG === 'true') { - browserConsoleLogOptions = { - level: 'log', - format: '%b %T: %m', - terminal: true, - }; - captureConsole = true; -} else { - browserConsoleLogOptions = { - terminal: false, - }; -} - const customLaunchers = { bs_chrome_mac_tahoe_beta: { base: 'BrowserStack', @@ -97,44 +83,12 @@ const customLaunchers = { module.exports = function(config) { config.set({ - browserStack: { - username: process.env.BS_USERNAME, - accessKey: process.env.BS_ACCESS_KEY, - // Pin CI sessions to the workflow tunnel; locally karma still - // starts its own when BROWSERSTACK_LOCAL_IDENTIFIER is unset. - ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER - ? { - startTunnel: false, - localIdentifier: - process.env.BROWSERSTACK_LOCAL_IDENTIFIER, - } - : {}), - }, - autoWatch: false, + ...getSharedKarmaSettings({ + files, + junitOutputFile: 'test-karma-beta.xml', + }), customLaunchers, browsers: Object.keys(customLaunchers), - frameworks: ['mocha', 'should'], - files, - reporters: ['progress', 'junit'], - colors: true, - singleRun: true, - debug: true, logLevel: config.LOG_INFO, - browserConsoleLogOptions, - client: { - captureConsole, - mocha: { - // Slow BrowserStack VMs need more than mocha's 2s default. - timeout: 20000, - }, - }, - junitReporter: { - outputDir: 'reports/', - outputFile: 'test-karma-beta.xml', - }, - browserDisconnectTimeout: 50000, - browserDisconnectTolerance: 5, - concurrency: 5, }); }; - diff --git a/test/cross-browser-testing/browserstack.karma.config.js b/test/cross-browser-testing/browserstack.karma.config.js index 5d91d1d0a..dcfc388c3 100644 --- a/test/cross-browser-testing/browserstack.karma.config.js +++ b/test/cross-browser-testing/browserstack.karma.config.js @@ -1,4 +1,6 @@ -const { DEBUG } = process.env; +const { + getSharedKarmaSettings, +} = require('./browserstack.karma.shared'); const files = [ '../lib/geomock.js', @@ -6,23 +8,6 @@ const files = [ './CBT-tests-es5.js', ]; -let captureConsole = false; -let browserConsoleLogOptions = {}; - -// Allows console logs to appear when doing npm run test:debug -if (DEBUG === 'true') { - browserConsoleLogOptions = { - level: 'log', - format: '%b %T: %m', - terminal: true, - }; - captureConsole = true; -} else { - browserConsoleLogOptions = { - terminal: false, - }; -} - const customLaunchers = { // Last pre-ES6 versions on BrowserStack. Legacy EdgeHTML (15–18) is // omitted (~0.02% share). @@ -58,50 +43,18 @@ const customLaunchers = { module.exports = function(config) { config.set({ - browserStack: { - username: process.env.BS_USERNAME, - accessKey: process.env.BS_ACCESS_KEY, - // Pin CI sessions to the workflow tunnel; locally karma still - // starts its own when BROWSERSTACK_LOCAL_IDENTIFIER is unset. - ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER - ? { - startTunnel: false, - localIdentifier: - process.env.BROWSERSTACK_LOCAL_IDENTIFIER, - } - : {}), - }, - autoWatch: false, + ...getSharedKarmaSettings({ + files, + junitOutputFile: 'test-karma.xml', + extra: { + // Session queueing often exceeds karma's 60s capture default. + captureTimeout: 300000, + // Slow VMs can stall longer than the 30s no-activity default. + browserNoActivityTimeout: 120000, + }, + }), customLaunchers, browsers: Object.keys(customLaunchers), - frameworks: ['mocha', 'should'], - files, - reporters: ['progress', 'junit'], - colors: true, - singleRun: true, - debug: true, logLevel: config.LOG_INFO, - browserConsoleLogOptions, - client: { - captureConsole, - mocha: { - // Slow BrowserStack VMs need more than mocha's 2s default. - timeout: 20000, - }, - }, - junitReporter: { - outputDir: 'reports/', - outputFile: 'test-karma.xml', - }, - // These settings are added because the connection to Browserstack - // can sometimes be unstable, requiring re-connections, or a longer than - // 2000 ms (default) timeout - browserDisconnectTimeout: 50000, - browserDisconnectTolerance: 5, - // Session queueing often exceeds karma's 60s capture default. - captureTimeout: 300000, - // Slow VMs can stall longer than the 30s no-activity default. - browserNoActivityTimeout: 120000, - concurrency: 5, }); }; diff --git a/test/cross-browser-testing/browserstack.karma.shared.js b/test/cross-browser-testing/browserstack.karma.shared.js new file mode 100644 index 000000000..e5994150a --- /dev/null +++ b/test/cross-browser-testing/browserstack.karma.shared.js @@ -0,0 +1,73 @@ +'use strict'; + +function getBrowserStackOptions() { + return { + username: process.env.BS_USERNAME, + accessKey: process.env.BS_ACCESS_KEY, + // Pin CI sessions to the workflow tunnel; locally karma still + // starts its own when BROWSERSTACK_LOCAL_IDENTIFIER is unset. + ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER + ? { + startTunnel: false, + localIdentifier: + process.env.BROWSERSTACK_LOCAL_IDENTIFIER, + } + : {}), + }; +} + +function getDebugConsoleOptions() { + if (process.env.DEBUG === 'true') { + return { + captureConsole: true, + browserConsoleLogOptions: { + level: 'log', + format: '%b %T: %m', + terminal: true, + }, + }; + } + + return { + captureConsole: false, + browserConsoleLogOptions: { + terminal: false, + }, + }; +} + +function getSharedKarmaSettings({ files, junitOutputFile, extra }) { + const { captureConsole, browserConsoleLogOptions } = + getDebugConsoleOptions(); + + return { + browserStack: getBrowserStackOptions(), + autoWatch: false, + frameworks: ['mocha', 'should'], + files, + reporters: ['progress', 'junit'], + colors: true, + singleRun: true, + debug: true, + browserConsoleLogOptions, + client: { + captureConsole, + // Slow BrowserStack VMs need more than mocha's 2s default. + mocha: { + timeout: 20000, + }, + }, + junitReporter: { + outputDir: 'reports/', + outputFile: junitOutputFile, + }, + browserDisconnectTimeout: 50000, + browserDisconnectTolerance: 5, + concurrency: 5, + ...extra, + }; +} + +module.exports = { + getSharedKarmaSettings, +}; From 54cef9eac17cd7d3f43624c900923c74a93783e8 Mon Sep 17 00:00:00 2001 From: Cris Ryan Tan Date: Fri, 14 Aug 2026 16:08:19 -0400 Subject: [PATCH 13/13] revert: drop EdgeHTML source and test workarounds after removing Edge 15 --- src/cookieSyncManager.ts | 33 +++---- test/jest/cookieSyncManager.spec.ts | 49 ----------- test/src/tests-runtimeToBatchEventsDTO.ts | 101 ++++++++++------------ 3 files changed, 57 insertions(+), 126 deletions(-) diff --git a/src/cookieSyncManager.ts b/src/cookieSyncManager.ts index 1247b201f..7a4ea9b63 100644 --- a/src/cookieSyncManager.ts +++ b/src/cookieSyncManager.ts @@ -156,29 +156,18 @@ export default function CookieSyncManager( mpid: MPID, cookieSyncDates: CookieSyncDates, ): void => { - try { - const img = document.createElement('img'); - - mpInstance.Logger.verbose(InformationMessages.CookieSync); - img.onload = function() { - cookieSyncDates[moduleId] = new Date().getTime(); - - mpInstance._Persistence.saveUserCookieSyncDatesToPersistence( - mpid, - cookieSyncDates - ); - }; - img.src = url; - } catch (error) { - // EdgeHTML throws on invalid img.src; contain so one bad pixel - // cannot break identify/setConsentState or later pixels. - mpInstance.Logger.error( - 'Error performing cookie sync for module ID ' + - moduleId + - ': ' + - ((error as Error).message || error) + const img = document.createElement('img'); + + mpInstance.Logger.verbose(InformationMessages.CookieSync); + img.onload = function() { + cookieSyncDates[moduleId] = new Date().getTime(); + + mpInstance._Persistence.saveUserCookieSyncDatesToPersistence( + mpid, + cookieSyncDates ); - } + }; + img.src = url; }; } diff --git a/test/jest/cookieSyncManager.spec.ts b/test/jest/cookieSyncManager.spec.ts index 64e3a486b..71139ca7f 100644 --- a/test/jest/cookieSyncManager.spec.ts +++ b/test/jest/cookieSyncManager.spec.ts @@ -897,55 +897,6 @@ describe('CookieSyncManager', () => { expect(loggerSpy).toHaveBeenCalledWith('Performing cookie sync'); }); - - it('should log an error instead of throwing when the tracking pixel cannot be created', () => { - // EdgeHTML throws on invalid img.src; performCookieSync must - // contain that so identify/setConsentState do not throw. - const mockImage = { - onload: jest.fn(), - }; - Object.defineProperty(mockImage, 'src', { - set() { - throw new Error('Invalid argument.'); - }, - }); - jest.spyOn(document, 'createElement').mockReturnValue( - mockImage as unknown as HTMLImageElement - ); - - const errorSpy = jest.fn(); - const saveSpy = jest.fn(); - - const mockMPInstance = ({ - _Persistence: { - saveUserCookieSyncDatesToPersistence: saveSpy, - }, - Logger: { - verbose: jest.fn(), - error: errorSpy, - }, - } as unknown) as IMParticleWebSDKInstance; - - const cookieSyncManager = new CookieSyncManager(mockMPInstance); - - const cookieSyncDates: CookieSyncDates = {}; - expect(() => - cookieSyncManager.performCookieSync( - 'https://test.com%3Fredirect%3Dhttps%3A%2F%2Fredirect.com', - 42, - '1234', - cookieSyncDates, - ) - ).not.toThrow(); - - expect(errorSpy).toHaveBeenCalledWith( - 'Error performing cookie sync for module ID 42: Invalid argument.' - ); - - // A failed pixel must not record a sync date - expect(cookieSyncDates[42]).toBeUndefined(); - expect(saveSpy).not.toHaveBeenCalled(); - }); }); describe('#isLastSyncDateExpired', () => { diff --git a/test/src/tests-runtimeToBatchEventsDTO.ts b/test/src/tests-runtimeToBatchEventsDTO.ts index 723896c47..3a9455c14 100644 --- a/test/src/tests-runtimeToBatchEventsDTO.ts +++ b/test/src/tests-runtimeToBatchEventsDTO.ts @@ -459,65 +459,56 @@ describe('Old model to batch model conversion', () => { expect(event.data.custom_event_type).to.equal('media') }); - it('Set width and height to 0 when window is defined but screen is not defined', function(this: Mocha.Context) { - // EdgeHTML/IE: window.screen cannot be deleted; doing so poisons - // convertEvents for the rest of the run. Skip on those UAs only. - if (/(?:Edge|Trident)\//.test(window.navigator.userAgent)) { - this.skip(); + it('Set width and height to 0 when window is defined but screen is not defined', () => { + const originalScreen = window.screen; + delete window.screen; + + const sdkEvent: SDKEvent = { + EventName: "Pause Event", + EventCategory: 8, + ExpandedEventCount: 0, + EventDataType: 4, + EventAttributes: { + content_duration: '120000', + content_id: "1234567", + content_title: "My sweet sweet media", + content_type: "Video", + media_session_id: "07be2e14-7e05-4053-bcb5-94950365822d", + playhead_position: '7023.335999999999', + stream_type: "OnDemand", + }, + ConsentState: null, + CurrencyCode: null, + CustomFlags: {}, + DataPlan: {}, + Debug: true, + DeviceId: "0edd580e-d887-44e4-89ae-cd65aa0ee933", + Location: null, + MPID: "-8433569646818451201", + OptOut: null, + SDKVersion: "2.11.15", + SourceMessageId: 'testSMID', + SessionId: "64102C03-592F-440D-8BCC-1D27AAA6B188", + SessionStartDate: 1603211322698, + Timestamp: 1603212299414, + ActiveTimeOnSite: 10, + UserAttributes: {}, + UserIdentities: [], + IsFirstRun: true, } - const originalScreen = window.screen; + const batch = Converter.convertEvents( + '-8433569646818451201', + [sdkEvent], + window.mParticle.getInstance() + ); - try { - delete window.screen; - - const sdkEvent: SDKEvent = { - EventName: "Pause Event", - EventCategory: 8, - ExpandedEventCount: 0, - EventDataType: 4, - EventAttributes: { - content_duration: '120000', - content_id: "1234567", - content_title: "My sweet sweet media", - content_type: "Video", - media_session_id: "07be2e14-7e05-4053-bcb5-94950365822d", - playhead_position: '7023.335999999999', - stream_type: "OnDemand", - }, - ConsentState: null, - CurrencyCode: null, - CustomFlags: {}, - DataPlan: {}, - Debug: true, - DeviceId: "0edd580e-d887-44e4-89ae-cd65aa0ee933", - Location: null, - MPID: "-8433569646818451201", - OptOut: null, - SDKVersion: "2.11.15", - SourceMessageId: 'testSMID', - SessionId: "64102C03-592F-440D-8BCC-1D27AAA6B188", - SessionStartDate: 1603211322698, - Timestamp: 1603212299414, - ActiveTimeOnSite: 10, - UserAttributes: {}, - UserIdentities: [], - IsFirstRun: true, - } - - const batch = Converter.convertEvents( - '-8433569646818451201', - [sdkEvent], - window.mParticle.getInstance() - ); + expect(batch).to.be.ok; + expect(batch.device_info.screen_height).to.equal(0); + expect(batch.device_info.screen_width).to.equal(0); - expect(batch).to.be.ok; - expect(batch.device_info.screen_height).to.equal(0); - expect(batch.device_info.screen_width).to.equal(0); - } finally { - // Restore even if an assertion fails so later suites are unaffected. - window.screen = originalScreen; - } + // set screen back on + window.screen = originalScreen; }); it('propagates PageUrl to page_url on the converted event', () => {