test_utils.js (8472B)
1 "use strict"; 2 3 const url = SimpleTest.getTestFileURL("mockpushserviceparent.js"); 4 const chromeScript = SpecialPowers.loadChromeScript(url); 5 6 /** 7 * Replaces `PushService.sys.mjs` with a mock implementation that handles requests 8 * from the DOM API. This allows tests to simulate local errors and error 9 * reporting, bypassing the `PushService.sys.mjs` machinery. 10 */ 11 async function replacePushService(mockService) { 12 chromeScript.addMessageListener("service-delivery-error", function (msg) { 13 mockService.reportDeliveryError(msg.messageId, msg.reason); 14 }); 15 chromeScript.addMessageListener("service-request", function (msg) { 16 let promise; 17 try { 18 let handler = mockService[msg.name]; 19 promise = Promise.resolve(handler(msg.params)); 20 } catch (error) { 21 promise = Promise.reject(error); 22 } 23 promise.then( 24 result => { 25 chromeScript.sendAsyncMessage("service-response", { 26 id: msg.id, 27 result, 28 }); 29 }, 30 error => { 31 chromeScript.sendAsyncMessage("service-response", { 32 id: msg.id, 33 error, 34 }); 35 } 36 ); 37 }); 38 await new Promise(resolve => { 39 chromeScript.addMessageListener("service-replaced", function onReplaced() { 40 chromeScript.removeMessageListener("service-replaced", onReplaced); 41 resolve(); 42 }); 43 chromeScript.sendAsyncMessage("service-replace"); 44 }); 45 } 46 47 async function restorePushService() { 48 await new Promise(resolve => { 49 chromeScript.addMessageListener("service-restored", function onRestored() { 50 chromeScript.removeMessageListener("service-restored", onRestored); 51 resolve(); 52 }); 53 chromeScript.sendAsyncMessage("service-restore"); 54 }); 55 } 56 57 let currentMockSocket = null; 58 59 /** 60 * Sets up a mock connection for the WebSocket backend. This only replaces 61 * the transport layer; `PushService.sys.mjs` still handles DOM API requests, 62 * observes permission changes, writes to IndexedDB, and notifies service 63 * workers of incoming push messages. 64 */ 65 function setupMockPushSocket(mockWebSocket) { 66 currentMockSocket = mockWebSocket; 67 currentMockSocket._isActive = true; 68 chromeScript.sendAsyncMessage("socket-setup"); 69 chromeScript.addMessageListener("socket-client-msg", function (msg) { 70 mockWebSocket.handleMessage(msg); 71 }); 72 } 73 74 function teardownMockPushSocket() { 75 if (currentMockSocket) { 76 return new Promise(resolve => { 77 currentMockSocket._isActive = false; 78 chromeScript.addMessageListener("socket-server-teardown", resolve); 79 chromeScript.sendAsyncMessage("socket-teardown"); 80 }); 81 } 82 return Promise.resolve(); 83 } 84 85 /** 86 * Minimal implementation of web sockets for use in testing. Forwards 87 * messages to a mock web socket in the parent process that is used 88 * by the push service. 89 */ 90 class MockWebSocket { 91 // Default implementation to make the push server work minimally. 92 // Override methods to implement custom functionality. 93 constructor() { 94 this.userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8"; 95 this.registerCount = 0; 96 // We only allow one active mock web socket to talk to the parent. 97 // This flag is used to keep track of which mock web socket is active. 98 this._isActive = false; 99 } 100 101 onHello() { 102 this.serverSendMsg( 103 JSON.stringify({ 104 messageType: "hello", 105 uaid: this.userAgentID, 106 status: 200, 107 use_webpush: true, 108 }) 109 ); 110 } 111 112 onRegister(request) { 113 this.serverSendMsg( 114 JSON.stringify({ 115 messageType: "register", 116 uaid: this.userAgentID, 117 channelID: request.channelID, 118 status: 200, 119 pushEndpoint: "https://example.com/endpoint/" + this.registerCount++, 120 }) 121 ); 122 } 123 124 onUnregister(request) { 125 this.serverSendMsg( 126 JSON.stringify({ 127 messageType: "unregister", 128 channelID: request.channelID, 129 status: 200, 130 }) 131 ); 132 } 133 134 onAck() { 135 // Do nothing. 136 } 137 138 handleMessage(msg) { 139 let request = JSON.parse(msg); 140 let messageType = request.messageType; 141 switch (messageType) { 142 case "hello": 143 this.onHello(request); 144 break; 145 case "register": 146 this.onRegister(request); 147 break; 148 case "unregister": 149 this.onUnregister(request); 150 break; 151 case "ack": 152 this.onAck(request); 153 break; 154 default: 155 throw new Error("Unexpected message: " + messageType); 156 } 157 } 158 159 serverSendMsg(msg) { 160 if (this._isActive) { 161 chromeScript.sendAsyncMessage("socket-server-msg", msg); 162 } 163 } 164 } 165 166 // Remove permissions and prefs when the test finishes. 167 SimpleTest.registerCleanupFunction(async function () { 168 await new Promise(resolve => SpecialPowers.flushPermissions(resolve)); 169 await SpecialPowers.flushPrefEnv(); 170 await restorePushService(); 171 await teardownMockPushSocket(); 172 }); 173 174 function setPushPermission(allow) { 175 let permissions = [ 176 { type: "desktop-notification", allow, context: document }, 177 ]; 178 179 if (isXOrigin) { 180 // We need to add permission for the xorigin tests. In xorigin tests, the 181 // test page will be run under third-party context, so we need to use 182 // partitioned principal to add the permission. 183 let partitionedPrincipal = 184 SpecialPowers.wrap(document).partitionedPrincipal; 185 186 permissions.push({ 187 type: "desktop-notification", 188 allow, 189 context: { 190 url: partitionedPrincipal.originNoSuffix, 191 originAttributes: { 192 partitionKey: partitionedPrincipal.originAttributes.partitionKey, 193 }, 194 }, 195 }); 196 } 197 198 return SpecialPowers.pushPermissions(permissions); 199 } 200 201 function setupPrefs() { 202 return SpecialPowers.pushPrefEnv({ 203 set: [ 204 ["dom.push.enabled", true], 205 ["dom.push.connection.enabled", true], 206 ["dom.push.maxRecentMessageIDsPerSubscription", 0], 207 ["dom.serviceWorkers.exemptFromPerDomainMax", true], 208 ["dom.serviceWorkers.enabled", true], 209 ["dom.serviceWorkers.testing.enabled", true], 210 ], 211 }); 212 } 213 214 async function setupPrefsAndReplaceService(mockService) { 215 await replacePushService(mockService); 216 await setupPrefs(); 217 } 218 219 function setupPrefsAndMockSocket(mockSocket) { 220 setupMockPushSocket(mockSocket); 221 return setupPrefs(); 222 } 223 224 function injectControlledFrame(target = document.body) { 225 return new Promise(function (res) { 226 var iframe = document.createElement("iframe"); 227 iframe.src = "/tests/dom/push/test/frame.html"; 228 229 var controlledFrame = { 230 remove() { 231 target.removeChild(iframe); 232 iframe = null; 233 }, 234 waitOnWorkerMessage(type) { 235 return iframe 236 ? iframe.contentWindow.waitOnWorkerMessage(type) 237 : Promise.reject(new Error("Frame removed from document")); 238 }, 239 innerWindowId() { 240 return SpecialPowers.wrap(iframe).browsingContext.currentWindowContext 241 .innerWindowId; 242 }, 243 }; 244 245 iframe.onload = () => res(controlledFrame); 246 target.appendChild(iframe); 247 }); 248 } 249 250 function sendRequestToWorker(request) { 251 return navigator.serviceWorker.ready.then(registration => { 252 return new Promise((resolve, reject) => { 253 var channel = new MessageChannel(); 254 channel.port1.onmessage = e => { 255 (e.data.error ? reject : resolve)(e.data); 256 }; 257 registration.active.postMessage(request, [channel.port2]); 258 }); 259 }); 260 } 261 262 function waitForActive(swr) { 263 let sw = swr.installing || swr.waiting || swr.active; 264 return new Promise(resolve => { 265 if (sw.state === "activated") { 266 resolve(swr); 267 return; 268 } 269 sw.addEventListener("statechange", function onStateChange() { 270 if (sw.state === "activated") { 271 sw.removeEventListener("statechange", onStateChange); 272 resolve(swr); 273 } 274 }); 275 }); 276 } 277 278 function base64UrlDecode(s) { 279 s = s.replace(/-/g, "+").replace(/_/g, "/"); 280 281 // Replace padding if it was stripped by the sender. 282 // See http://tools.ietf.org/html/rfc4648#section-4 283 switch (s.length % 4) { 284 case 0: 285 break; // No pad chars in this case 286 case 2: 287 s += "=="; 288 break; // Two pad chars 289 case 3: 290 s += "="; 291 break; // One pad char 292 default: 293 throw new Error("Illegal base64url string!"); 294 } 295 296 // With correct padding restored, apply the standard base64 decoder 297 var decoded = atob(s); 298 299 var array = new Uint8Array(new ArrayBuffer(decoded.length)); 300 for (var i = 0; i < decoded.length; i++) { 301 array[i] = decoded.charCodeAt(i); 302 } 303 return array; 304 }