diff --git a/tests/unit/instance-initializers/raygun-test.js b/tests/unit/instance-initializers/raygun-test.js index 6197a04..63702a4 100644 --- a/tests/unit/instance-initializers/raygun-test.js +++ b/tests/unit/instance-initializers/raygun-test.js @@ -2,42 +2,70 @@ import { setupTest } from 'ember-qunit'; import { module, test } from 'qunit'; import { getOwnConfig } from '@embroider/macros'; import Service from '@ember/service'; +import RSVP from 'rsvp'; import { initialize } from 'dummy/instance-initializers/raygun'; -class MockRaygunService extends Service { } +class MockRaygunService extends Service { + constructor() { + super(...arguments); + this.calls = []; + } + send(...args) { + this.calls.push(['send', args]); + } + trackEvent(...args) { + this.calls.push(['trackEvent', args]); + } +} module('Unit | Instance Initializer | Raygun', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { this.defaultRaygunConfig = { ...getOwnConfig().raygunConfig }; + + // Snapshot RSVP error handlers so we can restore after each test. + this.rsvpHandlers = []; + this.origRsvpOn = RSVP.on; + RSVP.on = (evt, fn) => { + this.rsvpHandlers.push([evt, fn]); + return this.origRsvpOn.call(RSVP, evt, fn); + }; + this.owner.unregister('service:raygun'); this.owner.register('service:raygun', MockRaygunService); }); hooks.afterEach(function () { getOwnConfig().raygunConfig = this.defaultRaygunConfig; + + // Detach any RSVP handlers our initializer registered to avoid cross-test leakage. + for (const [evt, fn] of this.rsvpHandlers) { + RSVP.off(evt, fn); + } + RSVP.on = this.origRsvpOn; }); + // --------------------------------------------------------------------------- + // Existing config-propagation tests (kept intact) + // --------------------------------------------------------------------------- + test('sets API key', async function (assert) { await initialize(this.owner); - const raygunService = this.owner.lookup("service:raygun"); - - assert.equal("YOUR_API_KEY_HERE", raygunService.apiKey); + const raygunService = this.owner.lookup('service:raygun'); + assert.equal(raygunService.apiKey, 'YOUR_API_KEY_HERE'); }); test('enables crash reporting', async function (assert) { await initialize(this.owner); - const raygunService = this.owner.lookup("service:raygun"); - - assert.equal(true, raygunService.enableCrashReporting); + const raygunService = this.owner.lookup('service:raygun'); + assert.equal(raygunService.enableCrashReporting, true); }); test('enables pulse', async function (assert) { await initialize(this.owner); - const raygunService = this.owner.lookup("service:raygun"); - - assert.equal(true, raygunService.enablePulse); + const raygunService = this.owner.lookup('service:raygun'); + assert.equal(raygunService.enablePulse, true); }); test('does nothing if crash reporting is disabled', async function (assert) { @@ -45,22 +73,151 @@ module('Unit | Instance Initializer | Raygun', function (hooks) { config.enableCrashReporting = false; await initialize(this.owner); - const raygunService = this.owner.lookup("service:raygun"); + const raygunService = this.owner.lookup('service:raygun'); assert.notOk(raygunService.enableCrashReporting); - assert.equal(undefined, raygunService.apiKey); - }) + assert.equal(raygunService.apiKey, undefined); + }); test('allows passing options', async function (assert) { let config = getOwnConfig().raygunConfig; - config.options = { - testOption: true - } + config.options = { testOption: true }; await initialize(this.owner); - const raygunService = this.owner.lookup("service:raygun"); + const raygunService = this.owner.lookup('service:raygun'); assert.ok(raygunService.options.testOption); - }) + }); + + // --------------------------------------------------------------------------- + // NEW: hook installation when crash reporting is disabled + // --------------------------------------------------------------------------- + + test('does NOT register an RSVP error handler when crash reporting is disabled', async function (assert) { + getOwnConfig().raygunConfig.enableCrashReporting = false; + + await initialize(this.owner); + + assert.notOk( + this.rsvpHandlers.some(([evt]) => evt === 'error'), + 'no RSVP "error" handler is added' + ); + }); + + test('does NOT replace applicationInstance.onerror when crash reporting is disabled', async function (assert) { + getOwnConfig().raygunConfig.enableCrashReporting = false; + const original = function noopOnError() {}; + this.owner.onerror = original; + + await initialize(this.owner); + + assert.strictEqual(this.owner.onerror, original, 'onerror is left untouched'); + }); + + // --------------------------------------------------------------------------- + // NEW: RSVP unhandled-rejection hook + // --------------------------------------------------------------------------- + + test('registers an RSVP "error" handler that forwards rejections to raygun.send', async function (assert) { + await initialize(this.owner); + const raygunService = this.owner.lookup('service:raygun'); + + const errorHandlers = this.rsvpHandlers.filter(([evt]) => evt === 'error'); + assert.equal(errorHandlers.length, 1, 'exactly one RSVP error handler registered'); + + // Invoke the captured handler directly to verify its body. + const reason = new Error('unhandled-rejection'); + const [, handler] = errorHandlers[0]; + handler(reason); + + assert.equal(raygunService.calls.length, 1); + const [type, args] = raygunService.calls[0]; + assert.equal(type, 'send'); + assert.deepEqual(args, [{ error: reason }]); + }); + + // --------------------------------------------------------------------------- + // NEW: applicationInstance.onerror wrapping + // --------------------------------------------------------------------------- + + test('wraps applicationInstance.onerror so errors flow to raygun.send', async function (assert) { + this.owner.onerror = undefined; + + await initialize(this.owner); + const raygunService = this.owner.lookup('service:raygun'); + + assert.equal(typeof this.owner.onerror, 'function', 'onerror was assigned'); + + const err = new Error('app-error'); + this.owner.onerror(err); + + assert.equal(raygunService.calls.length, 1); + const [type, args] = raygunService.calls[0]; + assert.equal(type, 'send'); + assert.deepEqual(args, [err]); + }); + + test('preserves a previously-installed applicationInstance.onerror handler', async function (assert) { + const priorCalls = []; + const prior = (e) => priorCalls.push(e); + this.owner.onerror = prior; + + await initialize(this.owner); + const raygunService = this.owner.lookup('service:raygun'); + + const err = new Error('chained'); + this.owner.onerror(err); + + assert.deepEqual(priorCalls, [err], 'prior onerror handler was still invoked'); + assert.equal(raygunService.calls.length, 1, 'raygun.send was also invoked'); + assert.deepEqual(raygunService.calls[0], ['send', [err]]); + }); + + // --------------------------------------------------------------------------- + // NEW: Pulse page-view tracking via router.routeDidChange + // --------------------------------------------------------------------------- + + test('subscribes to routeDidChange and emits a Pulse pageView event', async function (assert) { + const router = this.owner.lookup('service:router'); + const handlers = []; + const origOn = router.on.bind(router); + router.on = (evt, fn) => { + handlers.push([evt, fn]); + return origOn(evt, fn); + }; + + await initialize(this.owner); + const raygunService = this.owner.lookup('service:raygun'); + + const subscribed = handlers.find(([evt]) => evt === 'routeDidChange'); + assert.ok(subscribed, 'a routeDidChange listener was registered'); + + // Invoke the captured handler with a synthetic transition. + const [, handler] = subscribed; + handler({ to: { name: 'posts.show' } }); + assert.equal(raygunService.calls.length, 1); + const [type, args] = raygunService.calls[0]; + assert.equal(type, 'trackEvent'); + assert.deepEqual(args, [{ type: 'pageView', path: 'posts.show' }]); + }); + + test('does NOT subscribe to routeDidChange when crash reporting is disabled', async function (assert) { + getOwnConfig().raygunConfig.enableCrashReporting = false; + + const router = this.owner.lookup('service:router'); + const handlers = []; + const origOn = router.on.bind(router); + router.on = (evt, fn) => { + handlers.push([evt, fn]); + return origOn(evt, fn); + }; + + await initialize(this.owner); + + assert.notOk( + handlers.some(([evt]) => evt === 'routeDidChange'), + 'no routeDidChange listener is added' + ); + }); }); diff --git a/tests/unit/services/raygun-test.js b/tests/unit/services/raygun-test.js index 6671956..c59122c 100644 --- a/tests/unit/services/raygun-test.js +++ b/tests/unit/services/raygun-test.js @@ -1,45 +1,188 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; -module('Unit | Service | raygun', function(hooks) { +module('Unit | Service | raygun', function (hooks) { setupTest(hooks); - hooks.beforeEach(function() { + hooks.beforeEach(function () { this.oldRaygun = window.rg4js; + this.oldWarn = console.warn; + this.warnings = []; + console.warn = (...args) => { + this.warnings.push(args.join(' ')); + }; this.raygunCalls = []; window.rg4js = (type, ...args) => { this.raygunCalls.push([type, args]); }; - }) + }); - hooks.afterEach(function() { + hooks.afterEach(function () { window.rg4js = this.oldRaygun; + console.warn = this.oldWarn; this.raygunCalls = []; + this.warnings = []; }); - test('it exists', function(assert) { - let service = this.owner.lookup('service:raygun'); + test('it exists', function (assert) { + let service = this.owner.lookup('service:raygun'); assert.ok(service); }); - test('tracking an event', function(assert) { + // --------------------------------------------------------------------------- + // Method forwarding + // --------------------------------------------------------------------------- + + test('trackEvent forwards arguments to rg4js', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + raygunService.trackEvent({ type: 'testEvent' }); + + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'trackEvent'); + assert.deepEqual(args, [{ type: 'testEvent' }]); + }); + + test('send forwards an Error to rg4js', function (assert) { let raygunService = this.owner.lookup('service:raygun'); + const err = new Error('boom'); + raygunService.send(err); - raygunService.trackEvent({ - type: 'testEvent' - }) - const [ latestCallType, latestCallArgs ] = this.raygunCalls.pop(); - assert.equal('trackEvent', latestCallType); - assert.deepEqual([{ type: 'testEvent'}], latestCallArgs); - }) + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'send'); + assert.deepEqual(args, [err]); + }); - test('sending an error', function (assert) { + test('setUser forwards user payload to rg4js', function (assert) { let raygunService = this.owner.lookup('service:raygun'); + const user = { + identifier: 'user-1', + isAnonymous: false, + email: 'a@example.com', + fullName: 'A B', + }; + raygunService.setUser(user); - raygunService.send(new Error('e')) - const [latestCallType, latestCallArgs] = this.raygunCalls.pop(); - assert.equal('send', latestCallType); - assert.deepEqual([new Error('e')], latestCallArgs); - }) + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'setUser'); + assert.deepEqual(args, [user]); + }); + + test('trackEvent supports pageView shape', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + raygunService.trackEvent({ type: 'pageView', path: 'posts.show' }); + + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'trackEvent'); + assert.deepEqual(args, [{ type: 'pageView', path: 'posts.show' }]); + }); + + // --------------------------------------------------------------------------- + // Property setters round-trip through rg4js + // --------------------------------------------------------------------------- + + test('apiKey setter forwards to rg4js and getter returns the stored value', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + raygunService.apiKey = 'KEY-123'; + + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'apiKey'); + assert.deepEqual(args, ['KEY-123']); + assert.equal(raygunService.apiKey, 'KEY-123'); + }); + + test('enableCrashReporting setter forwards to rg4js and round-trips', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + raygunService.enableCrashReporting = true; + + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'enableCrashReporting'); + assert.deepEqual(args, [true]); + assert.equal(raygunService.enableCrashReporting, true); + }); + test('enablePulse setter forwards to rg4js and round-trips', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + raygunService.enablePulse = true; + + const [type, args] = this.raygunCalls.pop(); + assert.equal(type, 'enablePulse'); + assert.deepEqual(args, [true]); + assert.equal(raygunService.enablePulse, true); + }); + + // --------------------------------------------------------------------------- + // Graceful degradation when rg4js is missing + // --------------------------------------------------------------------------- + + module('when rg4js is unavailable (CDN blocked / CSP)', function (innerHooks) { + innerHooks.beforeEach(function () { + // Force-remove the global so the service's _isRaygunAvailable() guard returns false. + window.rg4js = undefined; + }); + + test('send returns null and warns instead of throwing', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + const result = raygunService.send(new Error('e')); + + assert.strictEqual(result, null, 'send returns null when rg4js missing'); + assert.ok( + this.warnings.some((w) => w.includes('Unable to send data')), + 'emits a console.warn explaining the failure' + ); + }); + + test('trackEvent returns null and warns', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + const result = raygunService.trackEvent({ type: 'pageView' }); + + assert.strictEqual(result, null); + assert.ok(this.warnings.some((w) => w.includes('Unable to track event'))); + }); + + test('setUser returns null and warns', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + const result = raygunService.setUser({ identifier: 'x' }); + + assert.strictEqual(result, null); + assert.ok(this.warnings.some((w) => w.includes('Unable to set user'))); + }); + + // NOTE: We can't assert the underlying _value is unset, because the + // dummy app's instance initializer pre-populates the service before + // these tests run. Instead we verify the setter is a no-op against the + // rg4js spy and that it emits the expected warning. + + test('apiKey setter does not throw and warns', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + this.raygunCalls.length = 0; + this.warnings.length = 0; + + raygunService.apiKey = 'KEY-X'; + + assert.equal(this.raygunCalls.length, 0, 'no rg4js call is made'); + assert.ok(this.warnings.some((w) => w.includes('Unable to set apiKey'))); + }); + + test('enableCrashReporting setter does not throw and warns', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + this.raygunCalls.length = 0; + this.warnings.length = 0; + + raygunService.enableCrashReporting = false; + + assert.equal(this.raygunCalls.length, 0, 'no rg4js call is made'); + assert.ok(this.warnings.some((w) => w.includes('Unable to enable crash reporting'))); + }); + + test('enablePulse setter does not throw and warns', function (assert) { + let raygunService = this.owner.lookup('service:raygun'); + this.raygunCalls.length = 0; + this.warnings.length = 0; + + raygunService.enablePulse = false; + + assert.equal(this.raygunCalls.length, 0, 'no rg4js call is made'); + assert.ok(this.warnings.some((w) => w.includes('Unable to enable Pulse'))); + }); + }); });