Repository: microsoft/vscode-edge-devtools
Pull request #449 (2021-08-09 22:16:04)

Remote CDN connection logic for devtools
This PR adds the ability for the extension to choose the legacy (inbox) or CDN based connection to a debug target based on it's version.

It adds a new versionDetectionSocket that sends a single message: CDP's browser.getVersion in order to determine which version of the tools to load, then informs the devtoolsPanel and disposes itself


Patch:
diff --git a/package.json b/package.json
index a36bd41..62562a6 100644
--- a/package.json
+++ b/package.json
@@ -601,7 +601,7 @@
         "build": "npm run build-wp && npm run build-post",
         "build-min": "npm run build-wp && npm run build-post && npm run minify-devtools",
         "build-debug": "npm run build-wp && ts-node postBuildStep.ts debug",
-        "build-edge-watch": "webpack --env debug",
+        "build-edge-watch": "webpack --env debug --env devtoolsBaseUri=http://localhost:3000/vscode_app.html",
         "build-wp": "webpack",
         "build-post": "ts-node postBuildStep.ts",
         "build-watch": "npm run build && npm run watch",
diff --git a/src/devtoolsPanel.ts b/src/devtoolsPanel.ts
index b992237..aa70a03 100644
--- a/src/devtoolsPanel.ts
+++ b/src/devtoolsPanel.ts
@@ -17,6 +17,7 @@ import {
 } from './common/webviewEvents';
 import { JsDebugProxyPanelSocket } from './JsDebugProxyPanelSocket';
 import { PanelSocket } from './panelSocket';
