• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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