helpers-browser-toolbox.js (7277B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 /* eslint-disable no-unused-vars, no-undef */ 4 5 "use strict"; 6 7 const { BrowserToolboxLauncher } = ChromeUtils.importESModule( 8 "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs" 9 ); 10 const { 11 DevToolsClient, 12 } = require("resource://devtools/client/devtools-client.js"); 13 14 /** 15 * Open up a browser toolbox and return a ToolboxTask object for interacting 16 * with it. ToolboxTask has the following methods: 17 * 18 * importFunctions(object) 19 * 20 * The object contains functions from this process which should be defined in 21 * the global evaluation scope of the toolbox. The toolbox cannot load testing 22 * files directly. 23 * 24 * destroy() 25 * 26 * Destroy the browser toolbox and make sure it exits cleanly. 27 * 28 * @param {object}: 29 * - {Function} existingProcessClose: if truth-y, connect to an existing 30 * browser toolbox process rather than launching a new one and 31 * connecting to it. The given function is expected to return an 32 * object containing an `exitCode`, like `{exitCode}`, and will be 33 * awaited in the returned `destroy()` function. `exitCode` is 34 * asserted to be 0 (success). 35 */ 36 async function initBrowserToolboxTask({ existingProcessClose } = {}) { 37 await pushPref("devtools.chrome.enabled", true); 38 await pushPref("devtools.debugger.remote-enabled", true); 39 await pushPref("devtools.browsertoolbox.enable-test-server", true); 40 await pushPref("devtools.debugger.prompt-connection", false); 41 42 // This rejection seems to affect all tests using the browser toolbox. 43 ChromeUtils.importESModule( 44 "resource://testing-common/PromiseTestUtils.sys.mjs" 45 ).PromiseTestUtils.allowMatchingRejectionsGlobally(/File closed/); 46 47 let process; 48 let dbgProcess; 49 if (!existingProcessClose) { 50 [process, dbgProcess] = await new Promise(resolve => { 51 BrowserToolboxLauncher.init({ 52 onRun: (_process, _dbgProcess) => resolve([_process, _dbgProcess]), 53 overwritePreferences: true, 54 }); 55 }); 56 ok(true, "Browser toolbox started"); 57 is( 58 BrowserToolboxLauncher.getBrowserToolboxSessionState(), 59 true, 60 "Has session state" 61 ); 62 } else { 63 ok(true, "Connecting to existing browser toolbox"); 64 } 65 66 // The port of the DevToolsServer installed in the toolbox process is fixed. 67 // See browser-toolbox/window.js 68 let transport; 69 while (true) { 70 try { 71 transport = await DevToolsClient.socketConnect({ 72 host: "localhost", 73 port: 6001, 74 webSocket: false, 75 }); 76 break; 77 } catch (e) { 78 await waitForTime(100); 79 } 80 } 81 ok(true, "Got transport"); 82 83 const client = new DevToolsClient(transport); 84 await client.connect(); 85 86 const commands = await CommandsFactory.forMainProcess({ client }); 87 const target = await commands.descriptorFront.getTarget(); 88 const consoleFront = await target.getFront("console"); 89 90 ok(true, "Connected"); 91 92 await importFunctions({ 93 info: msg => dump(msg + "\n"), 94 is: (a, b, description) => { 95 let msg = 96 "'" + JSON.stringify(a) + "' is equal to '" + JSON.stringify(b) + "'"; 97 if (description) { 98 msg += " - " + description; 99 } 100 if (a !== b) { 101 msg = "FAILURE: " + msg; 102 dump(msg + "\n"); 103 throw new Error(msg); 104 } else { 105 msg = "SUCCESS: " + msg; 106 dump(msg + "\n"); 107 } 108 }, 109 ok: (a, description) => { 110 let msg = "'" + JSON.stringify(a) + "' is true"; 111 if (description) { 112 msg += " - " + description; 113 } 114 if (!a) { 115 msg = "FAILURE: " + msg; 116 dump(msg + "\n"); 117 throw new Error(msg); 118 } else { 119 msg = "SUCCESS: " + msg; 120 dump(msg + "\n"); 121 } 122 }, 123 }); 124 125 async function evaluateExpression(expression, options = {}) { 126 const onEvaluationResult = consoleFront.once("evaluationResult"); 127 await consoleFront.evaluateJSAsync({ text: expression, ...options }); 128 return onEvaluationResult; 129 } 130 131 /** 132 * Invoke the given function and argument(s) within the global evaluation scope 133 * of the toolbox. The evaluation scope predefines the name "gToolbox" for the 134 * toolbox itself. 135 * 136 * @param {value|Array<value>} arg 137 * If an Array is passed, we will consider it as the list of arguments 138 * to pass to `fn`. Otherwise we will consider it as the unique argument 139 * to pass to it. 140 * @param {Function} fn 141 * Function to call in the global scope within the browser toolbox process. 142 * This function will be stringified and passed to the process via RDP. 143 * @return {Promise<Value>} 144 * Return the primitive value returned by `fn`. 145 */ 146 async function spawn(arg, fn) { 147 // Use JSON.stringify to ensure that we can pass strings 148 // as well as any JSON-able object. 149 const argString = JSON.stringify(Array.isArray(arg) ? arg : [arg]); 150 const rv = await evaluateExpression(`(${fn}).apply(null,${argString})`, { 151 // Use the following argument in order to ensure waiting for the completion 152 // of the promise returned by `fn` (in case this is an async method). 153 mapped: { await: true }, 154 }); 155 if (rv.exceptionMessage) { 156 throw new Error(`ToolboxTask.spawn failure: ${rv.exceptionMessage}`); 157 } else if (rv.topLevelAwaitRejected) { 158 throw new Error(`ToolboxTask.spawn await rejected`); 159 } 160 return rv.result; 161 } 162 163 async function importFunctions(functions) { 164 for (const [key, fn] of Object.entries(functions)) { 165 await evaluateExpression(`this.${key} = ${fn}`); 166 } 167 } 168 169 async function importScript(script) { 170 const response = await evaluateExpression(script); 171 if (response.hasException) { 172 ok( 173 false, 174 "ToolboxTask.spawn exception while importing script: " + 175 response.exceptionMessage 176 ); 177 } 178 } 179 180 let destroyed = false; 181 async function destroy() { 182 // No need to do anything if `destroy` was already called. 183 if (destroyed) { 184 return; 185 } 186 187 const closePromise = existingProcessClose 188 ? existingProcessClose() 189 : dbgProcess.wait(); 190 evaluateExpression("gToolbox.destroy()").catch(e => { 191 // Ignore connection close as the toolbox destroy may destroy 192 // everything quickly enough so that evaluate request is still pending 193 if (!e.message.includes("Connection closed")) { 194 throw e; 195 } 196 }); 197 198 const { exitCode } = await closePromise; 199 ok(true, "Browser toolbox process closed"); 200 201 is(exitCode, 0, "The remote debugger process died cleanly"); 202 203 if (!existingProcessClose) { 204 is( 205 BrowserToolboxLauncher.getBrowserToolboxSessionState(), 206 false, 207 "No session state after closing" 208 ); 209 } 210 211 await commands.destroy(); 212 destroyed = true; 213 } 214 215 // When tests involving using this task fail, the spawned Browser Toolbox is not 216 // destroyed and might impact the next tests (e.g. pausing the content process before 217 // the debugger from the content toolbox does). So make sure to cleanup everything. 218 registerCleanupFunction(destroy); 219 220 return { 221 importFunctions, 222 importScript, 223 spawn, 224 destroy, 225 }; 226 }