+import { BrowserVersionDetectionSocket } from './versionSocketConnection';
 import {
     applyPathMapping,
     fetchUri,
@@ -37,8 +38,10 @@ export class DevToolsPanel {
     private readonly telemetryReporter: Readonly<TelemetryReporter>;
     private readonly targetUrl: string;
     private panelSocket: PanelSocket;
+    private versionDetectionSocket: BrowserVersionDetectionSocket;
     private consoleOutput: vscode.OutputChannel;
     private timeStart: number | null;
+    private devtoolsBaseUri: string | null;
 
     private constructor(
         panel: vscode.WebviewPanel,
@@ -53,6 +56,7 @@ export class DevToolsPanel {
         this.targetUrl = targetUrl;
         this.config = config;
         this.timeStart = null;
+        this.devtoolsBaseUri = this.config.devtoolsBaseUri || null;
         this.consoleOutput = vscode.window.createOutputChannel('DevTools Console');
         if (config.isJsDebugProxiedCDPConnection) {
             // Direct users to the Debug Console
@@ -91,6 +95,13 @@ export class DevToolsPanel {
             this.panelSocket.on('consoleOutput', msg => this.onSocketConsoleOutput(msg));
         }
 
+        // This Websocket is only used on initial connection to determine the browser version.
+        // The browser version is used to select between CDN and bundled tools
+        // Future versions of the extension will remove this socket and only use CDN
+        this.versionDetectionSocket = new BrowserVersionDetectionSocket(this.targetUrl);
+        this.versionDetectionSocket.on('setBrowserRevision', revision => this.setBrowserRevision(revision));
+
+
         // Handle closing
         this.panel.onDidDispose(() => {
             this.dispose();
@@ -99,7 +110,13 @@ export class DevToolsPanel {
         // Handle view change
         this.panel.onDidChangeViewState(_e => {
             if (this.panel.visible) {
-                this.update();
+                if (this.panelSocket.isConnectedToTarget) {
+                    // Connection type determined already
+                    this.update();
+                } else {
+                    // Use version socket to determine which Webview/Tools to use
+                    this.versionDetectionSocket.detectVersion();
+                }
             }
         }, this, this.disposables);
 
@@ -124,6 +141,7 @@ export class DevToolsPanel {
         this.panel.dispose();
         this.panelSocket.dispose();
         this.consoleOutput.dispose();
+        this.versionDetectionSocket.dispose();
         if (this.timeStart !== null) {
             const timeEnd = performance.now();
             const sessionTime = timeEnd - this.timeStart;
@@ -329,7 +347,8 @@ export class DevToolsPanel {
     }
 
     private update() {
-        this.panel.webview.html = this.config.isCdnHostedTools ? this.getCdnHtmlForWebview() : this.getHtmlForWebview();
+        // Check to see which version of devtools we need to launch
+        this.panel.webview.html = (this.config.isCdnHostedTools || this.config.useLocalEdgeWatch) ? this.getCdnHtmlForWebview() : this.getHtmlForWebview();
     }
 
     private getHtmlForWebview() {
@@ -375,12 +394,8 @@ export class DevToolsPanel {
     }
 
     private getCdnHtmlForWebview() {
-        let cdnBaseUrl = this.config.devtoolsBaseUri;
-        if (!cdnBaseUrl) {
-            // Not provided, calculate based on config
-            // TODO: CDP call to determine actual hash and stuff
-            cdnBaseUrl = this.config.useLocalEdgeWatch ? 'http://localhost:3000/vscode_app.html' : 'https://devtools.invalid';
-        }
+        // Default to config provided base uri
+        const cdnBaseUri = this.config.devtoolsBaseUri || this.devtoolsBaseUri;
         const hostPath = vscode.Uri.file(path.join(this.extensionPath, 'out', 'host_beta', 'host.bundle.js'));
         const hostUri = this.panel.webview.asWebviewUri(hostPath);
 
@@ -403,17 +418,29 @@ export class DevToolsPanel {
                     img-src 'self' data: ${this.panel.webview.cspSource};
                     style-src 'self' 'unsafe-inline' ${this.panel.webview.cspSource};
                     script-src 'self' 'unsafe-eval' ${this.panel.webview.cspSource};
-                    frame-src 'self' ${this.panel.webview.cspSource} ${cdnBaseUrl};
+                    frame-src 'self' ${this.panel.webview.cspSource} ${cdnBaseUri};
                     connect-src 'self' data: ${this.panel.webview.cspSource};
                 ">
             </head>
             <body>
-                <iframe id="devtools-frame" frameBorder="0" src="${cdnBaseUrl}?experiments=true&theme=${theme}"></iframe>
+                <iframe id="devtools-frame" frameBorder="0" src="${cdnBaseUri}?experiments=true&theme=${theme}"></iframe>
             </body>
             </html>
             `;
     }
 
+    private setBrowserRevision(revision: string) {
+        if (revision !== '') {
+            this.config.isCdnHostedTools = true;
+            this.devtoolsBaseUri = `https://devtools.azureedge.net/serve_file/${revision}/vscode_app.html`;
+        } else {
+            this.config.isCdnHostedTools = false;
+            this.devtoolsBaseUri = '';
+        }
+        this.update();
+    }
+
+
     static createOrShow(
         context: vscode.ExtensionContext,
         telemetryReporter: Readonly<TelemetryReporter>,
diff --git a/src/panelSocket.ts b/src/panelSocket.ts
index 3a18940..fb115c7 100644
--- a/src/panelSocket.ts
+++ b/src/panelSocket.ts
@@ -67,7 +67,7 @@ export class PanelSocket extends EventEmitter {
         return this.emit(eventName, args);
     }
 
-    private connectToTarget() {
+    private connectToTarget(): void {
         // Create the websocket
         this.socket = new WebSocket(this.targetUrl);
         this.socket.onopen = () => this.onOpen();
diff --git a/src/versionSocketConnection.ts b/src/versionSocketConnection.ts
new file mode 100644
index 0000000..0d2a815
--- /dev/null
+++ b/src/versionSocketConnection.ts
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+import { EventEmitter } from 'events';
+import WebSocket from 'ws';
+import { WebSocketEvent } from './common/webviewEvents';
+
+export type IDevToolsPostMessageCallback = (e: WebSocketEvent, message?: string) => void;
+
+export interface BrowserVersionCdpResponse {
+   id: number;
+   result?: {
+       product?: string;
+       revision?: string;
+   }
+}
+
+const MIN_SUPPORTED_VERSION = '94.0.975.0';
+
+export class BrowserVersionDetectionSocket extends EventEmitter {
+    private readonly targetUrl: string;
+    private socket: WebSocket | undefined;
+
+    constructor(targetUrl: string) {
+        super();
+        this.targetUrl = targetUrl;
+    }
+
+    dispose(): void {
+        if (this.socket) {
+            this.socket.close();
+            this.socket = undefined;
+        }
+    }
+
+    detectVersion(): void {
+        // Connect to target to determine browser version
+        this.socket = new WebSocket(this.targetUrl);
+        this.socket.onopen = () => this.onOpen();
+        this.socket.onmessage = ev => this.onMessage(ev);
+    }
+
+    private onOpen(): void {
+        // Send request to get browser version
+        const requestMessage = {
+            id: 0,
+            method: 'Browser.getVersion',
+            params: {},
+        };
+        if (this.socket) {
+            this.socket.send(JSON.stringify(requestMessage));
+        }
+    }
+
+    private onMessage(message: { data: WebSocket.Data }) {
+        // Determine if this is the browser.getVersion response and send revision hash to devtoolsPanel
+        const data = JSON.parse(message.data.toString()) as BrowserVersionCdpResponse;
+        this.emit('setBrowserRevision', this.calcBrowserRevision(data));
+        // Dispose socket after version is determined
+        this.dispose();
+        return;
+    }
+
+    private calcBrowserRevision(data: BrowserVersionCdpResponse): string {
+        if (data.id !== 0 || !data.result || !data.result.product && !data.result.revision) {
+            return '';
+        }
+        // product in the form [Edg, HeadlessEdg]/#.#.#.#
+        const versionNum = (data.result.product as string).split('/')[1];
+        const currentVersion = versionNum.split('.');
+        const minSupportedVersion = MIN_SUPPORTED_VERSION.split('.');
+        const currentRevision = data.result.revision || '';
+        for (let i = 0; i < currentVersion.length; i++) {
+            // Loop through from Major to minor numbers
+            if (currentVersion[i] > minSupportedVersion[i]) {
+                return currentRevision;
+            }
+            if (currentVersion[i] < minSupportedVersion[i]) {
+                return '';
+            }
+            // Continue to the next number
+        }
+        // All numbers matched, return supported revision
+        return currentRevision;
+    }
+}


Test patch:
diff --git a/test/devtoolsPanel.test.ts b/test/devtoolsPanel.test.ts
index 6bba2e0..bdae38d 100644
--- a/test/devtoolsPanel.test.ts
+++ b/test/devtoolsPanel.test.ts
@@ -18,6 +18,7 @@ import {
     Writable,
 } from "./helpers/helpers";
 import { IRuntimeConfig, SETTINGS_PREF_DEFAULTS, SETTINGS_PREF_NAME } from "../src/utils";
+import { BrowserVersionDetectionSocket } from "../src/versionSocketConnection";
 
 jest.mock("vscode", () => createFakeVSCode(), { virtual: true });
 
@@ -29,6 +30,8 @@ describe("devtoolsPanel", () => {
     let mockPanelSocketFactory: { PanelSocket: jest.Mock };
     let mockJsDebugProxyPanelSocket: Mocked<JsDebugProxyPanelSocket>;
     let mockJsDebugProxyPanelSocketFactory: { JsDebugProxyPanelSocket: jest.Mock };
+    let mockVersionDetectionSocket: Mocked<BrowserVersionDetectionSocket>;
+    let mockVersionDetectionSocketFactory: { BrowserVersionDetectionSocket: jest.Mock };
     let mockWebviewEvents: { encodeMessageForChannel: jest.Mock };
     let mockRuntimeConfig: IRuntimeConfig;
 
@@ -82,6 +85,17 @@ describe("devtoolsPanel", () => {
         };
         jest.doMock("../src/JsDebugProxyPanelSocket", () => mockJsDebugProxyPanelSocketFactory);
 
+        mockVersionDetectionSocket = {
+            dispose: jest.fn(),
+            on: jest.fn(),
+            detectVersion: jest.fn(),
+        } as Mocked<BrowserVersionDetectionSocket>;
+
+        mockVersionDetectionSocketFactory = {
+            BrowserVersionDetectionSocket: jest.fn(() => mockVersionDetectionSocket),
+        };
+        jest.doMock("../src/versionSocketConnection", () => mockVersionDetectionSocketFactory);
+
         mockWebviewEvents = {
             encodeMessageForChannel: jest.fn(),
         };
@@ -157,7 +171,7 @@ describe("devtoolsPanel", () => {
     });
 
     describe("update", () => {
-        it("adds html to the webview only when visible", async () => {
+        it("adds attempts to detect browser version only when visible", async () => {
             const dtp = await import("../src/devtoolsPanel");
             dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "", mockRuntimeConfig);
             expect(mockPanel.onDidChangeViewState).toHaveBeenCalled();
@@ -171,10 +185,9 @@ describe("devtoolsPanel", () => {
             callback.call(thisObj);
             expect(mockPanel.webview.html).toBeUndefined();
 
-            // Visible
             testPanel.visible = true;
             callback.call(thisObj);
-            expect(mockPanel.webview.html).toBeDefined();
+            expect(mockVersionDetectionSocket.on).toHaveBeenCalledTimes(1);
         });
     });
 
diff --git a/test/versionDetectionSocket.test.ts b/test/versionDetectionSocket.test.ts
new file mode 100644
index 0000000..974e849
--- /dev/null
+++ b/test/versionDetectionSocket.test.ts
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+import WebSocket from "ws";
+import { Mocked } from "./helpers/helpers";
+import type {BrowserVersionCdpResponse} from "../src/versionSocketConnection";
+
+describe("versionDetectionSocket", () => {
+    let mockWebSocket: Mocked<WebSocket>;
+    beforeEach(() => {
+        mockWebSocket = {
+            close: jest.fn(),
+            onclose: jest.fn(),
+            onerror: jest.fn(),
+            onmessage: jest.fn(),
+            onopen: jest.fn(),
+            send: jest.fn(),
+        } as Mocked<WebSocket>;
+
+        // We need to use a non-arrow function here as it is used as a constructor.
+        // tslint:disable-next-line: object-literal-shorthand only-arrow-functions
+        jest.doMock("ws", () => function() { return mockWebSocket; });
+        jest.resetModules();
+    });
+
+    it("creates a new websocket on connection", async () => {
+        const expected = {
+            onmessage: mockWebSocket.onmessage,
+            onopen: mockWebSocket.onopen,
+        };
+        const vs = await import("../src/versionSocketConnection");
+        const versionSocket = new vs.BrowserVersionDetectionSocket("");
+
+        versionSocket.detectVersion();
+        expect(mockWebSocket.onmessage).not.toEqual(expected.onmessage);
+        expect(mockWebSocket.onopen).not.toEqual(expected.onopen);
+    });
+
+    it("calculates revision correctly", async () => {
+        const vs = await import("../src/versionSocketConnection");
+        const versionSocket = new vs.BrowserVersionDetectionSocket("");
+
+        const revisions: string[] = [];
+        versionSocket.on("setBrowserRevision", (msg: string) => {
+            if (msg !== ""){
+                revisions.push(msg);
+            }
+        });
+
+        const versionMessages: BrowserVersionCdpResponse[] = [
+            { id: 0, result: { revision: "FAIL", product: "Edg/92.0.0.0"}},
+            { id: 0, result: { revision: "PASS", product: "Edg/95.0.0.0"}},
+            { id: 0, result: { revision: "PASS", product: "Edg/94.0.975.0"}},
+            { id: 0, result: { revision: "FAIL", product: "Edg/94.0.100.0"}},
+        ]
+
+        for (const version of versionMessages) {
+            versionSocket.detectVersion();
+            mockWebSocket.onmessage.call(mockWebSocket, { data: JSON.stringify(version)});
+        }
+
+        expect(revisions.length).toEqual(2);
+        for (const rev of revisions) {
+            expect(rev).toEqual("PASS");
+        }
+    });
+});