1// Copyright 2023 The Chromium Authors 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5class CrossbenchRecorder { 6 7 constructor(host, port, token, onmessage, onerror) { 8 this._host = host; 9 this._port = port; 10 this._token = token; 11 this._onMessageHandler = onmessage; 12 this._onErrorHandler = onerror; 13 } 14 15 async start() { 16 this._webSocket = new WebSocket(`ws://${this._host}:${this._port}`); 17 this._isConnecting = true; 18 this._webSocket.onmessage = this._onmessage.bind(this); 19 this._webSocket.onerror = (e) => this._onErrorHandler(e); 20 this._webSocket.onopen = (e) => this.status(); 21 } 22 23 get isConnected() { 24 return this._webSocket && this._webSocket.readyState == WebSocket.OPEN; 25 } 26 27 _onmessage(messageEvent) { 28 const { success, type, payload, error } = JSON.parse(messageEvent.data); 29 this._onMessageHandler(success, type, payload, error); 30 } 31 32 _command(command, args = undefined) { 33 const message = { token: this._token, command: command, args: args }; 34 if (!this.isConnected) { 35 throw Error("Invalid websocket state"); 36 } 37 this._webSocket.send(JSON.stringify(message)); 38 } 39 40 status() { 41 this._command("status"); 42 } 43 44 run(cmd, json) { 45 this._command("run", { cmd, json }); 46 } 47 48 readline() { 49 this._command("readline"); 50 } 51 52 stop() { 53 if (this.isConnected) { 54 this._command("stop"); 55 } 56 this._webSocket.close(); 57 } 58} 59 60function $(query) { 61 return document.querySelector(query); 62} 63 64class Status { 65 static DISCONNECTED = "disconnected"; 66 static CONNECTING = "connecting"; 67 static CONNECTED = "connected"; 68 static RUNNING = "running"; 69} 70 71 72class UI { 73 _crossbench; 74 _recorderJSON; 75 _status = Status.DISCONNECTED; 76 _pingIntervalID; 77 78 constructor() { 79 if (chrome?.runtime?.onMessage) { 80 chrome.runtime.onMessage.addListener(this._onChromeMessage.bind(this)); 81 } 82 $("#port").addEventListener("change", this._reconnect.bind(this)); 83 $("#token").addEventListener("change", this._reconnect.bind(this)); 84 $("#connectButton").onclick = this._reconnect.bind(this); 85 $("#crossbenchCMD").addEventListener("change", this._updateCMD.bind(this)); 86 $("#helpButton").onclick = (e) => this._showHelp("loading"); 87 $("#probesHelpButton").onclick = (e) => this._showHelp("probes"); 88 $("#runButton").onclick = this._run.bind(this); 89 $("#stopButton").onclick = this._stop.bind(this); 90 $("#copyStdout").onclick = () => this._copyOutput("#outputStdout"); 91 $("#copyStderr").onclick = () => this._copyOutput("#outputStderr"); 92 $("#copyRecorderJSON").onclick = () => this._copyOutput("#copyRecorderJSON"); 93 94 95 $("#port").value = localStorage.getItem("crossbenchPort") | 44645; 96 $("#token").value = localStorage.getItem("crossbenchToken"); 97 $("#crossbenchCMD").value = localStorage.getItem("crossbenchCMD"); 98 this._recorderJSON = JSON.parse(localStorage.getItem("recordingJSON") || "{}"); 99 this._reconnect(); 100 } 101 102 _updateRecording() { 103 $("#recording").value = JSON.stringify(this._recorderJSON, undefined, " "); 104 } 105 106 _updateCMD() { 107 const cmd = $("#crossbenchCMD").value; 108 localStorage.setItem("crossbenchCMD", cmd); 109 } 110 111 _run() { 112 if (!this._crossbench) return; 113 if (!this._recorderJSON) return; 114 const cmd = $("#crossbenchCMD").value; 115 this._crossbench.run(cmd, this._recorderJSON); 116 this._clearOutput(); 117 } 118 119 _clearOutput() { 120 $("#outputStdout").value = ""; 121 $("#outputStderr").value = ""; 122 } 123 124 _showHelp(type) { 125 if (this._status !== Status.CONNECTED) return; 126 if (type === "loading") { 127 this._crossbench.run("--help"); 128 } else if (type === "probes") { 129 this._crossbench.run("describe probes"); 130 } else { 131 console.error("Unknown help type: ", type); 132 } 133 } 134 135 _stop() { 136 this._crossbench.stop(); 137 } 138 139 _copyOutput(selector) { 140 navigator.clipboard.writeText($(selector).value); 141 } 142 143 _updateStatus(status) { 144 if (this._status === status) return; 145 document.body.className = status; 146 $("#status").value = status; 147 if (status === Status.DISCONNECTED) { 148 this._stopPinger(); 149 } else if (status === Status.CONNECTING) { 150 this._checkStatusTransition(Status.DISCONNECTED, status); 151 } else if (status === Status.CONNECTED) { 152 if (this._status !== Status.RUNNING) { 153 this._checkStatusTransition(Status.CONNECTING, status); 154 this._ensurePinger(); 155 } 156 } else if (status === Status.RUNNING) { 157 this._clearOutput(); 158 this._checkStatusTransition(Status.CONNECTED, status); 159 } else { 160 console.error("Unknown status: ", status); 161 } 162 this._status = status; 163 } 164 165 _checkStatusTransition(from, to) { 166 if (this._status != from) { 167 console.error(`Invalid status transition ${this._status} => ${to};`); 168 } 169 } 170 171 _stopPinger() { 172 clearInterval(this._pingIntervalID); 173 this._pingIntervalID = undefined; 174 } 175 176 _ensurePinger() { 177 if (!this._crossbench || this._pingIntervalID) return; 178 this._pingIntervalID = setInterval(this._ping.bind(this), 1000); 179 } 180 181 _ping() { 182 if (!this._crossbench || !this._crossbench.isConnected) { 183 this._updateStatus(Status.DISCONNECTED); 184 } 185 } 186 187 _appendOutput({ stdout = "", stderr = "" }) { 188 const stdoutNode = $("#outputStdout"); 189 const stderrNode = $("#outputStderr"); 190 stdoutNode.value += stdout; 191 stderrNode.value += stderr; 192 stdoutNode.scrollTop = stdoutNode.scrollHeight; 193 stderrNode.scrollTop = stderrNode.scrollHeight; 194 } 195 196 _reconnect() { 197 if (this._crossbench) { 198 this._crossbench.stop(); 199 } 200 201 const portNode = $("#port"); 202 const tokenNode = $("#token"); 203 const port = portNode.value; 204 const token = tokenNode.value; 205 206 let success = true; 207 if (port.length < 4 || !(parseInt(port) > 1024)) { 208 success = false; 209 portNode.className = "error"; 210 } 211 if (token.length != 32) { 212 success = false; 213 tokenNode.className = "error"; 214 } 215 216 // Try connecting to the local crossbench proxy server: 217 this._clearOutput(); 218 this._stopPinger(); 219 try { 220 this._crossbench = new CrossbenchRecorder( 221 "localhost", parseInt(port), token, 222 this._onCrossbenchMessage.bind(this), 223 this._onConnectionFail.bind(this)); 224 this._crossbench.start(); 225 this._updateStatus(Status.CONNECTING); 226 } catch (e) { 227 success = false; 228 portNode.className = "error"; 229 tokenNode.className = "error"; 230 this._crossbench = undefined; 231 console.error(e); 232 this._updateStatus(Status.DISCONNECTED); 233 } 234 localStorage.setItem("crossbenchPort", port); 235 localStorage.setItem("crossbenchToken", token); 236 if (!success) return; 237 portNode.className = ""; 238 tokenNode.className = ""; 239 } 240 241 _onConnectionFail(e) { 242 const websocket = e.target; 243 const errorMessage = [ 244 `Connection to WebSocket at ${websocket.url} failed.`, 245 "Did you run `./cb.py devtools-recorder-proxy`?" 246 ].join("\n"); 247 this._appendOutput({ stderr: errorMessage }); 248 this._updateStatus(Status.DISCONNECTED); 249 } 250 251 _onCrossbenchMessage(isSuccess, command, payload, error) { 252 console.log("Response: ", { isSuccess, command, payload, error }); 253 if (!isSuccess) { 254 console.error(error); 255 this._appendOutput({ stderr: error }); 256 if (error == "AuthenticationError") { 257 this._onAuthenticationError(); 258 } 259 return; 260 } 261 if (command == "status") return this._updateStatus(payload); 262 if (command == "output") return this._appendOutput(payload); 263 } 264 265 _onAuthenticationError() { 266 this._updateStatus(Status.DISCONNECTED); 267 } 268 269 _onChromeMessage(request, sender, sendResponse) { 270 if (request === "stop") return this._stop(); 271 this._recorderJSON = JSON.parse(request); 272 localStorage.setItem("recordingJSON", request); 273 this._updateRecording(); 274 } 275} 276 277globalThis.ui = new UI(); 278 279