diff --git a/infra/testing/helpers/extendable-event-utils.mjs b/infra/testing/helpers/extendable-event-utils.mjs new file mode 100644 index 000000000..7d991ee98 --- /dev/null +++ b/infra/testing/helpers/extendable-event-utils.mjs @@ -0,0 +1,46 @@ +/* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. +*/ + + +const extendLifetimePromises = new WeakMap(); + +export const eventDoneWaiting = async (event) => { + const promises = extendLifetimePromises.get(event); + let promise; + + while (promise = promises.shift()) { + // Ignore errors. + await promise.catch((e) => e); + } +}; + +export const watchEvent = (event) => { + const promises = []; + extendLifetimePromises.set(event, promises); + + event.waitUntil = (promise) => { + promises.push(promise); + }; + + if (event instanceof FetchEvent) { + event.respondWith = (responseOrPromise) => { + promises.push(Promise.resolve(responseOrPromise)); + + // TODO(philipwalton): we cannot currently call the native + // `respondWith()` due to this bug in Firefix: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1538756 + // FetchEvent.prototype.respondWith.call(event, responseOrPromise); + }; + } +}; + +export const dispatchAndWaitUntilDone = async (event) => { + watchEvent(event); + self.dispatchEvent(event); + await eventDoneWaiting(event); +}; diff --git a/test/workbox-google-analytics/integration/basic-example.js b/test/workbox-google-analytics/integration/test-all.js similarity index 77% rename from test/workbox-google-analytics/integration/basic-example.js rename to test/workbox-google-analytics/integration/test-all.js index d70f8ca54..938dc4374 100644 --- a/test/workbox-google-analytics/integration/basic-example.js +++ b/test/workbox-google-analytics/integration/test-all.js @@ -11,11 +11,20 @@ const qs = require('qs'); const {By} = require('selenium-webdriver'); const activateAndControlSW = require('../../../infra/testing/activate-and-control'); const waitUntil = require('../../../infra/testing/wait-until'); +const {runUnitTests} = require('../../../infra/testing/webdriver/runUnitTests'); -describe(`[workbox-google-analytics] Load and use Google Analytics`, function() { - const driver = global.__workbox.webdriver; - const testServerAddress = global.__workbox.server.getAddress(); +// Store local references of these globals. +const {webdriver, server} = global.__workbox; + +describe(`[workbox-google-analytics]`, function() { + it(`passes all SW unit tests`, async function() { + await runUnitTests('/test/workbox-google-analytics/sw/'); + }); +}); + +describe(`[workbox-google-analytics] initialize`, function() { + const testServerAddress = server.getAddress(); const testingURL = `${testServerAddress}/test/workbox-google-analytics/static/basic-example/`; const swURL = `${testingURL}sw.js`; @@ -37,20 +46,20 @@ describe(`[workbox-google-analytics] Load and use Google Analytics`, function() before(async function() { // Load the page and wait for the first service worker to activate. - await driver.get(testingURL); + await webdriver.get(testingURL); await activateAndControlSW(swURL); }); beforeEach(async function() { // Reset the spied requests array. - await driver.executeAsyncScript(messageSW, { + await webdriver.executeAsyncScript(messageSW, { action: 'clear-spied-requests', }); }); it(`should load a page with service worker`, async function() { - const err = await driver.executeAsyncScript((done) => { + const err = await webdriver.executeAsyncScript((done) => { fetch('https://www.google-analytics.com/analytics.js', {mode: 'no-cors'}) .then(() => done(), (err) => done(err.message)); }); @@ -61,42 +70,42 @@ describe(`[workbox-google-analytics] Load and use Google Analytics`, function() it(`replay failed Google Analytics hits`, async function() { // Skip this test in browsers that don't support background sync. // TODO(philipwalton): figure out a way to work around this. - const browserSupportsSync = await driver.executeScript(() => { + const browserSupportsSync = await webdriver.executeScript(() => { return 'SyncManager' in window; }); if (!browserSupportsSync) this.skip(); const simulateOfflineEl = - await driver.findElement(By.id('simulate-offline')); + await webdriver.findElement(By.id('simulate-offline')); // Send a hit while online to ensure regular requests work. - await driver.executeAsyncScript((done) => { + await webdriver.executeAsyncScript((done) => { window.gtag('event', 'beacon', { transport_type: 'beacon', event_callback: () => done(), }); }); - let requests = await driver.executeAsyncScript(messageSW, { + let requests = await webdriver.executeAsyncScript(messageSW, { action: 'get-spied-requests', }); expect(requests).to.have.lengthOf(1); // Reset the spied requests array. - await driver.executeAsyncScript(messageSW, { + await webdriver.executeAsyncScript(messageSW, { action: 'clear-spied-requests', }); // Check the "simulate offline" checkbox and make some requests. await simulateOfflineEl.click(); - await driver.executeAsyncScript((done) => { + await webdriver.executeAsyncScript((done) => { window.gtag('event', 'beacon', { transport_type: 'beacon', event_label: Date.now(), event_callback: () => done(), }); }); - await driver.executeAsyncScript((done) => { + await webdriver.executeAsyncScript((done) => { window.gtag('event', 'pixel', { transport_type: 'image', event_label: Date.now(), @@ -104,26 +113,26 @@ describe(`[workbox-google-analytics] Load and use Google Analytics`, function() }); }); // This request should not match GA routes, so it shouldn't be replayed. - await driver.executeAsyncScript((done) => { + await webdriver.executeAsyncScript((done) => { fetch('https://httpbin.org/get').then(() => done()); }); // Get all spied requests and ensure there haven't been any (since we're // offline). - requests = await driver.executeAsyncScript(messageSW, { + requests = await webdriver.executeAsyncScript(messageSW, { action: 'get-spied-requests', }); expect(requests).to.have.lengthOf(0); // Uncheck the "simulate offline" checkbox and then trigger a sync. await simulateOfflineEl.click(); - await driver.executeAsyncScript(messageSW, { + await webdriver.executeAsyncScript(messageSW, { action: 'dispatch-sync-event', }); // Wait until all expected requests have replayed. await waitUntil(async () => { - requests = await driver.executeAsyncScript(messageSW, { + requests = await webdriver.executeAsyncScript(messageSW, { action: 'get-spied-requests', }); return requests.length === 2; diff --git a/test/workbox-google-analytics/node/test-index.mjs b/test/workbox-google-analytics/sw/test-initialize.mjs similarity index 67% rename from test/workbox-google-analytics/node/test-index.mjs rename to test/workbox-google-analytics/sw/test-initialize.mjs index 1c855623d..94b2113aa 100644 --- a/test/workbox-google-analytics/node/test-index.mjs +++ b/test/workbox-google-analytics/sw/test-initialize.mjs @@ -6,50 +6,63 @@ https://opensource.org/licenses/MIT. */ -import {expect} from 'chai'; -import sinon from 'sinon'; -import {reset as iDBReset} from 'shelving-mock-indexeddb'; -import {eventsDoneWaiting, resetEventListeners} from '../../../infra/testing/sw-env-mocks/event-listeners.js'; -import {Queue} from '../../../packages/workbox-background-sync/Queue.mjs'; -import {cacheNames} from '../../../packages/workbox-core/_private/cacheNames.mjs'; -import {NetworkFirst, NetworkOnly} from '../../../packages/workbox-strategies/index.mjs'; -import {initialize} from '../../../packages/workbox-google-analytics/initialize.mjs'; +import {Queue} from 'workbox-background-sync/Queue.mjs'; +import {QueueStore} from 'workbox-background-sync/lib/QueueStore.mjs'; +import {cacheNames} from 'workbox-core/_private/cacheNames.mjs'; +import {DBWrapper} from 'workbox-core/_private/DBWrapper.mjs'; +import {initialize} from 'workbox-google-analytics/initialize.mjs'; import { GOOGLE_ANALYTICS_HOST, GTM_HOST, ANALYTICS_JS_PATH, GTAG_JS_PATH, GTM_JS_PATH, -} from '../../../packages/workbox-google-analytics/utils/constants.mjs'; +} from 'workbox-google-analytics/utils/constants.mjs'; +import {NetworkFirst} from 'workbox-strategies/NetworkFirst.mjs'; +import {NetworkOnly} from 'workbox-strategies/NetworkOnly.mjs'; +import {dispatchAndWaitUntilDone} from '../../../infra/testing/helpers/extendable-event-utils.mjs'; + const PAYLOAD = 'v=1&t=pageview&tid=UA-12345-1&cid=1&dp=%2F'; -describe(`[workbox-google-analytics] initialize`, function() { +describe(`initialize`, function() { const sandbox = sinon.createSandbox(); - const reset = async () => { + const db = new DBWrapper('workbox-background-sync', 3, { + onupgradeneeded: QueueStore.prototype._upgradeDb, + }); + + beforeEach(async function() { Queue._queueNames.clear(); - resetEventListeners(); sandbox.restore(); - iDBReset(); + await db.clear('requests'); const usedCaches = await caches.keys(); await Promise.all(usedCaches.map((cacheName) => caches.delete(cacheName))); - }; - beforeEach(async function() { - await reset(); + // Spy on all added event listeners so they can be removed. + sandbox.spy(self, 'addEventListener'); + + // Don't actually register for a sync event in any test, as it could + // make them non-deterministic. + if ('sync' in registration) { + sandbox.stub(registration.sync, 'register'); + } }); - after(async function() { - await reset(); + afterEach(function() { + for (const args of self.addEventListener.args) { + self.removeEventListener(...args); + } + + sandbox.restore(); }); - it(`should register a handler to cache the analytics.js script`, function() { + it(`should register a handler to cache the analytics.js script`, async function() { sandbox.spy(NetworkFirst.prototype, 'handle'); initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request( `https://${GOOGLE_ANALYTICS_HOST}${ANALYTICS_JS_PATH}`, { mode: 'no-cors', @@ -59,12 +72,12 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(NetworkFirst.prototype.handle.calledOnce).to.be.true; }); - it(`should register a handler to cache the gtag.js script`, function() { + it(`should register a handler to cache the gtag.js script`, async function() { sandbox.spy(NetworkFirst.prototype, 'handle'); initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request( `https://${GTM_HOST}${GTAG_JS_PATH}?id=UA-XXXXX-Y`, { mode: 'no-cors', @@ -74,12 +87,12 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(NetworkFirst.prototype.handle.calledOnce).to.be.true; }); - it(`should register a handler to cache the gtm.js script`, function() { + it(`should register a handler to cache the gtm.js script`, async function() { sandbox.spy(NetworkFirst.prototype, 'handle'); initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request( `https://${GTM_HOST}${GTM_JS_PATH}?id=GTM-XXXX`, { mode: 'no-cors', @@ -97,9 +110,9 @@ describe(`[workbox-google-analytics] initialize`, function() { mode: 'no-cors', }); - self.dispatchEvent(new FetchEvent('fetch', {request: analyticsJsRequest})); - - await eventsDoneWaiting(); + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { + request: analyticsJsRequest, + })); const usedCaches = await caches.keys(); expect(usedCaches).to.have.lengthOf(1); @@ -107,6 +120,7 @@ describe(`[workbox-google-analytics] initialize`, function() { const cache = await caches.open('foobar'); const cachedResponse = await cache.match(analyticsJsRequest); + expect(cachedResponse).to.be.instanceOf(Response); }); @@ -118,9 +132,9 @@ describe(`[workbox-google-analytics] initialize`, function() { mode: 'no-cors', }); - self.dispatchEvent(new FetchEvent('fetch', {request: analyticsJsRequest})); - - await eventsDoneWaiting(); + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { + request: analyticsJsRequest, + })); const defaultCacheName = cacheNames.getGoogleAnalyticsName(); const usedCaches = await caches.keys(); @@ -132,12 +146,12 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(cachedResponse).to.be.instanceOf(Response); }); - it(`should register GET/POST routes for collect endpoints`, function() { + it(`should register GET/POST routes for collect endpoints`, async function() { sandbox.spy(NetworkOnly.prototype, 'handle'); initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}`, { method: 'GET', @@ -146,7 +160,7 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(NetworkOnly.prototype.handle.callCount).to.equal(1); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/collect`, { method: 'POST', body: PAYLOAD, @@ -156,7 +170,7 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(NetworkOnly.prototype.handle.callCount).to.equal(2); // Test the experimental /r/collect endpoint - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/r/collect?${PAYLOAD}`, { method: 'GET', @@ -166,7 +180,7 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(NetworkOnly.prototype.handle.callCount).to.equal(3); // Test the experimental /r/collect endpoint - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/r/collect`, { method: 'POST', body: PAYLOAD, @@ -181,7 +195,7 @@ describe(`[workbox-google-analytics] initialize`, function() { initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}`, { method: 'GET', @@ -192,17 +206,17 @@ describe(`[workbox-google-analytics] initialize`, function() { expect(self.fetch.firstCall.args[0].url).to.equal(`https://` + `${GOOGLE_ANALYTICS_HOST}/collect?${PAYLOAD}`); - self.dispatchEvent(new FetchEvent('fetch', { - request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/collect`, { - method: 'POST', - body: PAYLOAD, - }), - })); + const request = new Request(`https://${GOOGLE_ANALYTICS_HOST}/collect`, { + method: 'POST', + body: PAYLOAD, + }); + await dispatchAndWaitUntilDone(new FetchEvent('fetch', {request})); expect(self.fetch.calledTwice).to.be.true; - const bodyText = await self.fetch.secondCall.args[0].text(); - expect(bodyText).to.equal(PAYLOAD); + // We can't compare payload bodies after the fetch has run, but if the + // fetch succeeds and sends the request, we know the body wasn't altered. + expect(self.fetch.secondCall.args[0]).to.equal(request); }); it(`should not alter hit paths`, async function() { @@ -211,7 +225,7 @@ describe(`[workbox-google-analytics] initialize`, function() { initialize(); // Test the /r/collect endpoint - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/r/collect?${PAYLOAD}`, { method: 'GET', @@ -223,7 +237,7 @@ describe(`[workbox-google-analytics] initialize`, function() { `${GOOGLE_ANALYTICS_HOST}/r/collect?${PAYLOAD}`); // Test the /r/collect endpoint - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/r/collect`, { method: 'POST', body: PAYLOAD, @@ -241,22 +255,20 @@ describe(`[workbox-google-analytics] initialize`, function() { initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}`, { method: 'GET', }), })); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/collect`, { method: 'POST', body: PAYLOAD, }), })); - await eventsDoneWaiting(); - const [call1Args, call2Args] = Queue.prototype.pushRequest.args; expect(call1Args[0].request.url).to.equal(`https://` + `${GOOGLE_ANALYTICS_HOST}/collect?${PAYLOAD}`); @@ -266,40 +278,44 @@ describe(`[workbox-google-analytics] initialize`, function() { it(`should add the qt param to replayed hits`, async function() { sandbox.stub(self, 'fetch').rejects(); - sandbox.spy(Queue.prototype, 'pushRequest'); + const pushRequestSpy = sandbox.spy(Queue.prototype, 'pushRequest'); const clock = sandbox.useFakeTimers({toFake: ['Date']}); initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}`, { method: 'GET', }), })); - await eventsDoneWaiting(); clock.tick(100); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/r/collect`, { method: 'POST', body: PAYLOAD, }), })); - await eventsDoneWaiting(); - self.fetch.restore(); sandbox.stub(self, 'fetch').resolves(new Response('', {status: 200})); clock.tick(100); - self.dispatchEvent(new SyncEvent('sync', { - tag: `workbox-background-sync:workbox-google-analytics`, - })); - - await eventsDoneWaiting(); + // Manually trigger the `onSync` callback in both sync and non-sync + // supporting browsers. + if ('sync' in registration) { + await dispatchAndWaitUntilDone(new SyncEvent('sync', { + tag: `workbox-background-sync:workbox-google-analytics`, + })); + } else { + // Get the `this` context of the underlying Queue instance in order + // to manually replay it. + const queue = pushRequestSpy.thisValues[0]; + await queue._onSync({queue}); + } expect(self.fetch.callCount).to.equal(2); @@ -323,40 +339,44 @@ describe(`[workbox-google-analytics] initialize`, function() { it(`should update an existing qt param`, async function() { sandbox.stub(self, 'fetch').rejects(); - sandbox.spy(Queue.prototype, 'pushRequest'); + const pushRequestSpy = sandbox.spy(Queue.prototype, 'pushRequest'); const clock = sandbox.useFakeTimers({toFake: ['Date']}); initialize(); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}&qt=1000`, { method: 'GET', }), })); - await eventsDoneWaiting(); clock.tick(100); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/r/collect`, { method: 'POST', body: `${PAYLOAD}&qt=3000`, }), })); - await eventsDoneWaiting(); - self.fetch.restore(); sandbox.stub(self, 'fetch').resolves(new Response('', {status: 200})); clock.tick(100); - self.dispatchEvent(new SyncEvent('sync', { - tag: `workbox-background-sync:workbox-google-analytics`, - })); - - await eventsDoneWaiting(); + // Manually trigger the `onSync` callback in both sync and non-sync + // supporting browsers. + if ('sync' in registration) { + await dispatchAndWaitUntilDone(new SyncEvent('sync', { + tag: `workbox-background-sync:workbox-google-analytics`, + })); + } else { + // Get the `this` context of the underlying Queue instance in order + // to manually replay it. + const queue = pushRequestSpy.thisValues[0]; + await queue._onSync({queue}); + } expect(self.fetch.callCount).to.equal(2); @@ -371,7 +391,7 @@ describe(`[workbox-google-analytics] initialize`, function() { it(`should add parameterOverrides to replayed hits`, async function() { sandbox.stub(self, 'fetch').rejects(); - sandbox.spy(Queue.prototype, 'pushRequest'); + const pushRequestSpy = sandbox.spy(Queue.prototype, 'pushRequest'); initialize({ parameterOverrides: { @@ -380,30 +400,35 @@ describe(`[workbox-google-analytics] initialize`, function() { }, }); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}`, { method: 'GET', }), })); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/collect`, { method: 'POST', body: PAYLOAD, }), })); - await eventsDoneWaiting(); - self.fetch.restore(); sandbox.stub(self, 'fetch').resolves(new Response('', {status: 200})); - self.dispatchEvent(new SyncEvent('sync', { - tag: `workbox-background-sync:workbox-google-analytics`, - })); - - await eventsDoneWaiting(); + // Manually trigger the `onSync` callback in both sync and non-sync + // supporting browsers. + if ('sync' in registration) { + await dispatchAndWaitUntilDone(new SyncEvent('sync', { + tag: `workbox-background-sync:workbox-google-analytics`, + })); + } else { + // Get the `this` context of the underlying Queue instance in order + // to manually replay it. + const queue = pushRequestSpy.thisValues[0]; + await queue._onSync({queue}); + } expect(self.fetch.callCount).to.equal(2); @@ -434,30 +459,33 @@ describe(`[workbox-google-analytics] initialize`, function() { }, }); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}` + `/collect?${PAYLOAD}&foo=1`, { method: 'GET', }), })); - self.dispatchEvent(new FetchEvent('fetch', { + await dispatchAndWaitUntilDone(new FetchEvent('fetch', { request: new Request(`https://${GOOGLE_ANALYTICS_HOST}/collect`, { method: 'POST', body: PAYLOAD + '&foo=2', }), })); - await eventsDoneWaiting(); - self.fetch.restore(); sandbox.stub(self, 'fetch').resolves(new Response('', {status: 200})); - self.dispatchEvent(new SyncEvent('sync', { - tag: `workbox-background-sync:workbox-google-analytics`, - })); - - await eventsDoneWaiting(); + // Manually trigger the `onSync` callback in both sync and non-sync + // supporting browsers. + if ('sync' in registration) { + await dispatchAndWaitUntilDone(new SyncEvent('sync', { + tag: `workbox-background-sync:workbox-google-analytics`, + })); + } else { + const queue = Queue.prototype.pushRequest.thisValues[0]; + await queue._onSync({queue}); + } expect(self.fetch.callCount).to.equal(2);