diff --git a/.changeset/vitest-analytics-migration.md b/.changeset/vitest-analytics-migration.md new file mode 100644 index 0000000000..8808e5f86f --- /dev/null +++ b/.changeset/vitest-analytics-migration.md @@ -0,0 +1,5 @@ +--- +'@firebase/analytics': patch +--- + +Migrate test suite to Vitest and export test helper function. diff --git a/packages/analytics/package.json b/packages/analytics/package.json index c8e7760a65..6cb2eaa7dc 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -25,15 +25,16 @@ "build:deps": "lerna run build --scope @firebase/analytics --include-dependencies", "dev": "rollup -c -w", "test": "run-p --npm-path npm lint test:all", - "test:all": "run-p --npm-path npm test:browser test:integration", + "test:all": "vitest run", "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:all", - "test:browser": "karma start --nocache", - "test:integration": "karma start ./karma.integration.conf.js --nocache", + "test:browser": "vitest run", + "test:integration": "vitest run -c vitest.integration.config.mjs", "trusted-type-check": "tsec -p tsconfig.json --noEmit", "api-report": "api-extractor run --local --verbose", "doc": "api-documenter markdown --input temp --output docs", "build:doc": "yarn build && yarn doc", - "typings:public": "node ../../scripts/build/use_typings.js ./dist/analytics-public.d.ts" + "typings:public": "node ../../scripts/build/use_typings.js ./dist/analytics-public.d.ts", + "test:browser:debug": "vitest --browser.headless=false" }, "peerDependencies": { "@firebase/app": "0.x" @@ -64,11 +65,5 @@ "bugs": { "url": "https://github.com/firebase/firebase-js-sdk/issues" }, - "typings": "dist/src/index.d.ts", - "nyc": { - "extension": [ - ".ts" - ], - "reportDir": "./coverage/node" - } + "typings": "dist/src/index.d.ts" } diff --git a/packages/analytics/rollup.config.js b/packages/analytics/rollup.config.js index 4be307f953..b8085a080c 100644 --- a/packages/analytics/rollup.config.js +++ b/packages/analytics/rollup.config.js @@ -33,7 +33,7 @@ const buildPlugins = [ typescriptPlugin({ typescript, tsconfigOverride: { - exclude: [...tsconfig.exclude, '**/*.test.ts'] + exclude: [...tsconfig.exclude, '**/*.test.ts', 'testing/**'] } }), json({ preferConst: true }) diff --git a/packages/analytics/src/api.test.ts b/packages/analytics/src/api.test.ts index eacaf3ee81..96a0e783bd 100644 --- a/packages/analytics/src/api.test.ts +++ b/packages/analytics/src/api.test.ts @@ -15,9 +15,7 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonStub, stub } from 'sinon'; -import '../testing/setup'; +import { expect, vi } from 'vitest'; import { getFullApp } from '../testing/get-fake-firebase-services'; import { getAnalytics, @@ -27,9 +25,14 @@ import { } from './api'; import { FirebaseApp, deleteApp } from '@firebase/app'; import { AnalyticsError } from './errors'; -import * as init from './initialize-analytics'; const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' }; -import * as factory from './factory'; + +import * as initAnalytics from './initialize-analytics'; + +vi.mock('./initialize-analytics', { spy: true }); + +import { _setWrappedGtagFunction } from './factory'; + import { defaultConsentSettingsForInit, defaultEventParametersForInit @@ -37,25 +40,25 @@ import { import { ConsentSettings } from './public-types'; describe('FirebaseAnalytics API tests', () => { - let initStub: SinonStub = stub(); let app: FirebaseApp; - const wrappedGtag: SinonStub = stub(); + const wrappedGtag = vi.fn(); beforeEach(() => { - initStub = stub(init, '_initializeAnalytics').resolves( + vi.spyOn(initAnalytics, '_initializeAnalytics').mockResolvedValue( 'FAKE_MEASUREMENT_ID' ); + _setWrappedGtagFunction(undefined); }); afterEach(async () => { - await initStub(); - initStub.restore(); + _setWrappedGtagFunction(undefined); + wrappedGtag.mockReset(); if (app) { return deleteApp(app); } }); - after(() => { + afterAll(() => { delete window['gtag']; delete window['dataLayer']; }); @@ -64,7 +67,7 @@ describe('FirebaseAnalytics API tests', () => { app = getFullApp(fakeAppParams); const analyticsInstance = initializeAnalytics(app); const newInstance = initializeAnalytics(app); - expect(analyticsInstance).to.equal(newInstance); + expect(analyticsInstance).toBe(newInstance); }); it('initializeAnalytics() with same options returns same instance', () => { app = getFullApp(fakeAppParams); @@ -74,7 +77,7 @@ describe('FirebaseAnalytics API tests', () => { const newInstance = initializeAnalytics(app, { config: { 'send_page_view': false } }); - expect(analyticsInstance).to.equal(newInstance); + expect(analyticsInstance).toBe(newInstance); }); it('initializeAnalytics() with different options throws', () => { app = getFullApp(fakeAppParams); @@ -85,7 +88,7 @@ describe('FirebaseAnalytics API tests', () => { initializeAnalytics(app, { config: { 'send_page_view': true } }) - ).to.throw(AnalyticsError.ALREADY_INITIALIZED); + ).toThrow(AnalyticsError.ALREADY_INITIALIZED); }); it('initializeAnalytics() with different options (one undefined) throws', () => { app = getFullApp(fakeAppParams); @@ -94,17 +97,17 @@ describe('FirebaseAnalytics API tests', () => { initializeAnalytics(app, { config: { 'send_page_view': true } }) - ).to.throw(AnalyticsError.ALREADY_INITIALIZED); + ).toThrow(AnalyticsError.ALREADY_INITIALIZED); }); it('getAnalytics() returns same instance created by previous getAnalytics()', () => { app = getFullApp(fakeAppParams); const analyticsInstance = getAnalytics(app); - expect(getAnalytics(app)).to.equal(analyticsInstance); + expect(getAnalytics(app)).toBe(analyticsInstance); }); it('getAnalytics() returns same instance created by initializeAnalytics()', () => { app = getFullApp(fakeAppParams); const analyticsInstance = initializeAnalytics(app); - expect(getAnalytics(app)).to.equal(analyticsInstance); + expect(getAnalytics(app)).toBe(analyticsInstance); }); it('setDefaultEventParameters() updates defaultEventParametersForInit if gtag does not exist ', () => { const eventParametersForInit = { @@ -113,42 +116,37 @@ describe('FirebaseAnalytics API tests', () => { }; app = getFullApp(fakeAppParams); setDefaultEventParameters(eventParametersForInit); - expect(defaultEventParametersForInit).to.deep.equal(eventParametersForInit); + expect(defaultEventParametersForInit).toEqual(eventParametersForInit); }); it('setDefaultEventParameters() calls gtag set if wrappedGtagFunction exists', () => { const eventParametersForInit = { 'github_user': 'dwyfrequency', 'company': 'google' }; - stub(factory, 'wrappedGtagFunction').get(() => wrappedGtag); + _setWrappedGtagFunction(wrappedGtag); app = getFullApp(fakeAppParams); setDefaultEventParameters(eventParametersForInit); - expect(wrappedGtag).to.have.been.calledWithExactly( - 'set', - eventParametersForInit - ); + expect(wrappedGtag).toHaveBeenCalledWith('set', eventParametersForInit); }); it('setConsent() updates defaultConsentSettingsForInit if gtag does not exist ', () => { const consentParametersForInit: ConsentSettings = { 'analytics_storage': 'granted', 'functionality_storage': 'denied' }; - stub(factory, 'wrappedGtagFunction').get(() => undefined); + _setWrappedGtagFunction(undefined); app = getFullApp(fakeAppParams); setConsent(consentParametersForInit); - expect(defaultConsentSettingsForInit).to.deep.equal( - consentParametersForInit - ); + expect(defaultConsentSettingsForInit).toEqual(consentParametersForInit); }); it('setConsent() calls gtag consent "update" if wrappedGtagFunction exists', () => { const consentParametersForInit: ConsentSettings = { 'analytics_storage': 'granted', 'functionality_storage': 'denied' }; - stub(factory, 'wrappedGtagFunction').get(() => wrappedGtag); + _setWrappedGtagFunction(wrappedGtag); app = getFullApp(fakeAppParams); setConsent(consentParametersForInit); - expect(wrappedGtag).to.have.been.calledWithExactly( + expect(wrappedGtag).toHaveBeenCalledWith( 'consent', 'update', consentParametersForInit diff --git a/packages/analytics/src/factory.ts b/packages/analytics/src/factory.ts index b0ef0e04aa..2fdff9a1ec 100644 --- a/packages/analytics/src/factory.ts +++ b/packages/analytics/src/factory.ts @@ -84,6 +84,14 @@ let gtagCoreFunction: Gtag; */ export let wrappedGtagFunction: Gtag; +/** + * For testing + * @internal + */ +export function _setWrappedGtagFunction(fn: Gtag | undefined): void { + wrappedGtagFunction = fn as Gtag; +} + /** * Flag to ensure page initialization steps (creation or wrapping of * dataLayer and gtag script) are only run once per page load. diff --git a/packages/analytics/src/functions.test.ts b/packages/analytics/src/functions.test.ts index 1e4ee4a569..30268e00e5 100644 --- a/packages/analytics/src/functions.test.ts +++ b/packages/analytics/src/functions.test.ts @@ -15,9 +15,7 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonStub, stub } from 'sinon'; -import '../testing/setup'; +import { expect, vi, MockInstance } from 'vitest'; import { setCurrentScreen, logEvent, @@ -39,10 +37,10 @@ const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeInitializationPromise = Promise.resolve(fakeMeasurementId); describe('FirebaseAnalytics methods', () => { - const gtagStub: SinonStub = stub(); + const gtagStub: MockInstance = vi.fn(); afterEach(() => { - gtagStub.reset(); + gtagStub.mockReset(); }); it('logEvent() calls gtag function correctly', async () => { @@ -50,7 +48,7 @@ describe('FirebaseAnalytics methods', () => { currency: 'USD' }); - expect(gtagStub).to.have.been.calledWith(GtagCommand.EVENT, 'add_to_cart', { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.EVENT, 'add_to_cart', { 'send_to': fakeMeasurementId, currency: 'USD' }); @@ -59,7 +57,7 @@ describe('FirebaseAnalytics methods', () => { it('logEvent() with no event params calls gtag function correctly', async () => { await logEvent(gtagStub, fakeInitializationPromise, 'view_item'); - expect(gtagStub).to.have.been.calledWith(GtagCommand.EVENT, 'view_item', { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.EVENT, 'view_item', { 'send_to': fakeMeasurementId }); }); @@ -75,7 +73,7 @@ describe('FirebaseAnalytics methods', () => { { global: true } ); - expect(gtagStub).to.have.been.calledWith(GtagCommand.EVENT, 'add_to_cart', { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.EVENT, 'add_to_cart', { currency: 'USD' }); }); @@ -91,7 +89,7 @@ describe('FirebaseAnalytics methods', () => { } ); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.EVENT, 'add_to_cart', undefined @@ -100,7 +98,7 @@ describe('FirebaseAnalytics methods', () => { it('setCurrentScreen() (deprecated) calls gtag correctly (instance)', async () => { await setCurrentScreen(gtagStub, fakeInitializationPromise, 'home'); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -114,14 +112,14 @@ describe('FirebaseAnalytics methods', () => { await setCurrentScreen(gtagStub, fakeInitializationPromise, 'home', { global: true }); - expect(gtagStub).to.be.calledWith(GtagCommand.SET, { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.SET, { 'screen_name': 'home' }); }); it('setUserId() with null (user) id calls gtag correctly (instance)', async () => { await setUserId(gtagStub, fakeInitializationPromise, null); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -133,7 +131,7 @@ describe('FirebaseAnalytics methods', () => { it('setUserId() calls gtag correctly (instance)', async () => { await setUserId(gtagStub, fakeInitializationPromise, 'user123'); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -147,7 +145,7 @@ describe('FirebaseAnalytics methods', () => { await setUserId(gtagStub, fakeInitializationPromise, 'user123', { global: true }); - expect(gtagStub).to.be.calledWith(GtagCommand.SET, { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.SET, { 'user_id': 'user123' }); }); @@ -156,7 +154,7 @@ describe('FirebaseAnalytics methods', () => { await setUserId(gtagStub, fakeInitializationPromise, null, { global: true }); - expect(gtagStub).to.be.calledWith(GtagCommand.SET, { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.SET, { 'user_id': null }); }); @@ -166,7 +164,7 @@ describe('FirebaseAnalytics methods', () => { 'currency': 'USD', 'language': 'en' }); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -186,7 +184,7 @@ describe('FirebaseAnalytics methods', () => { { 'currency': 'USD', 'language': 'en' }, { global: true } ); - expect(gtagStub).to.be.calledWith(GtagCommand.SET, { + expect(gtagStub).toHaveBeenCalledWith(GtagCommand.SET, { 'user_properties.currency': 'USD', 'user_properties.language': 'en' }); @@ -194,9 +192,9 @@ describe('FirebaseAnalytics methods', () => { it('setAnalyticsCollectionEnabled() calls gtag correctly', async () => { await setAnalyticsCollectionEnabled(fakeInitializationPromise, true); - expect(window[`ga-disable-${fakeMeasurementId}`]).to.be.false; + expect(window[`ga-disable-${fakeMeasurementId}`]).toBe(false); await setAnalyticsCollectionEnabled(fakeInitializationPromise, false); - expect(window[`ga-disable-${fakeMeasurementId}`]).to.be.true; + expect(window[`ga-disable-${fakeMeasurementId}`]).toBe(true); delete window[`ga-disable-${fakeMeasurementId}`]; }); it('_setDefaultEventParametersForInit() stores individual params correctly', async () => { @@ -205,7 +203,7 @@ describe('FirebaseAnalytics methods', () => { 'company': 'google' }; _setDefaultEventParametersForInit(eventParametersForInit); - expect(defaultEventParametersForInit).to.deep.equal(eventParametersForInit); + expect(defaultEventParametersForInit).toEqual(eventParametersForInit); }); it('_setDefaultEventParametersForInit() replaces previous params with new params', async () => { const eventParametersForInit = { @@ -215,7 +213,7 @@ describe('FirebaseAnalytics methods', () => { const additionalParams = { 'food': 'sushi' }; _setDefaultEventParametersForInit(eventParametersForInit); _setDefaultEventParametersForInit(additionalParams); - expect(defaultEventParametersForInit).to.deep.equal({ + expect(defaultEventParametersForInit).toEqual({ ...additionalParams }); }); @@ -225,9 +223,7 @@ describe('FirebaseAnalytics methods', () => { 'functionality_storage': 'denied' }; _setConsentDefaultForInit(consentParametersForInit); - expect(defaultConsentSettingsForInit).to.deep.equal( - consentParametersForInit - ); + expect(defaultConsentSettingsForInit).toEqual(consentParametersForInit); }); it('_setConsentDefaultForInit() replaces previous params with new params', async () => { const consentParametersForInit: ConsentSettings = { @@ -237,7 +233,7 @@ describe('FirebaseAnalytics methods', () => { const additionalParams = { 'wait_for_update': 500 }; _setConsentDefaultForInit(consentParametersForInit); _setConsentDefaultForInit(additionalParams); - expect(defaultConsentSettingsForInit).to.deep.equal({ + expect(defaultConsentSettingsForInit).toEqual({ ...additionalParams }); }); @@ -254,7 +250,7 @@ describe('FirebaseAnalytics methods', () => { } as Gtag, fakeInitializationPromise ) - ).to.be.rejectedWith(AnalyticsError.NO_CLIENT_ID); + ).rejects.toThrow(AnalyticsError.NO_CLIENT_ID); }); it('internalGetGoogleAnalyticsClientId() returns client_id when available', async () => { const CLIENT_ID = 'clientId1234'; @@ -269,6 +265,6 @@ describe('FirebaseAnalytics methods', () => { } as Gtag, fakeInitializationPromise ); - expect(id).to.equal(CLIENT_ID); + expect(id).toBe(CLIENT_ID); }); }); diff --git a/packages/analytics/src/get-config.test.ts b/packages/analytics/src/get-config.test.ts index 9d383a8525..f366222bf4 100644 --- a/packages/analytics/src/get-config.test.ts +++ b/packages/analytics/src/get-config.test.ts @@ -15,9 +15,7 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonStub, stub, useFakeTimers, restore, match } from 'sinon'; -import '../testing/setup'; +import { expect, vi, MockInstance } from 'vitest'; import { fetchDynamicConfig, fetchDynamicConfigWithRetry, @@ -34,28 +32,30 @@ const fakeAppId = 'abcdefgh12345:23405'; const fakeAppParams = { appId: fakeAppId, apiKey: 'AAbbCCdd12345' }; const fakeUrl = DYNAMIC_CONFIG_URL.replace('{app-id}', fakeAppId); const successObject = { measurementId: fakeMeasurementId, appId: fakeAppId }; -let fetchStub: SinonStub; +let fetchStub: MockInstance; function stubFetch(status: number, body: { [key: string]: any }): void { - fetchStub = stub(window, 'fetch'); + fetchStub = vi.spyOn(window, 'fetch'); const mockResponse = new window.Response(JSON.stringify(body), { status }); - fetchStub.returns(Promise.resolve(mockResponse)); + fetchStub.mockResolvedValue(mockResponse); } describe('Dynamic Config Fetch Functions', () => { - afterEach(restore); + afterEach(() => { + vi.restoreAllMocks(); + }); describe('fetchDynamicConfig() - no retry', () => { it('successfully request and receives dynamic config JSON data', async () => { stubFetch(200, successObject); const config: DynamicConfig = await fetchDynamicConfig(fakeAppParams); - expect(fetchStub.args[0][0]).to.equal(fakeUrl); - expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( + expect(fetchStub.mock.calls[0][0]).toBe(fakeUrl); + expect(fetchStub.mock.calls[0][1].headers.get('x-goog-api-key')).toBe( fakeAppParams.apiKey ); - expect(config.appId).to.equal(fakeAppId); - expect(config.measurementId).to.equal(fakeMeasurementId); + expect(config.appId).toBe(fakeAppId); + expect(config.measurementId).toBe(fakeMeasurementId); }); it('throws error on failed response', async () => { stubFetch(500, { @@ -64,14 +64,14 @@ describe('Dynamic Config Fetch Functions', () => { const app = getFakeApp(fakeAppParams); await expect( fetchDynamicConfig(app.options as AppFields) - ).to.be.rejectedWith(AnalyticsError.CONFIG_FETCH_FAILED); + ).rejects.toThrow(AnalyticsError.CONFIG_FETCH_FAILED); }); it('throws error on failed response, includes server error message if provided', async () => { stubFetch(500, { error: { message: 'Oops' } }); const app = getFakeApp(fakeAppParams); await expect( fetchDynamicConfig(app.options as AppFields) - ).to.be.rejectedWith( + ).rejects.toThrow( new RegExp(`Oops.+${AnalyticsError.CONFIG_FETCH_FAILED}`) ); }); @@ -82,19 +82,19 @@ describe('Dynamic Config Fetch Functions', () => { const app = getFakeApp(fakeAppParams); const config: DynamicConfig | MinimalDynamicConfig = await fetchDynamicConfigWithRetry(app); - expect(fetchStub.args[0][0]).to.equal(fakeUrl); - expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( + expect(fetchStub.mock.calls[0][0]).toBe(fakeUrl); + expect(fetchStub.mock.calls[0][1].headers.get('x-goog-api-key')).toBe( fakeAppParams.apiKey ); - expect(config.appId).to.equal(fakeAppId); - expect(config.measurementId).to.equal(fakeMeasurementId); + expect(config.appId).toBe(fakeAppId); + expect(config.measurementId).toBe(fakeMeasurementId); }); it('throws error on non-retriable failed response', async () => { stubFetch(404, { error: {/* no message */} }); const app = getFakeApp(fakeAppParams); - await expect(fetchDynamicConfigWithRetry(app)).to.be.rejectedWith( + await expect(fetchDynamicConfigWithRetry(app)).rejects.toThrow( AnalyticsError.CONFIG_FETCH_FAILED ); }); @@ -102,39 +102,43 @@ describe('Dynamic Config Fetch Functions', () => { stubFetch(404, { error: {/* no message */} }); - const consoleStub = stub(console, 'warn'); + const consoleStub = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); const app = getFakeApp({ ...fakeAppParams, measurementId: fakeMeasurementId }); await fetchDynamicConfigWithRetry(app); - expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); - consoleStub.restore(); + expect(consoleStub).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(fakeMeasurementId) + ); }); it('retries on retriable error until success', async () => { // Configures Date.now() to advance clock from zero in 20ms increments, enabling // tests to assert a known throttle end time and allow setTimeout to work. - const clock = useFakeTimers({ shouldAdvanceTime: true }); + vi.useFakeTimers({ shouldAdvanceTime: true, now: 0 }); // Ensures backoff is always zero, which simplifies reasoning about timer. - const powSpy = stub(Math, 'pow').returns(0); - const randomSpy = stub(Math, 'random').returns(0.5); + vi.spyOn(Math, 'pow').mockReturnValue(0); + vi.spyOn(Math, 'random').mockReturnValue(0.5); const fakeRetryData = { throttleMetadata: {}, - getThrottleMetadata: stub(), - setThrottleMetadata: stub(), - deleteThrottleMetadata: stub(), + getThrottleMetadata: vi.fn(), + setThrottleMetadata: vi.fn(), + deleteThrottleMetadata: vi.fn(), intervalMillis: 5 }; // Returns responses with each of 4 retriable statuses, then a success response. const retriableStatuses = [429, 500, 503, 504]; - fetchStub = stub(window, 'fetch'); - retriableStatuses.forEach((status, index) => { + fetchStub = vi.spyOn(window, 'fetch'); + retriableStatuses.forEach(status => { const failResponse = new window.Response(JSON.stringify({}), { status }); - fetchStub.onCall(index).resolves(failResponse); + fetchStub.mockResolvedValueOnce(failResponse); }); const successResponse = new window.Response( JSON.stringify(successObject), @@ -142,7 +146,7 @@ describe('Dynamic Config Fetch Functions', () => { status: 200 } ); - fetchStub.onCall(retriableStatuses.length).resolves(successResponse); + fetchStub.mockResolvedValueOnce(successResponse); const app = getFakeApp(fakeAppParams); const config: DynamicConfig | MinimalDynamicConfig = @@ -151,29 +155,25 @@ describe('Dynamic Config Fetch Functions', () => { // Verify retryData.setThrottleMetadata() was called on each retry. for (let i = 0; i < retriableStatuses.length; i++) { retriableStatuses[i]; - expect(fakeRetryData.setThrottleMetadata.args[i][1]).to.deep.equal({ + expect(fakeRetryData.setThrottleMetadata.mock.calls[i][1]).toEqual({ backoffCount: i + 1, throttleEndTimeMillis: (i + 1) * 20 }); } - expect(fetchStub.args[0][0]).to.equal(fakeUrl); - expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( + expect(fetchStub.mock.calls[0][0]).toBe(fakeUrl); + expect(fetchStub.mock.calls[0][1].headers.get('x-goog-api-key')).toBe( fakeAppParams.apiKey ); - expect(config.appId).to.equal(fakeAppId); - expect(config.measurementId).to.equal(fakeMeasurementId); - - powSpy.restore(); - randomSpy.restore(); - clock.restore(); + expect(config.appId).toBe(fakeAppId); + expect(config.measurementId).toBe(fakeMeasurementId); }); it('retries on retriable error until aborted by timeout', async () => { const fakeRetryData = { throttleMetadata: {}, - getThrottleMetadata: stub(), - setThrottleMetadata: stub(), - deleteThrottleMetadata: stub(), + getThrottleMetadata: vi.fn(), + setThrottleMetadata: vi.fn(), + deleteThrottleMetadata: vi.fn(), intervalMillis: 10 }; @@ -183,18 +183,18 @@ describe('Dynamic Config Fetch Functions', () => { const app = getFakeApp(fakeAppParams); // Set fetch timeout to 50 ms. const fetchPromise = fetchDynamicConfigWithRetry(app, fakeRetryData, 50); - await expect(fetchPromise).to.be.rejectedWith( - AnalyticsError.FETCH_THROTTLE - ); + await expect(fetchPromise).rejects.toThrow(AnalyticsError.FETCH_THROTTLE); // Should be enough time for at least 2 retries, including fuzzing. - expect(fakeRetryData.setThrottleMetadata.callCount).to.be.greaterThan(1); + expect( + fakeRetryData.setThrottleMetadata.mock.calls.length + ).toBeGreaterThan(1); }); it('retries on 503 error until aborted by timeout', async () => { const fakeRetryData = { throttleMetadata: {}, - getThrottleMetadata: stub(), - setThrottleMetadata: stub(), - deleteThrottleMetadata: stub(), + getThrottleMetadata: vi.fn(), + setThrottleMetadata: vi.fn(), + deleteThrottleMetadata: vi.fn(), intervalMillis: 10 }; @@ -204,17 +204,17 @@ describe('Dynamic Config Fetch Functions', () => { const app = getFakeApp(fakeAppParams); // Set fetch timeout to 50 ms. const fetchPromise = fetchDynamicConfigWithRetry(app, fakeRetryData, 50); - await expect(fetchPromise).to.be.rejectedWith( - AnalyticsError.FETCH_THROTTLE - ); + await expect(fetchPromise).rejects.toThrow(AnalyticsError.FETCH_THROTTLE); const retryTime1 = - fakeRetryData.setThrottleMetadata.args[0][1].throttleEndTimeMillis; + fakeRetryData.setThrottleMetadata.mock.calls[0][1] + .throttleEndTimeMillis; const retryTime2 = - fakeRetryData.setThrottleMetadata.args[1][1].throttleEndTimeMillis; - expect(fakeRetryData.setThrottleMetadata).to.be.called; + fakeRetryData.setThrottleMetadata.mock.calls[1][1] + .throttleEndTimeMillis; + expect(fakeRetryData.setThrottleMetadata).toHaveBeenCalled(); // Interval between first and second retry should be greater than lowest fuzzable // value of LONG_RETRY_FACTOR. - expect(retryTime2 - retryTime1).to.be.at.least( + expect(retryTime2 - retryTime1).toBeGreaterThanOrEqual( Math.floor(LONG_RETRY_FACTOR / 2) * fakeRetryData.intervalMillis ); }); @@ -224,15 +224,17 @@ describe('Dynamic Config Fetch Functions', () => { async () => { const fakeRetryData = { throttleMetadata: {}, - getThrottleMetadata: stub(), - setThrottleMetadata: stub(), - deleteThrottleMetadata: stub(), + getThrottleMetadata: vi.fn(), + setThrottleMetadata: vi.fn(), + deleteThrottleMetadata: vi.fn(), intervalMillis: 10 }; // Always returns retriable server error. stubFetch(500, {}); - const consoleStub = stub(console, 'warn'); + const consoleStub = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); const app = getFakeApp({ ...fakeAppParams, @@ -240,8 +242,10 @@ describe('Dynamic Config Fetch Functions', () => { }); // Set fetch timeout to 50 ms. await fetchDynamicConfigWithRetry(app, fakeRetryData, 50); - expect(consoleStub).calledWith(match.any, match(fakeMeasurementId)); - consoleStub.restore(); + expect(consoleStub).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(fakeMeasurementId) + ); } ); }); diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index ff06ba3ea6..ddacbe1473 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -15,9 +15,7 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonStub, stub } from 'sinon'; -import '../testing/setup'; +import { expect, vi, MockInstance } from 'vitest'; import { DataLayer, Gtag, DynamicConfig } from './types'; import { getOrCreateDataLayer, @@ -53,20 +51,22 @@ const fakeDynamicConfigPromises = [Promise.resolve(fakeDynamicConfig)]; describe('Trusted Types policies and functions', () => { if (window.trustedTypes) { describe('Trusted types exists', () => { - let ttStub: SinonStub; + let ttStub: MockInstance; beforeEach(() => { - ttStub = stub( - window.trustedTypes as TrustedTypePolicyFactory, - 'createPolicy' - ).returns({ - createScriptURL: (s: string) => s - } as any); + ttStub = vi + .spyOn( + window.trustedTypes as TrustedTypePolicyFactory, + 'createPolicy' + ) + .mockReturnValue({ + createScriptURL: (s: string) => s + } as any); }); afterEach(() => { removeGtagScripts(); - ttStub.restore(); + ttStub.mockRestore(); }); it('Verify trustedTypes is called if the API is available', () => { @@ -77,17 +77,19 @@ describe('Trusted Types policies and functions', () => { } ); - expect(ttStub).to.be.called; - expect(trustedTypesPolicy).not.to.be.undefined; + expect(ttStub).toHaveBeenCalled(); + expect(trustedTypesPolicy).toBeDefined(); }); it('createGtagTrustedTypesScriptURL verifies gtag URL base exists when a URL is provided', () => { - expect(createGtagTrustedTypesScriptURL(GTAG_URL)).to.equal(GTAG_URL); + expect(createGtagTrustedTypesScriptURL(GTAG_URL)).toBe(GTAG_URL); }); it('createGtagTrustedTypesScriptURL rejects URLs with non-gtag base', () => { const NON_GTAG_URL = 'http://iamnotgtag.com'; - const loggerWarnStub = stub(logger, 'warn'); + const loggerWarnStub = vi + .spyOn(logger, 'warn') + .mockImplementation(() => {}); const errorMessage = ERROR_FACTORY.create( AnalyticsError.INVALID_GTAG_RESOURCE, { @@ -95,8 +97,8 @@ describe('Trusted Types policies and functions', () => { } ).message; - expect(createGtagTrustedTypesScriptURL(NON_GTAG_URL)).to.equal(''); - expect(loggerWarnStub).to.be.calledWith(errorMessage); + expect(createGtagTrustedTypesScriptURL(NON_GTAG_URL)).toBe(''); + expect(loggerWarnStub).toHaveBeenCalledWith(errorMessage); }); }); } @@ -110,7 +112,7 @@ describe('Trusted Types policies and functions', () => { } ); - expect(trustedTypesPolicy).to.be.undefined; + expect(trustedTypesPolicy).toBeUndefined(); }); }); }); @@ -122,22 +124,22 @@ describe('Gtag wrapping functions', () => { it('getOrCreateDataLayer is able to create a new data layer if none exists', () => { delete window['dataLayer']; - expect(getOrCreateDataLayer('dataLayer')).to.deep.equal([]); + expect(getOrCreateDataLayer('dataLayer')).toEqual([]); }); it('getOrCreateDataLayer is able to correctly identify an existing data layer', () => { const existingDataLayer = (window['dataLayer'] = []); - expect(getOrCreateDataLayer('dataLayer')).to.equal(existingDataLayer); + expect(getOrCreateDataLayer('dataLayer')).toBe(existingDataLayer); }); it('insertScriptIfNeeded inserts script tag', () => { const customDataLayerName = 'customDataLayerName'; - expect(findGtagScriptOnPage(customDataLayerName)).to.be.null; + expect(findGtagScriptOnPage(customDataLayerName)).toBeNull(); insertScriptTag(customDataLayerName, fakeMeasurementId); const scriptTag = findGtagScriptOnPage(customDataLayerName); - expect(scriptTag).to.not.be.null; - expect(scriptTag!.src).to.contain(`l=customDataLayerName`); - expect(scriptTag!.src).to.contain(`id=${fakeMeasurementId}`); + expect(scriptTag).not.toBeNull(); + expect(scriptTag!.src).toContain(`l=customDataLayerName`); + expect(scriptTag!.src).toContain(`id=${fakeMeasurementId}`); }); // The test above essentially already touches this functionality but it is still valuable @@ -145,8 +147,8 @@ describe('Gtag wrapping functions', () => { const defaultDataLayerName = 'dataLayer'; insertScriptTag(defaultDataLayerName, fakeMeasurementId); const scriptTag = findGtagScriptOnPage(defaultDataLayerName); - expect(scriptTag!.src).to.contain(`l=${defaultDataLayerName}`); - expect(findGtagScriptOnPage('NON_EXISTENT_DATA_LAYER_ID')).to.be.null; + expect(scriptTag!.src).toContain(`l=${defaultDataLayerName}`); + expect(findGtagScriptOnPage('NON_EXISTENT_DATA_LAYER_ID')).toBeNull(); }); describe('wrapOrCreateGtag() when user has not previously inserted a gtag script tag on this page', () => { @@ -156,9 +158,9 @@ describe('Gtag wrapping functions', () => { }); it('wrapOrCreateGtag creates new gtag function if needed', () => { - expect(window['gtag']).to.not.exist; + expect(window['gtag']).toBeFalsy(); wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); - expect(window['gtag']).to.exist; + expect(window['gtag']).toBeDefined(); }); it('new window.gtag function waits for all initialization promises before sending group events', async () => { @@ -180,20 +182,20 @@ describe('Gtag wrapping functions', () => { 'send_to': 'some_group' }; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise1.resolve(fakeMeasurementId); // Resolves first initialization promise. - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise2.resolve('other-measurement-id'); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() await promiseAllSettled(fakeDynamicConfigPromises); const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('event'); - expect(data[1]).to.equal('purchase'); - expect(data[2]).to.equal(eventObject); + expect(data[0]).toBe('event'); + expect(data[1]).toBe('purchase'); + expect(data[2]).toBe(eventObject); }); it( @@ -218,21 +220,21 @@ describe('Gtag wrapping functions', () => { 'send_to': [fakeMeasurementId, 'some_group'] }; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise1.resolve(); // Resolves first initialization promise. - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise2.resolve(); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() await promiseAllSettled(fakeDynamicConfigPromises); const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('event'); - expect(data[1]).to.equal('purchase'); - expect(data[2]).to.equal(eventObject); + expect(data[0]).toBe('event'); + expect(data[1]).toBe('purchase'); + expect(data[2]).toBe(eventObject); } ); @@ -257,20 +259,20 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd123' }; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise1.resolve(); // Resolves first initialization promise. - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise2.resolve(); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('event'); - expect(data[1]).to.equal('purchase'); - expect(data[2]).to.equal(eventObject); + expect(data[0]).toBe('event'); + expect(data[1]).toBe('purchase'); + expect(data[2]).toBe(eventObject); } ); @@ -296,18 +298,18 @@ describe('Gtag wrapping functions', () => { 'send_to': fakeMeasurementId }; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', eventObject); - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise1.resolve(); // Resolves first initialization promise. await promiseAllSettled(fakeDynamicConfigPromises); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('event'); - expect(data[1]).to.equal('purchase'); - expect(data[2]).to.equal(eventObject); + expect(data[0]).toBe('event'); + expect(data[1]).toBe('purchase'); + expect(data[2]).toBe(eventObject); } ); @@ -318,7 +320,7 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd123' }); await Promise.all([]); // Promise.all() always runs before event call, even if empty. - expect((window['dataLayer'] as DataLayer).length).to.equal(1); + expect((window['dataLayer'] as DataLayer).length).toBe(1); }); it('new window.gtag function does not wait when sending "set" calls', async () => { @@ -333,10 +335,10 @@ describe('Gtag wrapping functions', () => { const eventObject = { 'language': 'en' }; (window['gtag'] as Gtag)(GtagCommand.SET, eventObject); const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('set'); - expect(data[1]).to.equal(eventObject); + expect(data[0]).toBe('set'); + expect(data[1]).toBe(eventObject); }); it('new window.gtag function does not wait when sending "consent" calls', async () => { @@ -358,11 +360,11 @@ describe('Gtag wrapping functions', () => { consentParameters ); const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('consent'); - expect(data[1]).to.equal('update'); - expect(data[2]).to.equal(consentParameters); + expect(data[0]).toBe('consent'); + expect(data[1]).toBe('update'); + expect(data[2]).toBe(consentParameters); }); it('new window.gtag function does not wait when sending "get" calls', async () => { @@ -381,12 +383,12 @@ describe('Gtag wrapping functions', () => { clientId => console.log(clientId) ); const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('get'); - expect(data[1]).to.equal(fakeMeasurementId); - expect(data[2]).to.equal('client_id'); - expect(data[3]).to.not.be.undefined; + expect(data[0]).toBe('get'); + expect(data[1]).toBe(fakeMeasurementId); + expect(data[2]).toBe('client_id'); + expect(data[3]).toBeDefined(); }); it('new window.gtag function does not wait when sending an unknown command', async () => { @@ -400,10 +402,10 @@ describe('Gtag wrapping functions', () => { window['dataLayer'] = []; (window['gtag'] as Gtag)('new-command-from-gtag-team', fakeMeasurementId); const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('new-command-from-gtag-team'); - expect(data[1]).to.equal(fakeMeasurementId); + expect(data[0]).toBe('new-command-from-gtag-team'); + expect(data[1]).toBe(fakeMeasurementId); }); it('new window.gtag function waits for initialization promise when sending "config" calls', async () => { @@ -424,19 +426,19 @@ describe('Gtag wrapping functions', () => { fakeMeasurementId, eventObject ); - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); initPromise1.resolve(fakeMeasurementId); await promiseAllSettled(fakeDynamicConfigPromises); // Resolves dynamic config fetches. - expect((window['dataLayer'] as DataLayer).length).to.equal(0); + expect((window['dataLayer'] as DataLayer).length).toBe(0); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('config'); - expect(data[1]).to.equal(fakeMeasurementId); - expect(data[2]).to.equal(eventObject); + expect(data[0]).toBe('config'); + expect(data[1]).toBe(fakeMeasurementId); + expect(data[2]).toBe(eventObject); }); it('new window.gtag function does not wait when sending "config" calls if there are no pending initialization promises', async () => { @@ -453,23 +455,23 @@ describe('Gtag wrapping functions', () => { await promiseAllSettled(fakeDynamicConfigPromises); await Promise.resolve(); // Config call is always chained onto initialization promise list, even if empty. const dataLayer = window['dataLayer'] as DataLayer; - expect(dataLayer.length).to.equal(1); + expect(dataLayer.length).toBe(1); const data = dataLayer[0]; - expect(data[0]).to.equal('config'); - expect(data[1]).to.equal(fakeMeasurementId); - expect(data[2]).to.equal(eventObject); + expect(data[0]).toBe('config'); + expect(data[1]).toBe(fakeMeasurementId); + expect(data[2]).toBe(eventObject); }); }); describe('wrapOrCreateGtag() when user has previously inserted gtag script tag on this page', () => { - const existingGtagStub: SinonStub = stub(); + const existingGtagStub: MockInstance = vi.fn(); beforeEach(() => { window['gtag'] = existingGtagStub; }); afterEach(() => { - existingGtagStub.reset(); + existingGtagStub.mockReset(); }); it('new window.gtag function waits for all initialization promises before sending group events', async () => { @@ -489,21 +491,25 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd123', 'send_to': 'some_group' }); - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise1.resolve(); // Resolves first initialization promise. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise2.resolve(); // Resolves second initialization promise. await promiseAllSettled(fakeDynamicConfigPromises); // Resolves dynamic config fetches. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() - expect(existingGtagStub).to.be.calledWith(GtagCommand.EVENT, 'purchase', { - 'send_to': 'some_group', - 'transaction_id': 'abcd123' - }); + expect(existingGtagStub).toHaveBeenCalledWith( + GtagCommand.EVENT, + 'purchase', + { + 'send_to': 'some_group', + 'transaction_id': 'abcd123' + } + ); }); it( @@ -526,18 +532,18 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd123', 'send_to': [fakeMeasurementId, 'some_group'] }); - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise1.resolve(); // Resolves first initialization promise. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise2.resolve(); // Resolves second initialization promise. await promiseAllSettled(fakeDynamicConfigPromises); // Resolves dynamic config fetches. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() - expect(existingGtagStub).to.be.calledWith( + expect(existingGtagStub).toHaveBeenCalledWith( GtagCommand.EVENT, 'purchase', { @@ -567,16 +573,16 @@ describe('Gtag wrapping functions', () => { (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123' }); - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise1.resolve(); // Resolves first initialization promise. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise2.resolve(); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() - expect(existingGtagStub).to.be.calledWith( + expect(existingGtagStub).toHaveBeenCalledWith( GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123' } @@ -604,15 +610,15 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd123', 'send_to': fakeMeasurementId }); - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise1.resolve(); // Resolves first initialization promise. await promiseAllSettled(fakeDynamicConfigPromises); // Resolves dynamic config fetches. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() - expect(existingGtagStub).to.be.calledWith( + expect(existingGtagStub).toHaveBeenCalledWith( GtagCommand.EVENT, 'purchase', { 'send_to': fakeMeasurementId, 'transaction_id': 'abcd123' } @@ -627,9 +633,13 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd321' }); await Promise.all([]); // Promise.all() always runs before event call, even if empty. - expect(existingGtagStub).to.be.calledWith(GtagCommand.EVENT, 'purchase', { - 'transaction_id': 'abcd321' - }); + expect(existingGtagStub).toHaveBeenCalledWith( + GtagCommand.EVENT, + 'purchase', + { + 'transaction_id': 'abcd321' + } + ); }); it('wrapped window.gtag function does not wait when sending "set" calls', async () => { @@ -642,7 +652,7 @@ describe('Gtag wrapping functions', () => { ); window['dataLayer'] = []; (window['gtag'] as Gtag)(GtagCommand.SET, { 'language': 'en' }); - expect(existingGtagStub).to.be.calledWith(GtagCommand.SET, { + expect(existingGtagStub).toHaveBeenCalledWith(GtagCommand.SET, { 'language': 'en' }); }); @@ -660,15 +670,15 @@ describe('Gtag wrapping functions', () => { (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { 'language': 'en' }); - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); initPromise1.resolve(fakeMeasurementId); await promiseAllSettled(fakeDynamicConfigPromises); // Resolves dynamic config fetches. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() - expect(existingGtagStub).to.be.calledWith( + expect(existingGtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -684,9 +694,9 @@ describe('Gtag wrapping functions', () => { 'transaction_id': 'abcd123' }); await promiseAllSettled(fakeDynamicConfigPromises); // Resolves dynamic config fetches. - expect(existingGtagStub).to.not.be.called; + expect(existingGtagStub).not.toHaveBeenCalled(); await Promise.resolve(); // Config call is always chained onto initialization promise list, even if empty. - expect(existingGtagStub).to.be.calledWith( + expect(existingGtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { diff --git a/packages/analytics/src/index.test.ts b/packages/analytics/src/index.test.ts index e3188fc1f8..4c856f0395 100644 --- a/packages/analytics/src/index.test.ts +++ b/packages/analytics/src/index.test.ts @@ -15,9 +15,7 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonStub, stub, useFakeTimers } from 'sinon'; -import '../testing/setup'; +import { expect, vi, MockInstance } from 'vitest'; import { settings } from './index'; import { getFakeApp, @@ -41,10 +39,9 @@ import { _FirebaseInstallationsInternal } from '@firebase/installations'; let analyticsInstance: AnalyticsService = {} as AnalyticsService; const fakeMeasurementId = 'abcd-efgh'; const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' }; -let fetchStub: SinonStub = stub(); +let fetchStub: MockInstance = vi.fn(); const customGtagName = 'customGtag'; const customDataLayerName = 'customDataLayer'; -let clock: sinon.SinonFakeTimers; let fakeInstallations: _FirebaseInstallationsInternal; // Fake indexedDB.open() request @@ -54,20 +51,20 @@ const fakeRequest = { close: () => {} } }; -let idbOpenStub = stub(); +let idbOpenStub: MockInstance = vi.fn(); function stubFetch(status: number, body: object): void { - fetchStub = stub(window, 'fetch'); + fetchStub = vi.spyOn(window, 'fetch'); const mockResponse = new Response(JSON.stringify(body), { status }); - fetchStub.returns(Promise.resolve(mockResponse)); + fetchStub.mockResolvedValue(mockResponse); } // Stub indexedDB.open() because sinon's clock does not know // how to wait for the real indexedDB callbacks to resolve. function stubIdbOpen(): void { - idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any); + idbOpenStub = vi.spyOn(indexedDB, 'open').mockReturnValue(fakeRequest as any); } describe('FirebaseAnalytics instance tests', () => { @@ -79,21 +76,21 @@ describe('FirebaseAnalytics instance tests', () => { it('Throws if no appId in config', () => { const app = getFakeApp({ apiKey: fakeAppParams.apiKey }); - expect(() => analyticsFactory(app, fakeInstallations)).to.throw( + expect(() => analyticsFactory(app, fakeInstallations)).toThrow( AnalyticsError.NO_APP_ID ); }); it('Throws if no apiKey or measurementId in config', () => { const app = getFakeApp({ appId: fakeAppParams.appId }); - expect(() => analyticsFactory(app, fakeInstallations)).to.throw( + expect(() => analyticsFactory(app, fakeInstallations)).toThrow( AnalyticsError.NO_API_KEY ); }); it('Warns if config has no apiKey but does have a measurementId', async () => { // Since this is a warning and doesn't block the rest of initialization // all the async stuff needs to be stubbed and cleaned up. - const warnStub = stub(console, 'warn'); - const docStub = stub(document, 'createElement'); + const warnStub = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const docStub = vi.spyOn(document, 'createElement'); stubFetch(200, { measurementId: fakeMeasurementId }); const app = getFakeApp({ appId: fakeAppParams.appId, @@ -103,22 +100,23 @@ describe('FirebaseAnalytics instance tests', () => { analyticsFactory(app, fakeInstallations); // Successfully resolves fake IDB open request. fakeRequest.onsuccess(); - // Lets async IDB validation process complete. - // await clock.runAllAsync(); - expect(warnStub.args[0][1]).to.include( + const { initializationPromisesMap } = getGlobalVars(); + await initializationPromisesMap[fakeAppParams.appId]; + expect(warnStub.mock.calls[0][1]).toContain( `Falling back to the measurement ID ${fakeMeasurementId}` ); - warnStub.restore(); - docStub.restore(); - fetchStub.restore(); - idbOpenStub.restore(); + warnStub.mockRestore(); + docStub.mockRestore(); + fetchStub.mockRestore(); + idbOpenStub.mockRestore(); delete window['gtag']; delete window['dataLayer']; + removeGtagScripts(); }); it('Throws if creating an instance with already-used appId', () => { const app = getFakeApp(fakeAppParams); resetGlobalVars(false, { [fakeAppParams.appId]: Promise.resolve() }); - expect(() => analyticsFactory(app, fakeInstallations)).to.throw( + expect(() => analyticsFactory(app, fakeInstallations)).toThrow( AnalyticsError.ALREADY_EXISTS ); }); @@ -126,9 +124,9 @@ describe('FirebaseAnalytics instance tests', () => { describe('Standard app, page already has user gtag script', () => { let app: FirebaseApp = {} as FirebaseApp; let fidDeferred: Deferred; - const gtagStub: SinonStub = stub(); + const gtagStub: MockInstance = vi.fn(); beforeEach(async () => { - clock = useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); resetGlobalVars(); app = getFakeApp(fakeAppParams); fidDeferred = new Deferred(); @@ -143,25 +141,26 @@ describe('FirebaseAnalytics instance tests', () => { // Successfully resolves fake IDB open request. fakeRequest.onsuccess(); }); - afterEach(() => { + afterEach(async () => { + await vi.runAllTimersAsync(); delete window['gtag']; delete window['dataLayer']; removeGtagScripts(); - fetchStub.restore(); - idbOpenStub.restore(); - clock.restore(); + fetchStub.mockRestore(); + idbOpenStub.mockRestore(); + vi.useRealTimers(); }); it('Contains reference to parent app', () => { - expect(analyticsInstance.app).to.equal(app); + expect(analyticsInstance.app).toBe(app); }); it('Calls gtag correctly on logEvent (instance)', async () => { logEvent(analyticsInstance, 'add_payment_info', { currency: 'USD' }); // Clear promise chain started by logEvent. - await clock.runAllAsync(); - expect(gtagStub).to.have.been.calledWith('js'); - expect(gtagStub).to.have.been.calledWith( + await vi.runAllTimersAsync(); + expect(gtagStub).toHaveBeenCalledWith('js', expect.any(Date)); + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -170,7 +169,7 @@ describe('FirebaseAnalytics instance tests', () => { update: true } ); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.EVENT, 'add_payment_info', { @@ -183,12 +182,11 @@ describe('FirebaseAnalytics instance tests', () => { describe('Standard app, mismatched environment', () => { let app: FirebaseApp = {} as FirebaseApp; - const gtagStub: SinonStub = stub(); + const gtagStub: MockInstance = vi.fn(); let fidDeferred: Deferred; - let warnStub: SinonStub; - let cookieStub: SinonStub; + let warnStub: MockInstance; beforeEach(() => { - clock = useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); resetGlobalVars(); app = getFakeApp(fakeAppParams); fidDeferred = new Deferred(); @@ -198,92 +196,95 @@ describe('FirebaseAnalytics instance tests', () => { window['gtag'] = gtagStub; window['dataLayer'] = []; stubFetch(200, { measurementId: fakeMeasurementId }); - warnStub = stub(console, 'warn'); + warnStub = vi.spyOn(console, 'warn').mockImplementation(() => {}); stubIdbOpen(); }); afterEach(() => { delete window['gtag']; delete window['dataLayer']; removeGtagScripts(); - fetchStub.restore(); - warnStub.restore(); - idbOpenStub.restore(); - gtagStub.resetHistory(); - clock.restore(); + gtagStub.mockClear(); }); it('Warns on initialization if cookies not available', async () => { - cookieStub = stub(navigator, 'cookieEnabled').value(false); + vi.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(false); analyticsInstance = analyticsFactory(app, fakeInstallations); // Successfully resolves fake IDB open request. fakeRequest.onsuccess(); - expect(warnStub.args[0][1]).to.include( + expect(warnStub).toHaveBeenCalled(); + const warningMessage = warnStub.mock.calls[0][1]; + expect(warningMessage).toContain( AnalyticsError.INVALID_ANALYTICS_CONTEXT ); - expect(warnStub.args[0][1]).to.include('Cookies'); - cookieStub.restore(); + expect(warningMessage).toContain('Cookies'); }); it('Warns on initialization if in browser extension', async () => { window.chrome = { runtime: { id: 'blah' } }; analyticsInstance = analyticsFactory(app, fakeInstallations); // Successfully resolves fake IDB open request. fakeRequest.onsuccess(); - expect(warnStub.args[0][1]).to.include( + expect(warnStub).toHaveBeenCalled(); + const warningMessage = warnStub.mock.calls[0][1]; + expect(warningMessage).toContain( AnalyticsError.INVALID_ANALYTICS_CONTEXT ); - expect(warnStub.args[0][1]).to.include('browser extension'); + expect(warningMessage).toContain('browser extension'); window.chrome = undefined; }); it('Warns on logEvent if indexedDB API not available', async () => { - idbOpenStub.restore(); - const idbStub = stub(window, 'indexedDB').value(undefined); + idbOpenStub.mockRestore(); + const idbStub = vi + .spyOn(window, 'indexedDB', 'get') + .mockReturnValue(undefined as any); try { analyticsInstance = analyticsFactory(app, fakeInstallations); logEvent(analyticsInstance, 'add_payment_info', { currency: 'USD' }); // Clear promise chain started by logEvent. - await clock.runAllAsync(); + await vi.runAllTimersAsync(); // gtag config call omits FID - expect(gtagStub).to.be.calledWith('config', 'abcd-efgh', { + expect(gtagStub).toHaveBeenCalledWith('config', 'abcd-efgh', { update: true, origin: 'firebase' }); - expect(warnStub.args[0][1]).to.include( - AnalyticsError.INDEXEDDB_UNAVAILABLE - ); - expect(warnStub.args[0][1]).to.include('IndexedDB is not available'); + expect(warnStub).toHaveBeenCalled(); + const warningMessage1 = warnStub.mock.calls[0][1]; + expect(warningMessage1).toContain(AnalyticsError.INDEXEDDB_UNAVAILABLE); + expect(warningMessage1).toContain('IndexedDB is not available'); } finally { - idbStub.restore(); + idbStub.mockRestore(); stubIdbOpen(); } }); it('Warns on logEvent if indexedDB.open() not allowed', async () => { - idbOpenStub.restore(); - idbOpenStub = stub(indexedDB, 'open').throws('idb open error test'); + idbOpenStub.mockRestore(); + idbOpenStub = vi.spyOn(indexedDB, 'open').mockImplementation(() => { + throw new Error('idb open error test'); + }); analyticsInstance = analyticsFactory(app, fakeInstallations); logEvent(analyticsInstance, 'add_payment_info', { currency: 'USD' }); // Clear promise chain started by logEvent. - await clock.runAllAsync(); + await vi.runAllTimersAsync(); // gtag config call omits FID - expect(gtagStub).to.be.calledWith('config', 'abcd-efgh', { + expect(gtagStub).toHaveBeenCalledWith('config', 'abcd-efgh', { update: true, origin: 'firebase' }); - expect(warnStub.args[0][1]).to.include( - AnalyticsError.INDEXEDDB_UNAVAILABLE - ); - expect(warnStub.args[0][1]).to.include('idb open error test'); + expect(warnStub).toHaveBeenCalled(); + const warningMessage2 = warnStub.mock.calls[0][1]; + expect(warningMessage2).toContain(AnalyticsError.INDEXEDDB_UNAVAILABLE); + expect(warningMessage2).toContain('idb open error test'); }); }); describe('Page has user gtag script with custom gtag and dataLayer names', () => { let app: FirebaseApp = {} as FirebaseApp; let fidDeferred: Deferred; - const gtagStub: SinonStub = stub(); + const gtagStub: MockInstance = vi.fn(); beforeEach(() => { - clock = useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); resetGlobalVars(); app = getFakeApp(fakeAppParams); fidDeferred = new Deferred(); @@ -302,22 +303,20 @@ describe('FirebaseAnalytics instance tests', () => { // Successfully resolves fake IDB open request. fakeRequest.onsuccess(); }); - afterEach(() => { + afterEach(async () => { + await vi.runAllTimersAsync(); delete window[customGtagName]; delete window[customDataLayerName]; removeGtagScripts(); - fetchStub.restore(); - idbOpenStub.restore(); - clock.restore(); }); it('Calls gtag correctly on logEvent (instance)', async () => { logEvent(analyticsInstance, 'add_payment_info', { currency: 'USD' }); // Clear promise chain started by logEvent. - await clock.runAllAsync(); - expect(gtagStub).to.have.been.calledWith('js'); - expect(gtagStub).to.have.been.calledWith( + await vi.runAllTimersAsync(); + expect(gtagStub).toHaveBeenCalledWith('js', expect.any(Date)); + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONFIG, fakeMeasurementId, { @@ -326,7 +325,7 @@ describe('FirebaseAnalytics instance tests', () => { update: true } ); - expect(gtagStub).to.have.been.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.EVENT, 'add_payment_info', { @@ -350,15 +349,13 @@ describe('FirebaseAnalytics instance tests', () => { // Successfully resolves fake IDB open request. fakeRequest.onsuccess(); await initializationPromisesMap[fakeAppParams.appId]; - expect(findGtagScriptOnPage('dataLayer')).to.not.be.null; - expect(typeof window['gtag']).to.equal('function'); - expect(Array.isArray(window['dataLayer'])).to.be.true; + expect(findGtagScriptOnPage('dataLayer')).not.toBeNull(); + expect(typeof window['gtag']).toBe('function'); + expect(Array.isArray(window['dataLayer'])).toBe(true); delete window['gtag']; delete window['dataLayer']; removeGtagScripts(); - fetchStub.restore(); - idbOpenStub.restore(); }); }); }); diff --git a/packages/analytics/src/initialize-analytics.test.ts b/packages/analytics/src/initialize-analytics.test.ts index 8760a63b2f..46a43277c2 100644 --- a/packages/analytics/src/initialize-analytics.test.ts +++ b/packages/analytics/src/initialize-analytics.test.ts @@ -15,9 +15,7 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonStub, stub } from 'sinon'; -import '../testing/setup'; +import { expect, vi, MockInstance } from 'vitest'; import { _initializeAnalytics } from './initialize-analytics'; import { getFakeApp, @@ -41,22 +39,22 @@ const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeFid = 'fid-1234-zyxw'; const fakeAppId = 'abcdefgh12345:23405'; const fakeAppParams = { appId: fakeAppId, apiKey: 'AAbbCCdd12345' }; -let fetchStub: SinonStub; +let fetchStub: MockInstance; let fakeInstallations: _FirebaseInstallationsInternal; function stubFetch(): void { - fetchStub = stub(window, 'fetch'); + fetchStub = vi.spyOn(window, 'fetch'); const mockResponse = new window.Response( JSON.stringify({ measurementId: fakeMeasurementId, appId: fakeAppId }), { status: 200 } ); - fetchStub.returns(Promise.resolve(mockResponse)); + fetchStub.mockResolvedValue(mockResponse); } describe('initializeAnalytics()', () => { - const gtagStub: SinonStub = stub(); + const gtagStub: MockInstance = vi.fn(); const dynamicPromisesList: Array> = []; const measurementIdToAppId: { [key: string]: string } = {}; let app: FirebaseApp; @@ -67,7 +65,6 @@ describe('initializeAnalytics()', () => { fakeInstallations = getFakeInstallations(fakeFid, fidDeferred.resolve); }); afterEach(() => { - fetchStub.restore(); removeGtagScripts(); }); it('gets FID and measurement ID and calls gtag config with them', async () => { @@ -80,11 +77,15 @@ describe('initializeAnalytics()', () => { gtagStub, 'dataLayer' ); - expect(gtagStub).to.be.calledWith(GtagCommand.CONFIG, fakeMeasurementId, { - 'firebase_id': fakeFid, - 'origin': 'firebase', - update: true - }); + expect(gtagStub).toHaveBeenCalledWith( + GtagCommand.CONFIG, + fakeMeasurementId, + { + 'firebase_id': fakeFid, + 'origin': 'firebase', + update: true + } + ); }); it('calls gtag config with options if provided', async () => { stubFetch(); @@ -97,12 +98,16 @@ describe('initializeAnalytics()', () => { 'dataLayer', { config: { 'send_page_view': false } } ); - expect(gtagStub).to.be.calledWith(GtagCommand.CONFIG, fakeMeasurementId, { - 'firebase_id': fakeFid, - 'origin': 'firebase', - update: true, - 'send_page_view': false - }); + expect(gtagStub).toHaveBeenCalledWith( + GtagCommand.CONFIG, + fakeMeasurementId, + { + 'firebase_id': fakeFid, + 'origin': 'firebase', + update: true, + 'send_page_view': false + } + ); }); it('calls gtag set if there are default event parameters', async () => { stubFetch(); @@ -119,9 +124,12 @@ describe('initializeAnalytics()', () => { gtagStub, 'dataLayer' ); - expect(gtagStub).to.be.calledWith(GtagCommand.SET, eventParametersForInit); + expect(gtagStub).toHaveBeenCalledWith( + GtagCommand.SET, + eventParametersForInit + ); // defaultEventParametersForInit is reset after initialization. - expect(defaultEventParametersForInit).to.equal(undefined); + expect(defaultEventParametersForInit).toBe(undefined); }); it('calls gtag consent if there are default consent parameters', async () => { stubFetch(); @@ -138,13 +146,13 @@ describe('initializeAnalytics()', () => { gtagStub, 'dataLayer' ); - expect(gtagStub).to.be.calledWith( + expect(gtagStub).toHaveBeenCalledWith( GtagCommand.CONSENT, 'default', consentParametersForInit ); // defaultEventParametersForInit is reset after initialization. - expect(defaultConsentSettingsForInit).to.equal(undefined); + expect(defaultConsentSettingsForInit).toBe(undefined); }); it('puts dynamic fetch promise into dynamic promises list', async () => { stubFetch(); @@ -157,8 +165,8 @@ describe('initializeAnalytics()', () => { 'dataLayer' ); const dynamicPromiseResult = await dynamicPromisesList[0]; - expect(dynamicPromiseResult.measurementId).to.equal(fakeMeasurementId); - expect(dynamicPromiseResult.appId).to.equal(fakeAppId); + expect(dynamicPromiseResult.measurementId).toBe(fakeMeasurementId); + expect(dynamicPromiseResult.appId).toBe(fakeAppId); }); it('puts dynamically fetched measurementId into lookup table', async () => { stubFetch(); @@ -170,11 +178,11 @@ describe('initializeAnalytics()', () => { gtagStub, 'dataLayer' ); - expect(measurementIdToAppId[fakeMeasurementId]).to.equal(fakeAppId); + expect(measurementIdToAppId[fakeMeasurementId]).toBe(fakeAppId); }); it('warns on local/fetched measurement ID mismatch', async () => { stubFetch(); - const consoleStub = stub(console, 'warn'); + const consoleStub = vi.spyOn(console, 'warn').mockImplementation(() => {}); await _initializeAnalytics( getFakeApp({ ...fakeAppParams, measurementId: 'old-measurement-id' }), dynamicPromisesList, @@ -183,9 +191,10 @@ describe('initializeAnalytics()', () => { gtagStub, 'dataLayer' ); - expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); - expect(consoleStub.args[0][1]).to.include('old-measurement-id'); - expect(consoleStub.args[0][1]).to.include('does not match'); - consoleStub.restore(); + expect(consoleStub).toHaveBeenCalled(); + const warningMessage = consoleStub.mock.calls[0][1]; + expect(warningMessage).toContain(fakeMeasurementId); + expect(warningMessage).toContain('old-measurement-id'); + expect(warningMessage).toContain('does not match'); }); }); diff --git a/packages/analytics/karma.integration.conf.js b/packages/analytics/test/setup.ts similarity index 57% rename from packages/analytics/karma.integration.conf.js rename to packages/analytics/test/setup.ts index 9421525245..cde8f508de 100644 --- a/packages/analytics/karma.integration.conf.js +++ b/packages/analytics/test/setup.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,18 +15,10 @@ * limitations under the License. */ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const karmaBase = require('../../config/karma.base'); +import { vi, afterEach } from 'vitest'; -const files = [`./testing/integration-tests/integration.ts`]; - -module.exports = function (config) { - config.set({ - ...karmaBase, - files, - preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, - frameworks: ['mocha'] - }); -}; - -module.exports.files = files; +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); diff --git a/packages/analytics/test/types/vitest-globals.d.ts b/packages/analytics/test/types/vitest-globals.d.ts new file mode 100644 index 0000000000..8e8f3552d9 --- /dev/null +++ b/packages/analytics/test/types/vitest-globals.d.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'vitest/globals'; diff --git a/packages/analytics/testing/integration-tests/integration.ts b/packages/analytics/testing/integration-tests/integration.ts index b920973a79..38121e5607 100644 --- a/packages/analytics/testing/integration-tests/integration.ts +++ b/packages/analytics/testing/integration-tests/integration.ts @@ -19,18 +19,8 @@ import { initializeApp, deleteApp, FirebaseApp } from '@firebase/app'; import '@firebase/installations'; import { getAnalytics, initializeAnalytics, logEvent } from '../../src/index'; import '../setup'; -import { expect } from 'chai'; -import { stub } from 'sinon'; - -let config: Record; -try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - config = require('../../../../config/project.json'); -} catch (e) { - throw new Error( - "Couldn't find config/project.json, make sure you ran test:setup." - ); -} +import { expect, vi } from 'vitest'; +import config from '../../../../config/project.json'; const RETRY_INTERVAL = 1000; const TIMEOUT_MILLIS = 20000; @@ -62,28 +52,35 @@ describe('FirebaseAnalytics Integration Smoke Tests', () => { app = initializeApp(config); logEvent(getAnalytics(app), 'login', { method: 'phone' }); const eventCalls = await checkForEventCalls(); - expect(eventCalls.length).to.equal(1); - expect(eventCalls[0].name).to.include('method=phone'); + expect(eventCalls.length).toBe(1); + expect(eventCalls[0].name).toContain('method=phone'); }); - it("Warns if measurement ID doesn't match.", done => { - const warnStub = stub(console, 'warn').callsFake(() => { - expect(warnStub.args[0][1]).to.include('does not match'); - done(); + it("Warns if measurement ID doesn't match.", () => { + return new Promise(resolve => { + vi.spyOn(console, 'warn').mockImplementation((_tag, message) => { + expect(message).toContain('does not match'); + resolve(); + }); + app = initializeApp({ + ...config, + measurementId: 'wrong-id' + }); + getAnalytics(app); }); - app = initializeApp({ - ...config, - measurementId: 'wrong-id' - }); - getAnalytics(app); }); }); describe('Using initializeAnalytics()', () => { + afterEach(() => { + if (app) { + return deleteApp(app); + } + }); it('logEvent() sends correct network request.', async () => { app = initializeApp(config); logEvent(initializeAnalytics(app), 'login', { method: 'email' }); const eventCalls = await checkForEventCalls(); - expect(eventCalls.length).to.equal(1); - expect(eventCalls[0].name).to.include('method=email'); + expect(eventCalls.length).toBe(1); + expect(eventCalls[0].name).toContain('method=email'); }); }); }); diff --git a/packages/analytics/testing/setup.ts b/packages/analytics/testing/setup.ts index de3f31890a..6fb58f0fb1 100644 --- a/packages/analytics/testing/setup.ts +++ b/packages/analytics/testing/setup.ts @@ -15,9 +15,4 @@ * limitations under the License. */ -import { use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinonChai from 'sinon-chai'; - -use(chaiAsPromised); -use(sinonChai); +import '../test/setup'; diff --git a/packages/analytics/karma.conf.js b/packages/analytics/vitest.config.mjs similarity index 59% rename from packages/analytics/karma.conf.js rename to packages/analytics/vitest.config.mjs index c6488ea06b..b27d94bcac 100644 --- a/packages/analytics/karma.conf.js +++ b/packages/analytics/vitest.config.mjs @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Google LLC + * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,18 +15,15 @@ * limitations under the License. */ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const karmaBase = require('../../config/karma.base'); +import createBaseConfig from '../../config/vitest.base.mjs'; -const files = [`**/*.test.ts`]; +const config = createBaseConfig(import.meta.url); -module.exports = function (config) { - config.set({ - ...karmaBase, - files, - preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, - frameworks: ['mocha'] - }); -}; +// Browser-only SDK: filter test projects to browser runner +if (config.test?.projects) { + config.test.projects = config.test.projects.filter( + project => project.test?.name === 'browser' + ); +} -module.exports.files = files; +export default config; diff --git a/packages/analytics/vitest.integration.config.mjs b/packages/analytics/vitest.integration.config.mjs new file mode 100644 index 0000000000..56993c42fa --- /dev/null +++ b/packages/analytics/vitest.integration.config.mjs @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createBaseConfig from '../../config/vitest.base.mjs'; + +const config = createBaseConfig(import.meta.url); + +if (config.test?.projects) { + config.test.projects = config.test.projects + .filter(project => project.test?.name === 'browser') + .map(project => ({ + ...project, + test: { + ...project.test, + name: 'integration', + include: ['testing/integration-tests/integration.ts'] + } + })); +} + +export default config;