"use strict"; const url = SimpleTest.getTestFileURL("mockpushserviceparent.js"); const chromeScript = SpecialPowers.loadChromeScript(url); /** * Replaces `PushService.jsm` with a mock implementation that handles requests * from the DOM API. This allows tests to simulate local errors and error * reporting, bypassing the `PushService.jsm` machinery. */ async function replacePushService(mockService) { chromeScript.addMessageListener("service-delivery-error", function (msg) { mockService.reportDeliveryError(msg.messageId, msg.reason); }); chromeScript.addMessageListener("service-request", function (msg) { let promise; try { let handler = mockService[msg.name]; promise = Promise.resolve(handler(msg.params)); } catch (error) { promise = Promise.reject(error); } promise.then( result => { chromeScript.sendAsyncMessage("service-response", { id: msg.id, result, }); }, error => { chromeScript.sendAsyncMessage("service-response", { id: msg.id, error, }); } ); }); await new Promise(resolve => { chromeScript.addMessageListener("service-replaced", function onReplaced() { chromeScript.removeMessageListener("service-replaced", onReplaced); resolve(); }); chromeScript.sendAsyncMessage("service-replace"); }); } async function restorePushService() { await new Promise(resolve => { chromeScript.addMessageListener("service-restored", function onRestored() { chromeScript.removeMessageListener("service-restored", onRestored); resolve(); }); chromeScript.sendAsyncMessage("service-restore"); }); } let currentMockSocket = null; /** * Sets up a mock connection for the WebSocket backend. This only replaces * the transport layer; `PushService.jsm` still handles DOM API requests, * observes permission changes, writes to IndexedDB, and notifies service * workers of incoming push messages. */ function setupMockPushSocket(mockWebSocket) { currentMockSocket = mockWebSocket; currentMockSocket._isActive = true; chromeScript.sendAsyncMessage("socket-setup"); chromeScript.addMessageListener("socket-client-msg", function (msg) { mockWebSocket.handleMessage(msg); }); } function teardownMockPushSocket() { if (currentMockSocket) { return new Promise(resolve => { currentMockSocket._isActive = false; chromeScript.addMessageListener("socket-server-teardown", resolve); chromeScript.sendAsyncMessage("socket-teardown"); }); } return Promise.resolve(); } /** * Minimal implementation of web sockets for use in testing. Forwards * messages to a mock web socket in the parent process that is used * by the push service. */ class MockWebSocket { // Default implementation to make the push server work minimally. // Override methods to implement custom functionality. constructor() { this.userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8"; this.registerCount = 0; // We only allow one active mock web socket to talk to the parent. // This flag is used to keep track of which mock web socket is active. this._isActive = false; } onHello(request) { this.serverSendMsg( JSON.stringify({ messageType: "hello", uaid: this.userAgentID, status: 200, use_webpush: true, }) ); } onRegister(request) { this.serverSendMsg( JSON.stringify({ messageType: "register", uaid: this.userAgentID, channelID: request.channelID, status: 200, pushEndpoint: "https://example.com/endpoint/" + this.registerCount++, }) ); } onUnregister(request) { this.serverSendMsg( JSON.stringify({ messageType: "unregister", channelID: request.channelID, status: 200, }) ); } onAck(request) { // Do nothing. } handleMessage(msg) { let request = JSON.parse(msg); let messageType = request.messageType; switch (messageType) { case "hello": this.onHello(request); break; case "register": this.onRegister(request); break; case "unregister": this.onUnregister(request); break; case "ack": this.onAck(request); break; default: throw new Error("Unexpected message: " + messageType); } } serverSendMsg(msg) { if (this._isActive) { chromeScript.sendAsyncMessage("socket-server-msg", msg); } } } // Remove permissions and prefs when the test finishes. SimpleTest.registerCleanupFunction(async function () { await new Promise(resolve => SpecialPowers.flushPermissions(resolve)); await SpecialPowers.flushPrefEnv(); await restorePushService(); await teardownMockPushSocket(); }); function setPushPermission(allow) { let permissions = [ { type: "desktop-notification", allow, context: document }, ]; if (isXOrigin) { // We need to add permission for the xorigin tests. In xorigin tests, the // test page will be run under third-party context, so we need to use // partitioned principal to add the permission. let partitionedPrincipal = SpecialPowers.wrap(document).partitionedPrincipal; permissions.push({ type: "desktop-notification", allow, context: { url: partitionedPrincipal.originNoSuffix, originAttributes: { partitionKey: partitionedPrincipal.originAttributes.partitionKey, }, }, }); } return SpecialPowers.pushPermissions(permissions); } function setupPrefs() { return SpecialPowers.pushPrefEnv({ set: [ ["dom.push.enabled", true], ["dom.push.connection.enabled", true], ["dom.push.maxRecentMessageIDsPerSubscription", 0], ["dom.serviceWorkers.exemptFromPerDomainMax", true], ["dom.serviceWorkers.enabled", true], ["dom.serviceWorkers.testing.enabled", true], ], }); } async function setupPrefsAndReplaceService(mockService) { await replacePushService(mockService); await setupPrefs(); } function setupPrefsAndMockSocket(mockSocket) { setupMockPushSocket(mockSocket); return setupPrefs(); } function injectControlledFrame(target = document.body) { return new Promise(function (res, rej) { var iframe = document.createElement("iframe"); iframe.src = "/tests/dom/push/test/frame.html"; var controlledFrame = { remove() { target.removeChild(iframe); iframe = null; }, waitOnWorkerMessage(type) { return iframe ? iframe.contentWindow.waitOnWorkerMessage(type) : Promise.reject(new Error("Frame removed from document")); }, innerWindowId() { return SpecialPowers.wrap(iframe).browsingContext.currentWindowContext .innerWindowId; }, }; iframe.onload = () => res(controlledFrame); target.appendChild(iframe); }); } function sendRequestToWorker(request) { return navigator.serviceWorker.ready.then(registration => { return new Promise((resolve, reject) => { var channel = new MessageChannel(); channel.port1.onmessage = e => { (e.data.error ? reject : resolve)(e.data); }; registration.active.postMessage(request, [channel.port2]); }); }); } function waitForActive(swr) { let sw = swr.installing || swr.waiting || swr.active; return new Promise(resolve => { if (sw.state === "activated") { resolve(swr); return; } sw.addEventListener("statechange", function onStateChange(evt) { if (sw.state === "activated") { sw.removeEventListener("statechange", onStateChange); resolve(swr); } }); }); } function base64UrlDecode(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); // Replace padding if it was stripped by the sender. // See http://tools.ietf.org/html/rfc4648#section-4 switch (s.length % 4) { case 0: break; // No pad chars in this case case 2: s += "=="; break; // Two pad chars case 3: s += "="; break; // One pad char default: throw new Error("Illegal base64url string!"); } // With correct padding restored, apply the standard base64 decoder var decoded = atob(s); var array = new Uint8Array(new ArrayBuffer(decoded.length)); for (var i = 0; i < decoded.length; i++) { array[i] = decoded.charCodeAt(i); } return array; }