From 8a6924f3181afb3dfec45bdd2cb269b3b7fdc079 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Wed, 11 Mar 2026 12:14:22 -0700 Subject: [PATCH 1/3] Support debugging integrated browser --- OPTIONS.md | 90 +++++++ package.json | 3 +- package.nls.json | 5 + src/build/generate-contributions.ts | 69 +++++- src/common/contributionUtils.ts | 3 + src/configuration.ts | 66 ++++- src/ioc.ts | 4 + src/targets/browser/browserLaunchParams.ts | 6 +- src/targets/browser/codeBrowserAttacher.ts | 232 ++++++++++++++++++ src/targets/browser/codeBrowserLauncher.ts | 144 +++++++++++ .../browser/codeBrowserSessionTransport.ts | 37 +++ .../codeBrowserConfigurationProvider.test.ts | 108 ++++++++ .../extension/codeBrowserIntegration.test.ts | 145 +++++++++++ src/typings/vscode.proposed.browser.d.ts | 89 +++++++ .../codeBrowserDebugConfigurationProvider.ts | 52 ++++ src/ui/configuration/index.ts | 2 + 16 files changed, 1049 insertions(+), 6 deletions(-) create mode 100644 src/targets/browser/codeBrowserAttacher.ts create mode 100644 src/targets/browser/codeBrowserLauncher.ts create mode 100644 src/targets/browser/codeBrowserSessionTransport.ts create mode 100644 src/test/extension/codeBrowserConfigurationProvider.test.ts create mode 100644 src/test/extension/codeBrowserIntegration.test.ts create mode 100644 src/typings/vscode.proposed.browser.d.ts create mode 100644 src/ui/configuration/codeBrowserDebugConfigurationProvider.ts diff --git a/OPTIONS.md b/OPTIONS.md index bc9ffac63..723ff9da9 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -437,3 +437,93 @@ "!**/node_modules/**" ]

webRoot

This specifies the workspace absolute path to the webserver root. Used to resolve paths like /app.js to files on disk. Shorthand for a pathMapping for "/"

Default value:
"${workspaceFolder}"
+ +### code-browser: launch + +

cascadeTerminateToConfigurations

A list of debug sessions which, when this debug session is terminated, will also be stopped.

+
Default value:
[]

customDescriptionGenerator

Customize the textual description the debugger shows for objects (local variables, etc...). Samples:
1. this.toString() // will call toString to print all objects
2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue
3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue

+
Default value:
undefined

customPropertiesGenerator

Customize the properties shown for an object in the debugger (local variables, etc...). Samples:
1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects
2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)
3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties

Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181

+
Default value:
undefined

disableNetworkCache

Controls whether to skip the network cache for each request

+
Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

+
Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

+
Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

+
Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

+
Default value:
[
+  "${workspaceFolder}/**/*.(m|c|)js",
+  "!**/node_modules/**"
+]

outputCapture

From where to capture output messages: the default debug API if set to console, or stdout/stderr streams if set to std.

+
Default value:
"console"

pathMapping

A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk

+
Default value:
{}

pauseForSourceMap

Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as rootPath is not disabled.

+
Default value:
true

perScriptSourcemaps

Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to "auto", we'll detect known cases where this is appropriate.

+
Default value:
"auto"

resolveSourceMapLocations

A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with "!" to exclude them. May be set to an empty array or null to avoid restriction.

+
Default value:
null

showAsyncStacks

Show the async calls that led to the current call stack.

+
Default value:
true

skipFiles

An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, ["**/node_modules/**", "!**/node_modules/my-module/**"]

+
Default value:
[]

smartStep

Automatically step through generated code that cannot be mapped back to the original source.

+
Default value:
true

sourceMapPathOverrides

A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.

+
Default value:
{
+  "webpack:///./~/*": "${webRoot}/node_modules/*",
+  "webpack:////*": "/*",
+  "webpack://@?:*/?:*/*": "${webRoot}/*",
+  "webpack://?:*/*": "${webRoot}/*",
+  "webpack:///([a-z]):/(.+)": "$1:/$2",
+  "meteor://💻app/*": "${webRoot}/*",
+  "turbopack://[project]/*": "${workspaceFolder}/*",
+  "turbopack:///[project]/*": "${workspaceFolder}/*"
+}

sourceMapRenames

Whether to use the "names" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.

+
Default value:
true

sourceMaps

Use JavaScript source maps (if they exist).

+
Default value:
true

timeout

Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.

+
Default value:
10000

timeouts

Timeouts for several debugger operations.

+
Default value:
{}

trace

Configures what diagnostic output is produced.

+
Default value:
false

url

Will search for a tab with this exact url and attach to it, if found

+
Default value:
"http://localhost:8080"

urlFilter

Will search for a page with this url and attach to it, if found. Can have * wildcards.

+
Default value:
""

vueComponentPaths

A list of file glob patterns to find *.vue components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.

+
Default value:
[
+  "${workspaceFolder}/**/*.vue",
+  "!**/node_modules/**"
+]

webRoot

This specifies the workspace absolute path to the webserver root. Used to resolve paths like /app.js to files on disk. Shorthand for a pathMapping for "/"

+
Default value:
"${workspaceFolder}"
+ +### code-browser: attach + +

cascadeTerminateToConfigurations

A list of debug sessions which, when this debug session is terminated, will also be stopped.

+
Default value:
[]

customDescriptionGenerator

Customize the textual description the debugger shows for objects (local variables, etc...). Samples:
1. this.toString() // will call toString to print all objects
2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue
3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue

+
Default value:
undefined

customPropertiesGenerator

Customize the properties shown for an object in the debugger (local variables, etc...). Samples:
1. { ...this, extraProperty: '12345' } // Add an extraProperty 12345 to all objects
2. this.customProperties ? this.customProperties() : this // Use customProperties method if available, if not use the properties in this (the default properties)
3. function () { return this.customProperties ? this.customProperties() : this } // Use customDescription method if available, if not return the default properties

Deprecated: This is a temporary implementation of this feature until we have time to implement it in the way described here: https://github.com/microsoft/vscode/issues/102181

+
Default value:
undefined

disableNetworkCache

Controls whether to skip the network cache for each request

+
Default value:
true

enableContentValidation

Toggles whether we verify the contents of files on disk match the ones loaded in the runtime. This is useful in a variety of scenarios and required in some, but can cause issues if you have server-side transformation of scripts, for example.

+
Default value:
true

enableDWARF

Toggles whether the debugger will try to read DWARF debug symbols from WebAssembly, which can be resource intensive. Requires the ms-vscode.wasm-dwarf-debugging extension to function.

+
Default value:
true

inspectUri

Format to use to rewrite the inspectUri: It's a template string that interpolates keys in {curlyBraces}. Available keys are:
- url.* is the parsed address of the running application. For instance, {url.port}, {url.hostname}
- port is the debug port that Chrome is listening on.
- browserInspectUri is the inspector URI on the launched browser
- browserInspectUriPath is the path part of the inspector URI on the launched browser (e.g.: "/devtools/browser/e9ec0098-306e-472a-8133-5e42488929c2").
- wsProtocol is the hinted websocket protocol. This is set to wss if the original URL is https, or ws otherwise.

+
Default value:
undefined

outFiles

If source maps are enabled, these glob patterns specify the generated JavaScript files. If a pattern starts with ! the files are excluded. If not specified, the generated code is expected in the same directory as its source.

+
Default value:
[
+  "${workspaceFolder}/**/*.(m|c|)js",
+  "!**/node_modules/**"
+]

outputCapture

From where to capture output messages: the default debug API if set to console, or stdout/stderr streams if set to std.

+
Default value:
"console"

pathMapping

A mapping of URLs/paths to local folders, to resolve scripts in the Browser to scripts on disk

+
Default value:
{}

pauseForSourceMap

Whether to wait for source maps to load for each incoming script. This has a performance overhead, and might be safely disabled when running off of disk, so long as rootPath is not disabled.

+
Default value:
true

perScriptSourcemaps

Whether scripts are loaded individually with unique sourcemaps containing the basename of the source file. This can be set to optimize sourcemap handling when dealing with lots of small scripts. If set to "auto", we'll detect known cases where this is appropriate.

+
Default value:
"auto"

resolveSourceMapLocations

A list of minimatch patterns for locations (folders and URLs) in which source maps can be used to resolve local files. This can be used to avoid incorrectly breaking in external source mapped code. Patterns can be prefixed with "!" to exclude them. May be set to an empty array or null to avoid restriction.

+
Default value:
null

showAsyncStacks

Show the async calls that led to the current call stack.

+
Default value:
true

skipFiles

An array of file or folder names, or path globs, to skip when debugging. Star patterns and negations are allowed, for example, ["**/node_modules/**", "!**/node_modules/my-module/**"]

+
Default value:
[]

smartStep

Automatically step through generated code that cannot be mapped back to the original source.

+
Default value:
true

sourceMapPathOverrides

A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk.

+
Default value:
{
+  "webpack:///./~/*": "${webRoot}/node_modules/*",
+  "webpack:////*": "/*",
+  "webpack://@?:*/?:*/*": "${webRoot}/*",
+  "webpack://?:*/*": "${webRoot}/*",
+  "webpack:///([a-z]):/(.+)": "$1:/$2",
+  "meteor://💻app/*": "${webRoot}/*",
+  "turbopack://[project]/*": "${workspaceFolder}/*",
+  "turbopack:///[project]/*": "${workspaceFolder}/*"
+}

sourceMapRenames

Whether to use the "names" mapping in sourcemaps. This requires requesting source content, which can be slow with certain debuggers.

+
Default value:
true

sourceMaps

Use JavaScript source maps (if they exist).

+
Default value:
true

timeout

Retry for this number of milliseconds to connect to Node.js. Default is 10000 ms.

+
Default value:
10000

timeouts

Timeouts for several debugger operations.

+
Default value:
{}

trace

Configures what diagnostic output is produced.

+
Default value:
false

url

Will search for a tab with this exact url and attach to it, if found

+
Default value:
null

urlFilter

Will search for a page with this url and attach to it, if found. Can have * wildcards.

+
Default value:
""

vueComponentPaths

A list of file glob patterns to find *.vue components. By default, searches the entire workspace. This needs to be specified due to extra lookups that Vue's sourcemaps require in Vue CLI 4. You can disable this special handling by setting this to an empty array.

+
Default value:
[
+  "${workspaceFolder}/**/*.vue",
+  "!**/node_modules/**"
+]

webRoot

This specifies the workspace absolute path to the webserver root. Used to resolve paths like /app.js to files on disk. Shorthand for a pathMapping for "/"

+
Default value:
"${workspaceFolder}"
diff --git a/package.json b/package.json index 2186168f7..c2c5f9b12 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,8 @@ "enabledApiProposals": [ "portsAttributes", "workspaceTrust", - "tunnels" + "tunnels", + "browser" ], "extensionKind": [ "workspace" diff --git a/package.nls.json b/package.nls.json index 0c2428de3..9e990ffc8 100644 --- a/package.nls.json +++ b/package.nls.json @@ -47,6 +47,11 @@ "chrome.label": "Web App (Chrome)", "chrome.launch.description": "Launch Chrome to debug a URL", "chrome.launch.label": "Chrome: Launch", + "codeBrowser.attach.description": "Attach to an open VS Code integrated browser", + "codeBrowser.attach.label": "Integrated Browser: Attach", + "codeBrowser.label": "Web App (Integrated Browser)", + "codeBrowser.launch.description": "Launch a VS Code integrated browser to debug a URL", + "codeBrowser.launch.label": "Integrated Browser: Launch", "commands.callersAdd.label": "Exclude Caller", "commands.callersAdd.paletteLabel": "Exclude caller from pausing in the current location", "commands.callersGoToCaller.label": "Go to caller location", diff --git a/src/build/generate-contributions.ts b/src/build/generate-contributions.ts index 1cd57a8be..8045992dc 100644 --- a/src/build/generate-contributions.ts +++ b/src/build/generate-contributions.ts @@ -25,6 +25,8 @@ import { breakpointLanguages, chromeAttachConfigDefaults, chromeLaunchConfigDefaults, + codeBrowserAttachConfigDefaults, + codeBrowserLaunchConfigDefaults, edgeAttachConfigDefaults, edgeLaunchConfigDefaults, extensionHostConfigDefaults, @@ -32,6 +34,8 @@ import { IChromeAttachConfiguration, IChromeLaunchConfiguration, IChromiumBaseConfiguration, + ICodeBrowserAttachConfiguration, + ICodeBrowserLaunchConfiguration, IEdgeAttachConfiguration, IEdgeLaunchConfiguration, IExtensionHostLaunchConfiguration, @@ -103,7 +107,11 @@ const forAnyDebugType = (contextKey: string, andExpr?: string) => forSomeContextKeys(allDebugTypes, contextKey, andExpr); const forBrowserDebugType = (contextKey: string, andExpr?: string) => - forSomeContextKeys([DebugType.Chrome, DebugType.Edge], contextKey, andExpr); + forSomeContextKeys( + [DebugType.Chrome, DebugType.Edge, DebugType.CodeBrowser], + contextKey, + andExpr, + ); const forNodeDebugType = (contextKey: string, andExpr?: string) => forSomeContextKeys([DebugType.Node, DebugType.ExtensionHost, 'node'], contextKey, andExpr); @@ -137,6 +145,7 @@ interface IDebugger { configurationAttributes: ConfigurationAttributes; defaults: T; strings?: { unverifiedBreakpoints?: string }; + when?: string; } const commonLanguages = ['javascript', 'typescript', 'javascriptreact', 'typescriptreact']; @@ -1095,6 +1104,62 @@ const edgeAttachConfig: IDebugger = { defaults: edgeAttachConfigDefaults, }; +const codeBrowserLaunchConfig: IDebugger = { + type: DebugType.CodeBrowser, + request: 'launch', + label: refString('codeBrowser.label'), + languages: browserLanguages, + when: '!isWeb', + configurationSnippets: [ + { + label: refString('codeBrowser.launch.label'), + description: refString('codeBrowser.launch.description'), + body: { + type: DebugType.CodeBrowser, + request: 'launch', + name: 'Launch Integrated Browser', + url: 'http://localhost:8080', + webRoot: '^"${2:\\${workspaceFolder\\}}"', + }, + }, + ], + configurationAttributes: { + ...chromiumBaseConfigurationAttributes, + url: { + type: 'string', + description: refString('browser.url.description'), + default: 'http://localhost:8080', + tags: [Tag.Setup], + }, + }, + required: ['url'], + defaults: codeBrowserLaunchConfigDefaults, +}; + +const codeBrowserAttachConfig: IDebugger = { + type: DebugType.CodeBrowser, + request: 'attach', + label: refString('codeBrowser.label'), + languages: browserLanguages, + when: '!isWeb', + configurationSnippets: [ + { + label: refString('codeBrowser.attach.label'), + description: refString('codeBrowser.attach.description'), + body: { + type: DebugType.CodeBrowser, + request: 'attach', + name: 'Attach to Integrated Browser', + webRoot: '^"${2:\\${workspaceFolder\\}}"', + }, + }, + ], + configurationAttributes: { + ...chromiumBaseConfigurationAttributes, + }, + defaults: codeBrowserAttachConfigDefaults, +}; + export const debuggers = [ nodeAttachConfig, nodeLaunchConfig, @@ -1104,6 +1169,8 @@ export const debuggers = [ chromeAttachConfig, edgeLaunchConfig, edgeAttachConfig, + codeBrowserLaunchConfig, + codeBrowserAttachConfig, ]; function buildDebuggers() { diff --git a/src/common/contributionUtils.ts b/src/common/contributionUtils.ts index f4839a584..fcc7dc793 100644 --- a/src/common/contributionUtils.ts +++ b/src/common/contributionUtils.ts @@ -82,6 +82,7 @@ export const enum DebugType { Node = 'pwa-node', Chrome = 'pwa-chrome', Edge = 'pwa-msedge', + CodeBrowser = 'pwa-code-browser', } export const preferredDebugTypes: ReadonlyMap = new Map([ @@ -89,6 +90,7 @@ export const preferredDebugTypes: ReadonlyMap = new Map([ [DebugType.Chrome, 'chrome'], [DebugType.ExtensionHost, 'extensionHost'], [DebugType.Edge, 'msedge'], + [DebugType.CodeBrowser, 'code-browser'], ]); export const getPreferredOrDebugType = (t: T) => @@ -101,6 +103,7 @@ const debugTypes: { [K in DebugType]: null } = { [DebugType.Node]: null, [DebugType.Chrome]: null, [DebugType.Edge]: null, + [DebugType.CodeBrowser]: null, }; const commandsObj: { [K in Commands]: null } = { diff --git a/src/configuration.ts b/src/configuration.ts index a6c1d00ea..dd8aaef3f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -759,6 +759,23 @@ export interface IEdgeAttachConfiguration extends IChromiumAttachConfiguration { useWebView: boolean | { pipeName: string }; } +/** + * Configuration to launch a VS Code integrated browser. + */ +export interface ICodeBrowserLaunchConfiguration extends IChromiumBaseConfiguration { + type: DebugType.CodeBrowser; + request: 'launch'; + url: string; +} + +/** + * Configuration to attach to VS Code integrated browsers. + */ +export interface ICodeBrowserAttachConfiguration extends IChromiumBaseConfiguration { + type: DebugType.CodeBrowser; + request: 'attach'; +} + /** * Attach request used internally to inject a pre-built target into the lifecycle. */ @@ -777,10 +794,16 @@ export type AnyNodeConfiguration = | ITerminalDelegateConfiguration; export type AnyChromeConfiguration = IChromeAttachConfiguration | IChromeLaunchConfiguration; export type AnyEdgeConfiguration = IEdgeAttachConfiguration | IEdgeLaunchConfiguration; +export type AnyCodeBrowserConfiguration = + | ICodeBrowserAttachConfiguration + | ICodeBrowserLaunchConfiguration; export type AnyChromiumLaunchConfiguration = IEdgeLaunchConfiguration | IChromeLaunchConfiguration; export type AnyChromiumAttachConfiguration = IEdgeAttachConfiguration | IChromeAttachConfiguration; export type AnyChromiumConfiguration = AnyEdgeConfiguration | AnyChromeConfiguration; -export type AnyLaunchConfiguration = AnyChromiumConfiguration | AnyNodeConfiguration; +export type AnyLaunchConfiguration = + | AnyChromiumConfiguration + | AnyNodeConfiguration + | AnyCodeBrowserConfiguration; export type AnyTerminalConfiguration = | ITerminalDelegateConfiguration | ITerminalLaunchConfiguration; @@ -809,13 +832,15 @@ export type ResolvingNodeConfiguration = | ResolvingNodeLaunchConfiguration; export type ResolvingChromeConfiguration = ResolvingConfiguration; export type ResolvingEdgeConfiguration = ResolvingConfiguration; +export type ResolvingCodeBrowserConfiguration = ResolvingConfiguration; export type AnyResolvingConfiguration = | ResolvingExtensionHostConfiguration | ResolvingChromeConfiguration | ResolvingNodeAttachConfiguration | ResolvingNodeLaunchConfiguration | ResolvingTerminalConfiguration - | ResolvingEdgeConfiguration; + | ResolvingEdgeConfiguration + | ResolvingCodeBrowserConfiguration; export const AnyLaunchConfiguration = Symbol('AnyLaunchConfiguration'); @@ -977,6 +1002,32 @@ export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = { useWebView: false, }; +const codeBrowserBaseDefaults: IChromiumBaseConfiguration = { + ...baseDefaults, + disableNetworkCache: true, + pathMapping: {}, + url: null, + urlFilter: '', + sourceMapPathOverrides: defaultSourceMapPathOverrides('${webRoot}'), + webRoot: '${workspaceFolder}', + server: null, + vueComponentPaths: ['${workspaceFolder}/**/*.vue', '!**/node_modules/**'], + perScriptSourcemaps: 'auto', +}; + +export const codeBrowserAttachConfigDefaults: ICodeBrowserAttachConfiguration = { + ...codeBrowserBaseDefaults, + type: DebugType.CodeBrowser, + request: 'attach', +}; + +export const codeBrowserLaunchConfigDefaults: ICodeBrowserLaunchConfiguration = { + ...codeBrowserBaseDefaults, + type: DebugType.CodeBrowser, + request: 'launch', + url: 'http://localhost:8080', +}; + export const nodeAttachConfigDefaults: INodeAttachConfiguration = { ...nodeBaseDefaults, type: DebugType.Node, @@ -1047,6 +1098,14 @@ export function applyEdgeDefaults( : { ...edgeLaunchConfigDefaults, browserLaunchLocation: browserLocation, ...config }; } +export function applyCodeBrowserDefaults( + config: ResolvingCodeBrowserConfiguration, +): AnyCodeBrowserConfiguration { + return config.request === 'attach' + ? { ...codeBrowserAttachConfigDefaults, ...config } + : { ...codeBrowserLaunchConfigDefaults, ...config }; +} + export function applyExtensionHostDefaults( config: ResolvingExtensionHostConfiguration, ): IExtensionHostLaunchConfiguration { @@ -1089,6 +1148,9 @@ export function applyDefaults( case DebugType.Terminal: configWithDefaults = applyTerminalDefaults(config); break; + case DebugType.CodeBrowser: + configWithDefaults = applyCodeBrowserDefaults(config); + break; default: throw assertNever(config, 'Unknown config: {value}'); } diff --git a/src/ioc.ts b/src/ioc.ts index 12da62962..7628ba9c1 100644 --- a/src/ioc.ts +++ b/src/ioc.ts @@ -94,6 +94,8 @@ import { } from './ioc-extras'; import { BrowserAttacher } from './targets/browser/browserAttacher'; import { ChromeLauncher } from './targets/browser/chromeLauncher'; +import { CodeBrowserAttacher } from './targets/browser/codeBrowserAttacher'; +import { CodeBrowserLauncher } from './targets/browser/codeBrowserLauncher'; import { EdgeLauncher } from './targets/browser/edgeLauncher'; import { RemoteBrowserAttacher } from './targets/browser/remoteBrowserAttacher'; import { RemoteBrowserHelper } from './targets/browser/remoteBrowserHelper'; @@ -283,6 +285,8 @@ export const createTopLevelSessionContainer = (parent: Container) => { .onActivation(trackDispose); container.bind(ILauncher).to(BrowserAttacher).onActivation(trackDispose); + container.bind(ILauncher).to(CodeBrowserLauncher).onActivation(trackDispose); + container.bind(ILauncher).to(CodeBrowserAttacher).onActivation(trackDispose); container .bind(ILauncher) .toDynamicValue(() => diff --git a/src/targets/browser/browserLaunchParams.ts b/src/targets/browser/browserLaunchParams.ts index c96a8f95c..49d4f9e51 100644 --- a/src/targets/browser/browserLaunchParams.ts +++ b/src/targets/browser/browserLaunchParams.ts @@ -4,9 +4,11 @@ import { URL } from 'url'; import { absolutePathToFileUrlWithDetection } from '../../common/urlUtils'; -import { AnyChromiumConfiguration } from '../../configuration'; +import { AnyChromiumConfiguration, AnyCodeBrowserConfiguration } from '../../configuration'; -export function baseURL(params: AnyChromiumConfiguration): string | undefined { +export function baseURL( + params: AnyChromiumConfiguration | AnyCodeBrowserConfiguration, +): string | undefined { if ('file' in params && params.file) { return absolutePathToFileUrlWithDetection(params.file); } diff --git a/src/targets/browser/codeBrowserAttacher.ts b/src/targets/browser/codeBrowserAttacher.ts new file mode 100644 index 000000000..b2065ba64 --- /dev/null +++ b/src/targets/browser/codeBrowserAttacher.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { inject, injectable, optional } from 'inversify'; +import type * as vscodeType from 'vscode'; +import Connection from '../../cdp/connection'; +import { DebugType } from '../../common/contributionUtils'; +import { EventEmitter } from '../../common/events'; +import { ILogger } from '../../common/logging'; +import { ISourcePathResolver } from '../../common/sourcePathResolver'; +import { AnyChromiumConfiguration, AnyLaunchConfiguration } from '../../configuration'; +import { browserAttachFailed } from '../../dap/errors'; +import { ProtocolError } from '../../dap/protocolError'; +import { VSCodeApi } from '../../ioc-extras'; +import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets'; +import { BrowserTargetManager } from './browserTargetManager'; +import { CodeBrowserSessionTransport } from './codeBrowserSessionTransport'; + +@injectable() +export class CodeBrowserAttacher implements ILauncher { + private _targetManager: BrowserTargetManager | undefined; + private _onTerminatedEmitter = new EventEmitter(); + readonly onTerminated = this._onTerminatedEmitter.event; + private _onTargetListChangedEmitter = new EventEmitter(); + readonly onTargetListChanged = this._onTargetListChangedEmitter.event; + + constructor( + @inject(ILogger) private readonly logger: ILogger, + @inject(ISourcePathResolver) private readonly pathResolver: ISourcePathResolver, + @optional() @inject(VSCodeApi) private readonly vscode?: typeof vscodeType, + ) {} + + public dispose() { + if (this._targetManager) { + this._targetManager.dispose(); + this._targetManager = undefined; + } + } + + public async launch( + params: AnyLaunchConfiguration, + context: ILaunchContext, + ): Promise { + if (params.type !== DebugType.CodeBrowser || params.request !== 'attach') { + return { blockSessionTermination: false }; + } + + if (!this.vscode) { + throw new ProtocolError(browserAttachFailed('VS Code API is not available')); + } + + const vscode = this.vscode; + const { window: vscodeWindow } = vscode; + + const browserTabs = vscodeWindow.browserTabs; + if (!browserTabs) { + throw new ProtocolError( + browserAttachFailed('The browser tab API is not available. Is the proposal enabled?'), + ); + } + + type BrowserPickItem = vscodeType.QuickPickItem & { + tab?: vscodeType.BrowserTab; + openNew?: true; + }; + + const buildItems = (): BrowserPickItem[] => { + const activeBrowserTab = vscodeWindow.activeBrowserTab; + const items: BrowserPickItem[] = []; + if (activeBrowserTab) { + items.push({ label: l10n.t('Active'), kind: vscode.QuickPickItemKind.Separator }); + items.push({ + label: activeBrowserTab.title, + detail: activeBrowserTab.url, + iconPath: activeBrowserTab.icon, + tab: activeBrowserTab, + }); + } + + const otherTabs = (vscodeWindow.browserTabs ?? []).filter(tab => tab !== activeBrowserTab); + if (otherTabs.length > 0) { + items.push({ label: l10n.t('Other'), kind: vscode.QuickPickItemKind.Separator }); + for (const tab of otherTabs) { + items.push({ + label: tab.title, + detail: tab.url, + iconPath: tab.icon, + tab, + }); + } + } + + items.push({ label: '', kind: vscode.QuickPickItemKind.Separator }); + items.push({ + label: l10n.t('Open new\u2026'), + iconPath: new vscode.ThemeIcon('add'), + openNew: true, + }); + + return items; + }; + + const selected = await new Promise(resolve => { + const qp = vscode.window.createQuickPick(); + qp.placeholder = l10n.t('Select a browser tab to debug'); + qp.matchOnDetail = true; + qp.items = buildItems(); + + const disposables: vscodeType.Disposable[] = []; + + const refresh = () => { + qp.items = buildItems(); + }; + if (vscodeWindow.onDidOpenBrowserTab) { + disposables.push(vscodeWindow.onDidOpenBrowserTab(refresh)); + } + if (vscodeWindow.onDidCloseBrowserTab) { + disposables.push(vscodeWindow.onDidCloseBrowserTab(refresh)); + } + if (vscodeWindow.onDidChangeActiveBrowserTab) { + disposables.push(vscodeWindow.onDidChangeActiveBrowserTab(refresh)); + } + if (vscodeWindow.onDidChangeBrowserTabState) { + disposables.push(vscodeWindow.onDidChangeBrowserTabState(refresh)); + } + + disposables.push(qp.onDidAccept(() => { + resolve(qp.selectedItems[0]); + qp.dispose(); + })); + disposables.push(qp.onDidHide(() => { + resolve(undefined); + qp.dispose(); + })); + disposables.push(qp); + disposables.push({ dispose: () => disposables.forEach(d => d.dispose()) }); + + qp.show(); + }); + + if (!selected) { + this._onTerminatedEmitter.fire({ killed: true, code: 0 }); + return { blockSessionTermination: false }; + } + + let tab: vscodeType.BrowserTab; + if (selected.openNew) { + const url = await vscode.window.showInputBox({ + prompt: l10n.t('Enter a URL to open'), + placeHolder: 'https://example.com', + validateInput: value => { + try { + new URL(value); + return undefined; + } catch { + return l10n.t('Please enter a valid URL'); + } + }, + }); + + if (!url) { + this._onTerminatedEmitter.fire({ killed: true, code: 0 }); + return { blockSessionTermination: false }; + } + + if (!vscodeWindow.openBrowserTab) { + throw new ProtocolError(browserAttachFailed('The browser tab API is not available.')); + } + + tab = await vscodeWindow.openBrowserTab(url); + } else { + tab = selected.tab!; + } + + const session = await tab.startCDPSession(); + + const transport = new CodeBrowserSessionTransport(session); + const connection = new Connection(transport, this.logger, context.telemetryReporter); + + connection.onDisconnected(() => { + this._targetManager?.dispose(); + this._targetManager = undefined; + this._onTargetListChangedEmitter.fire(); + this._onTerminatedEmitter.fire({ killed: true, code: 0 }); + }); + + const targetManager = await BrowserTargetManager.connect( + connection, + undefined, + this.pathResolver, + params as unknown as AnyChromiumConfiguration, + this.logger, + context.telemetryReporter, + context.targetOrigin, + ); + + if (!targetManager) { + connection.close(); + throw new ProtocolError(browserAttachFailed(l10n.t('Could not connect to browser target'))); + } + + this._targetManager = targetManager; + + targetManager.onTargetAdded(() => this._onTargetListChangedEmitter.fire()); + targetManager.onTargetRemoved(() => { + this._onTargetListChangedEmitter.fire(); + if (!targetManager.targetList().length) { + this._onTerminatedEmitter.fire({ killed: true, code: 0 }); + connection.close(); + } + }); + + await targetManager.waitForMainTarget(); + + return { blockSessionTermination: true }; + } + + async terminate(): Promise { + this._targetManager?.dispose(); + this._targetManager = undefined; + } + + async restart(): Promise { + // No-op for attach + } + + targetList(): ITarget[] { + return this._targetManager?.targetList() ?? []; + } +} diff --git a/src/targets/browser/codeBrowserLauncher.ts b/src/targets/browser/codeBrowserLauncher.ts new file mode 100644 index 000000000..cd0d9fa04 --- /dev/null +++ b/src/targets/browser/codeBrowserLauncher.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { inject, injectable, optional } from 'inversify'; +import type * as vscodeType from 'vscode'; +import Connection from '../../cdp/connection'; +import { DebugType } from '../../common/contributionUtils'; +import { EventEmitter } from '../../common/events'; +import { ILogger } from '../../common/logging'; +import { ISourcePathResolver } from '../../common/sourcePathResolver'; +import { + AnyChromiumConfiguration, + AnyLaunchConfiguration, + ICodeBrowserLaunchConfiguration, +} from '../../configuration'; +import { browserLaunchFailed } from '../../dap/errors'; +import { ProtocolError } from '../../dap/protocolError'; +import { VSCodeApi } from '../../ioc-extras'; +import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets'; +import { BrowserTargetManager } from './browserTargetManager'; +import { CodeBrowserSessionTransport } from './codeBrowserSessionTransport'; + +@injectable() +export class CodeBrowserLauncher implements ILauncher { + private _targetManager: BrowserTargetManager | undefined; + private _onTerminatedEmitter = new EventEmitter(); + readonly onTerminated = this._onTerminatedEmitter.event; + private _onTargetListChangedEmitter = new EventEmitter(); + readonly onTargetListChanged = this._onTargetListChangedEmitter.event; + + constructor( + @inject(ILogger) private readonly logger: ILogger, + @inject(ISourcePathResolver) private readonly pathResolver: ISourcePathResolver, + @optional() @inject(VSCodeApi) private readonly vscode?: typeof vscodeType, + ) {} + + public dispose() { + if (this._targetManager) { + this._targetManager.dispose(); + this._targetManager = undefined; + } + } + + public async launch( + params: AnyLaunchConfiguration, + context: ILaunchContext, + ): Promise { + if (params.type !== DebugType.CodeBrowser || params.request !== 'launch') { + return { blockSessionTermination: false }; + } + + if (!this.vscode) { + throw new ProtocolError(browserLaunchFailed(new Error('VS Code API is not available'))); + } + + const { window: vscodeWindow } = this.vscode; + + if (!vscodeWindow.openBrowserTab) { + throw new ProtocolError( + browserLaunchFailed( + new Error('The browser tab API is not available. Is the proposal enabled?'), + ), + ); + } + + const launchParams = params as ICodeBrowserLaunchConfiguration; + const url = launchParams.url; + if (!url) { + throw new ProtocolError( + browserLaunchFailed( + new Error(l10n.t('A "url" is required to launch an integrated browser')), + ), + ); + } + + const tab = await vscodeWindow.openBrowserTab(url); + const session = await tab.startCDPSession(); + + const transport = new CodeBrowserSessionTransport(session); + const connection = new Connection(transport, this.logger, context.telemetryReporter); + + connection.onDisconnected(() => { + this._targetManager?.dispose(); + this._targetManager = undefined; + this._onTargetListChangedEmitter.fire(); + this._onTerminatedEmitter.fire({ killed: true, code: 0 }); + }); + + const targetManager = await BrowserTargetManager.connect( + connection, + undefined, + this.pathResolver, + launchParams as unknown as AnyChromiumConfiguration, + this.logger, + context.telemetryReporter, + context.targetOrigin, + ); + + if (!targetManager) { + connection.close(); + throw new ProtocolError( + browserLaunchFailed(new Error(l10n.t('Could not connect to browser target'))), + ); + } + + this._targetManager = targetManager; + + targetManager.onTargetAdded(() => this._onTargetListChangedEmitter.fire()); + targetManager.onTargetRemoved(() => { + this._onTargetListChangedEmitter.fire(); + if (!targetManager.targetList().length) { + this._onTerminatedEmitter.fire({ killed: true, code: 0 }); + connection.close(); + } + }); + + await targetManager.waitForMainTarget(); + + return { blockSessionTermination: true }; + } + + async terminate(): Promise { + this._targetManager?.dispose(); + this._targetManager = undefined; + } + + async restart(): Promise { + // Reload all page targets + if (!this._targetManager) { + return; + } + for (const target of this._targetManager.targetList()) { + if (target.type() === 'page') { + target.restart(); + } + } + } + + targetList(): ITarget[] { + return this._targetManager?.targetList() ?? []; + } +} diff --git a/src/targets/browser/codeBrowserSessionTransport.ts b/src/targets/browser/codeBrowserSessionTransport.ts new file mode 100644 index 000000000..616c8e451 --- /dev/null +++ b/src/targets/browser/codeBrowserSessionTransport.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { ITransport } from '../../cdp/transport'; +import { EventEmitter } from '../../common/events'; +import { HrTime } from '../../common/hrnow'; + +/** + * A CDP transport that wraps a VS Code BrowserCDPSession. + */ +export class CodeBrowserSessionTransport implements ITransport { + private readonly messageEmitter = new EventEmitter<[string, HrTime]>(); + private readonly endEmitter = new EventEmitter(); + + public readonly onMessage = this.messageEmitter.event; + public readonly onEnd = this.endEmitter.event; + + constructor(private readonly session: vscode.BrowserCDPSession) { + session.onDidReceiveMessage(msg => { + this.messageEmitter.fire([typeof msg === 'string' ? msg : JSON.stringify(msg), new HrTime()]); + }); + session.onDidClose(() => { + this.endEmitter.fire(); + }); + } + + send(message: string): void { + const parsed = JSON.parse(message); + this.session.sendMessage(parsed); + } + + dispose(): void { + this.session.close(); + } +} diff --git a/src/test/extension/codeBrowserConfigurationProvider.test.ts b/src/test/extension/codeBrowserConfigurationProvider.test.ts new file mode 100644 index 000000000..9856d9e8a --- /dev/null +++ b/src/test/extension/codeBrowserConfigurationProvider.test.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { expect } from 'chai'; +import * as vscode from 'vscode'; +import { DebugType } from '../../common/contributionUtils'; +import { upcastPartial } from '../../common/objUtils'; +import { + codeBrowserAttachConfigDefaults, + codeBrowserLaunchConfigDefaults, +} from '../../configuration'; +import { CodeBrowserDebugConfigurationResolver } from '../../ui/configuration/codeBrowserDebugConfigurationProvider'; +import { testFixturesDir } from '../test'; +import { TestMemento } from '../testMemento'; + +describe('CodeBrowserDebugConfigurationProvider', () => { + let provider: CodeBrowserDebugConfigurationResolver; + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(testFixturesDir), + name: 'test-dir', + index: 0, + }; + + beforeEach(() => { + provider = new CodeBrowserDebugConfigurationResolver( + upcastPartial({ + logPath: testFixturesDir, + workspaceState: new TestMemento(), + }), + ); + }); + + describe('launch config', () => { + it('returns null for empty config', async () => { + const result = await provider.resolveDebugConfiguration(folder, { + type: '', + name: '', + request: '', + }); + expect(result).to.be.null; + }); + + it('applies launch defaults', async () => { + const result = await provider.resolveDebugConfiguration(folder, { + type: DebugType.CodeBrowser, + name: 'test', + request: 'launch', + url: 'http://localhost:3000', + }); + + expect(result).to.containSubset({ + type: DebugType.CodeBrowser, + request: 'launch', + url: 'http://localhost:3000', + webRoot: codeBrowserLaunchConfigDefaults.webRoot, + disableNetworkCache: codeBrowserLaunchConfigDefaults.disableNetworkCache, + }); + }); + + it('user config overrides defaults', async () => { + const result = await provider.resolveDebugConfiguration(folder, { + type: DebugType.CodeBrowser, + name: 'test', + request: 'launch', + url: 'http://localhost:9000', + webRoot: '/custom/path', + }); + + expect(result).to.containSubset({ + url: 'http://localhost:9000', + webRoot: '/custom/path', + }); + }); + }); + + describe('attach config', () => { + it('applies attach defaults', async () => { + const result = await provider.resolveDebugConfiguration(folder, { + type: DebugType.CodeBrowser, + name: 'test', + request: 'attach', + }); + + expect(result).to.containSubset({ + type: DebugType.CodeBrowser, + request: 'attach', + webRoot: codeBrowserAttachConfigDefaults.webRoot, + disableNetworkCache: codeBrowserAttachConfigDefaults.disableNetworkCache, + }); + }); + + it('user config overrides attach defaults', async () => { + const result = await provider.resolveDebugConfiguration(folder, { + type: DebugType.CodeBrowser, + name: 'test', + request: 'attach', + webRoot: '/my/root', + }); + + expect(result).to.containSubset({ + type: DebugType.CodeBrowser, + request: 'attach', + webRoot: '/my/root', + }); + }); + }); +}); diff --git a/src/test/extension/codeBrowserIntegration.test.ts b/src/test/extension/codeBrowserIntegration.test.ts new file mode 100644 index 000000000..f25ada420 --- /dev/null +++ b/src/test/extension/codeBrowserIntegration.test.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { expect } from 'chai'; +import { createServer, Server } from 'http'; +import type { AddressInfo } from 'net'; +import { SinonSpy, stub } from 'sinon'; +import * as vscode from 'vscode'; +import { DebugType } from '../../common/contributionUtils'; +import { EventEmitter } from '../../common/events'; + +describe('integrated browser debugging', function() { + this.timeout(30_000); + + let server: Server; + let serverUrl: string; + + before(async function() { + // Skip entire suite when the proposed browser API is not available + if (typeof vscode.window.openBrowserTab !== 'function') { + return this.skip(); + } + + await new Promise(resolve => { + server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(''); + }); + server.listen(0, '127.0.0.1', resolve); + }); + serverUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + try { + await vscode.debug.stopDebugging(); + } catch { + // no active session to stop + } + }); + + after(async () => { + if (server) { + await new Promise(resolve => server.close(() => resolve())); + } + }); + + /** Waits for a child debug session to start (one with __pendingTargetId). */ + const waitForChildSession = () => + new Promise(resolve => { + const d = vscode.debug.onDidStartDebugSession(s => { + if ('__pendingTargetId' in s.configuration) { + d.dispose(); + resolve(s); + } + }); + }); + + it('launch opens a browser tab visible via the API', async () => { + const tabsBefore = [...vscode.window.browserTabs]; + const sessionStarted = waitForChildSession(); + + await vscode.debug.startDebugging(undefined, { + type: DebugType.CodeBrowser, + request: 'launch', + name: 'Launch Test', + url: serverUrl, + }); + + const session = await sessionStarted; + expect(session).to.exist; + + // The launcher calls openBrowserTab, so a new tab should appear + const tabsAfter = vscode.window.browserTabs; + const newTabs = tabsAfter.filter(t => !tabsBefore.includes(t)); + expect(newTabs).to.have.lengthOf(1, 'expected exactly one new browser tab'); + expect(newTabs[0].url).to.include(serverUrl); + + await vscode.debug.stopDebugging(session); + }); + + it('attach debugs the pre-opened tab without opening another', async () => { + // Open a tab before starting the debug session + const tab = await vscode.window.openBrowserTab(serverUrl, { background: true }); + const tabCountBefore = vscode.window.browserTabs.length; + + // Stub createQuickPick to auto-select the tab matching our URL + let acceptEmitter: EventEmitter; + const originalCreateQuickPick = vscode.window.createQuickPick; + const createQuickPickStub: SinonSpy = stub( + vscode.window, + 'createQuickPick', + ).callsFake(() => { + const picker = originalCreateQuickPick.call(vscode.window); + acceptEmitter = new EventEmitter(); + stub(picker, 'onDidAccept').callsFake(acceptEmitter.event); + + // Once shown, poll for items matching our tab and auto-accept + const origShow = picker.show.bind(picker); + stub(picker, 'show').callsFake(() => { + origShow(); + const interval = setInterval(() => { + const match = picker.items.find( + i => 'detail' in i && typeof i.detail === 'string' && i.detail.includes(serverUrl), + ); + if (match) { + clearInterval(interval); + picker.selectedItems = [match]; + acceptEmitter.fire(); + } + }, 50); + }); + + return picker; + }); + + try { + const sessionStarted = waitForChildSession(); + + await vscode.debug.startDebugging(undefined, { + type: DebugType.CodeBrowser, + request: 'attach', + name: 'Attach Test', + }); + + const session = await sessionStarted; + expect(session).to.exist; + + // Verify no additional browser tabs were opened + expect(vscode.window.browserTabs).to.have.lengthOf( + tabCountBefore, + 'attach should not open a new browser tab', + ); + + // Verify the tab we opened is the one being debugged by checking + // that the debugged URL matches our pre-opened tab + expect(tab.url).to.include(serverUrl); + + await vscode.debug.stopDebugging(session); + } finally { + createQuickPickStub.restore(); + } + }); +}); diff --git a/src/typings/vscode.proposed.browser.d.ts b/src/typings/vscode.proposed.browser.d.ts new file mode 100644 index 000000000..b81b6955c --- /dev/null +++ b/src/typings/vscode.proposed.browser.d.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // @kycutler https://github.com/microsoft/vscode/issues/300319 + + /** + * An integrated browser page displayed in an editor tab. + */ + export interface BrowserTab { + /** The current URL of the page. */ + readonly url: string; + + /** The current page title. */ + readonly title: string; + + /** The page icon (favicon or a default globe icon). */ + readonly icon: IconPath; + + /** Create a new CDP session that exposes this browser tab. */ + startCDPSession(): Thenable; + } + + /** + * A CDP (Chrome DevTools Protocol) session that provides a bidirectional message channel. + * + * Create a session via {@link BrowserTab.startCDPSession}. + */ + export interface BrowserCDPSession { + /** Fires when a CDP message is received from an attached target. */ + readonly onDidReceiveMessage: Event; + + /** Fires when this session is closed. */ + readonly onDidClose: Event; + + /** Send a CDP request message to an attached target. */ + sendMessage(message: unknown): Thenable; + + /** Close this session and detach all targets. */ + close(): Thenable; + } + + /** Options for {@link window.openBrowserTab}. */ + export interface BrowserTabShowOptions { + /** + * The view column to show the browser in. Defaults to {@link ViewColumn.Active}. + * Use {@linkcode ViewColumn.Beside} to open next to the current editor. + */ + viewColumn?: ViewColumn; + + /** When `true`, the browser tab will not take focus. */ + preserveFocus?: boolean; + + /** When `true`, the browser tab will open in the background. */ + background?: boolean; + } + + export namespace window { + /** The currently open browser tabs. */ + export const browserTabs: readonly BrowserTab[]; + + /** Fires when a browser tab is opened. */ + export const onDidOpenBrowserTab: Event; + + /** Fires when a browser tab is closed. */ + export const onDidCloseBrowserTab: Event; + + /** The currently active browser tab. */ + export const activeBrowserTab: BrowserTab | undefined; + + /** Fires when {@link activeBrowserTab} changes. */ + export const onDidChangeActiveBrowserTab: Event; + + /** Fires when a browser tab's state (url, title, or icon) changes. */ + export const onDidChangeBrowserTabState: Event; + + /** + * Open a browser tab at the given URL. + * + * @param url The URL to navigate to. + * @param options Controls where and how the browser tab is shown. + * @returns The {@link BrowserTab} representing the opened page. + */ + export function openBrowserTab(url: string, options?: BrowserTabShowOptions): Thenable; + } +} diff --git a/src/ui/configuration/codeBrowserDebugConfigurationProvider.ts b/src/ui/configuration/codeBrowserDebugConfigurationProvider.ts new file mode 100644 index 000000000..1054a3fb7 --- /dev/null +++ b/src/ui/configuration/codeBrowserDebugConfigurationProvider.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { injectable } from 'inversify'; +import * as vscode from 'vscode'; +import { DebugType } from '../../common/contributionUtils'; +import { + AnyCodeBrowserConfiguration, + codeBrowserAttachConfigDefaults, + codeBrowserLaunchConfigDefaults, + ResolvingCodeBrowserConfiguration, +} from '../../configuration'; +import { BaseConfigurationResolver } from './baseConfigurationResolver'; + +/** + * Configuration provider for VS Code integrated browser debugging. + * Only available on desktop. + */ +@injectable() +export class CodeBrowserDebugConfigurationResolver + extends BaseConfigurationResolver + implements vscode.DebugConfigurationProvider +{ + protected async resolveDebugConfigurationAsync( + _folder: vscode.WorkspaceFolder | undefined, + config: ResolvingCodeBrowserConfiguration, + ): Promise { + if (vscode.env.uiKind === vscode.UIKind.Web) { + vscode.window.showErrorMessage( + 'Integrated Browser debugging is only available on VS Code Desktop.', + ); + return null; + } + + if (!config.name && !config.type && !config.request) { + return null; + } + + return config.request === 'attach' + ? { ...codeBrowserAttachConfigDefaults, ...config } + : { ...codeBrowserLaunchConfigDefaults, ...config }; + } + + protected getType() { + return DebugType.CodeBrowser as const; + } + + protected getSuggestedWorkspaceFolders(config: AnyCodeBrowserConfiguration) { + return [config.rootPath, config.webRoot]; + } +} diff --git a/src/ui/configuration/index.ts b/src/ui/configuration/index.ts index a8cfb0236..f9b84114c 100644 --- a/src/ui/configuration/index.ts +++ b/src/ui/configuration/index.ts @@ -7,6 +7,7 @@ import { ChromeDebugConfigurationProvider, ChromeDebugConfigurationResolver, } from './chromeDebugConfigurationProvider'; +import { CodeBrowserDebugConfigurationResolver } from './codeBrowserDebugConfigurationProvider'; import { EdgeDebugConfigurationProvider, EdgeDebugConfigurationResolver, @@ -22,6 +23,7 @@ import { TerminalDebugConfigurationResolver } from './terminalDebugConfiguration export const allConfigurationResolvers = [ ChromeDebugConfigurationResolver, EdgeDebugConfigurationResolver, + CodeBrowserDebugConfigurationResolver, ExtensionHostConfigurationResolver, NodeConfigurationResolver, TerminalDebugConfigurationResolver, From 47f8f1091a325a47a762c167abfb98b3088ee9c3 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Thu, 12 Mar 2026 12:41:36 -0700 Subject: [PATCH 2/3] feedback --- OPTIONS.md | 4 +- package.nls.json | 10 ++-- src/build/generate-contributions.ts | 42 ++++++++-------- src/common/contributionUtils.ts | 6 +-- src/configuration.ts | 50 ++++++++++--------- src/ioc.ts | 4 -- src/targets/browser/browserLaunchParams.ts | 4 +- ...erAttacher.ts => editorBrowserAttacher.ts} | 8 +-- ...erLauncher.ts => editorBrowserLauncher.ts} | 12 ++--- ...rt.ts => editorBrowserSessionTransport.ts} | 2 +- ...ditorBrowserConfigurationProvider.test.ts} | 34 ++++++------- ...st.ts => editorBrowserIntegration.test.ts} | 4 +- ...ditorBrowserDebugConfigurationProvider.ts} | 24 ++++----- src/ui/configuration/index.ts | 4 +- src/ui/ui-ioc.extensionOnly.ts | 4 ++ 15 files changed, 107 insertions(+), 105 deletions(-) rename src/targets/browser/{codeBrowserAttacher.ts => editorBrowserAttacher.ts} (96%) rename src/targets/browser/{codeBrowserLauncher.ts => editorBrowserLauncher.ts} (91%) rename src/targets/browser/{codeBrowserSessionTransport.ts => editorBrowserSessionTransport.ts} (94%) rename src/test/extension/{codeBrowserConfigurationProvider.test.ts => editorBrowserConfigurationProvider.test.ts} (72%) rename src/test/extension/{codeBrowserIntegration.test.ts => editorBrowserIntegration.test.ts} (98%) rename src/ui/configuration/{codeBrowserDebugConfigurationProvider.ts => editorBrowserDebugConfigurationProvider.ts} (63%) diff --git a/OPTIONS.md b/OPTIONS.md index 723ff9da9..f59a48a3f 100644 --- a/OPTIONS.md +++ b/OPTIONS.md @@ -438,7 +438,7 @@ ]

webRoot

This specifies the workspace absolute path to the webserver root. Used to resolve paths like /app.js to files on disk. Shorthand for a pathMapping for "/"

Default value:
"${workspaceFolder}"
-### code-browser: launch +### editor-browser: launch

cascadeTerminateToConfigurations

A list of debug sessions which, when this debug session is terminated, will also be stopped.

Default value:
[]

customDescriptionGenerator

Customize the textual description the debugger shows for objects (local variables, etc...). Samples:
1. this.toString() // will call toString to print all objects
2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue
3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue

@@ -483,7 +483,7 @@ ]

webRoot

This specifies the workspace absolute path to the webserver root. Used to resolve paths like /app.js to files on disk. Shorthand for a pathMapping for "/"

Default value:
"${workspaceFolder}"
-### code-browser: attach +### editor-browser: attach

cascadeTerminateToConfigurations

A list of debug sessions which, when this debug session is terminated, will also be stopped.

Default value:
[]

customDescriptionGenerator

Customize the textual description the debugger shows for objects (local variables, etc...). Samples:
1. this.toString() // will call toString to print all objects
2. this.customDescription ? this.customDescription() : defaultValue // Use customDescription method if available, if not return defaultValue
3. function (def) { return this.customDescription ? this.customDescription() : def } // Use customDescription method if available, if not return defaultValue

diff --git a/package.nls.json b/package.nls.json index 9e990ffc8..d9f51caf6 100644 --- a/package.nls.json +++ b/package.nls.json @@ -47,11 +47,11 @@ "chrome.label": "Web App (Chrome)", "chrome.launch.description": "Launch Chrome to debug a URL", "chrome.launch.label": "Chrome: Launch", - "codeBrowser.attach.description": "Attach to an open VS Code integrated browser", - "codeBrowser.attach.label": "Integrated Browser: Attach", - "codeBrowser.label": "Web App (Integrated Browser)", - "codeBrowser.launch.description": "Launch a VS Code integrated browser to debug a URL", - "codeBrowser.launch.label": "Integrated Browser: Launch", + "editorBrowser.attach.description": "Attach to an open VS Code integrated browser", + "editorBrowser.attach.label": "Integrated Browser: Attach", + "editorBrowser.label": "Web App (Integrated Browser)", + "editorBrowser.launch.description": "Launch a VS Code integrated browser to debug a URL", + "editorBrowser.launch.label": "Integrated Browser: Launch", "commands.callersAdd.label": "Exclude Caller", "commands.callersAdd.paletteLabel": "Exclude caller from pausing in the current location", "commands.callersGoToCaller.label": "Go to caller location", diff --git a/src/build/generate-contributions.ts b/src/build/generate-contributions.ts index 8045992dc..3e598dae1 100644 --- a/src/build/generate-contributions.ts +++ b/src/build/generate-contributions.ts @@ -25,19 +25,19 @@ import { breakpointLanguages, chromeAttachConfigDefaults, chromeLaunchConfigDefaults, - codeBrowserAttachConfigDefaults, - codeBrowserLaunchConfigDefaults, edgeAttachConfigDefaults, edgeLaunchConfigDefaults, + editorBrowserAttachConfigDefaults, + editorBrowserLaunchConfigDefaults, extensionHostConfigDefaults, IBaseConfiguration, IChromeAttachConfiguration, IChromeLaunchConfiguration, IChromiumBaseConfiguration, - ICodeBrowserAttachConfiguration, - ICodeBrowserLaunchConfiguration, IEdgeAttachConfiguration, IEdgeLaunchConfiguration, + IEditorBrowserAttachConfiguration, + IEditorBrowserLaunchConfiguration, IExtensionHostLaunchConfiguration, IMandatedConfiguration, INodeAttachConfiguration, @@ -108,7 +108,7 @@ const forAnyDebugType = (contextKey: string, andExpr?: string) => const forBrowserDebugType = (contextKey: string, andExpr?: string) => forSomeContextKeys( - [DebugType.Chrome, DebugType.Edge, DebugType.CodeBrowser], + [DebugType.Chrome, DebugType.Edge, DebugType.EditorBrowser], contextKey, andExpr, ); @@ -1104,18 +1104,18 @@ const edgeAttachConfig: IDebugger = { defaults: edgeAttachConfigDefaults, }; -const codeBrowserLaunchConfig: IDebugger = { - type: DebugType.CodeBrowser, +const editorBrowserLaunchConfig: IDebugger = { + type: DebugType.EditorBrowser, request: 'launch', - label: refString('codeBrowser.label'), + label: refString('editorBrowser.label'), languages: browserLanguages, when: '!isWeb', configurationSnippets: [ { - label: refString('codeBrowser.launch.label'), - description: refString('codeBrowser.launch.description'), + label: refString('editorBrowser.launch.label'), + description: refString('editorBrowser.launch.description'), body: { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'launch', name: 'Launch Integrated Browser', url: 'http://localhost:8080', @@ -1133,21 +1133,21 @@ const codeBrowserLaunchConfig: IDebugger = { }, }, required: ['url'], - defaults: codeBrowserLaunchConfigDefaults, + defaults: editorBrowserLaunchConfigDefaults, }; -const codeBrowserAttachConfig: IDebugger = { - type: DebugType.CodeBrowser, +const editorBrowserAttachConfig: IDebugger = { + type: DebugType.EditorBrowser, request: 'attach', - label: refString('codeBrowser.label'), + label: refString('editorBrowser.label'), languages: browserLanguages, when: '!isWeb', configurationSnippets: [ { - label: refString('codeBrowser.attach.label'), - description: refString('codeBrowser.attach.description'), + label: refString('editorBrowser.attach.label'), + description: refString('editorBrowser.attach.description'), body: { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'attach', name: 'Attach to Integrated Browser', webRoot: '^"${2:\\${workspaceFolder\\}}"', @@ -1157,7 +1157,7 @@ const codeBrowserAttachConfig: IDebugger = { configurationAttributes: { ...chromiumBaseConfigurationAttributes, }, - defaults: codeBrowserAttachConfigDefaults, + defaults: editorBrowserAttachConfigDefaults, }; export const debuggers = [ @@ -1169,8 +1169,8 @@ export const debuggers = [ chromeAttachConfig, edgeLaunchConfig, edgeAttachConfig, - codeBrowserLaunchConfig, - codeBrowserAttachConfig, + editorBrowserLaunchConfig, + editorBrowserAttachConfig, ]; function buildDebuggers() { diff --git a/src/common/contributionUtils.ts b/src/common/contributionUtils.ts index fcc7dc793..ccddc8b5b 100644 --- a/src/common/contributionUtils.ts +++ b/src/common/contributionUtils.ts @@ -82,7 +82,7 @@ export const enum DebugType { Node = 'pwa-node', Chrome = 'pwa-chrome', Edge = 'pwa-msedge', - CodeBrowser = 'pwa-code-browser', + EditorBrowser = 'pwa-editor-browser', } export const preferredDebugTypes: ReadonlyMap = new Map([ @@ -90,7 +90,7 @@ export const preferredDebugTypes: ReadonlyMap = new Map([ [DebugType.Chrome, 'chrome'], [DebugType.ExtensionHost, 'extensionHost'], [DebugType.Edge, 'msedge'], - [DebugType.CodeBrowser, 'code-browser'], + [DebugType.EditorBrowser, 'editor-browser'], ]); export const getPreferredOrDebugType = (t: T) => @@ -103,7 +103,7 @@ const debugTypes: { [K in DebugType]: null } = { [DebugType.Node]: null, [DebugType.Chrome]: null, [DebugType.Edge]: null, - [DebugType.CodeBrowser]: null, + [DebugType.EditorBrowser]: null, }; const commandsObj: { [K in Commands]: null } = { diff --git a/src/configuration.ts b/src/configuration.ts index dd8aaef3f..f311d499d 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -762,8 +762,8 @@ export interface IEdgeAttachConfiguration extends IChromiumAttachConfiguration { /** * Configuration to launch a VS Code integrated browser. */ -export interface ICodeBrowserLaunchConfiguration extends IChromiumBaseConfiguration { - type: DebugType.CodeBrowser; +export interface IEditorBrowserLaunchConfiguration extends IChromiumBaseConfiguration { + type: DebugType.EditorBrowser; request: 'launch'; url: string; } @@ -771,8 +771,8 @@ export interface ICodeBrowserLaunchConfiguration extends IChromiumBaseConfigurat /** * Configuration to attach to VS Code integrated browsers. */ -export interface ICodeBrowserAttachConfiguration extends IChromiumBaseConfiguration { - type: DebugType.CodeBrowser; +export interface IEditorBrowserAttachConfiguration extends IChromiumBaseConfiguration { + type: DebugType.EditorBrowser; request: 'attach'; } @@ -794,16 +794,16 @@ export type AnyNodeConfiguration = | ITerminalDelegateConfiguration; export type AnyChromeConfiguration = IChromeAttachConfiguration | IChromeLaunchConfiguration; export type AnyEdgeConfiguration = IEdgeAttachConfiguration | IEdgeLaunchConfiguration; -export type AnyCodeBrowserConfiguration = - | ICodeBrowserAttachConfiguration - | ICodeBrowserLaunchConfiguration; +export type AnyEditorBrowserConfiguration = + | IEditorBrowserAttachConfiguration + | IEditorBrowserLaunchConfiguration; export type AnyChromiumLaunchConfiguration = IEdgeLaunchConfiguration | IChromeLaunchConfiguration; export type AnyChromiumAttachConfiguration = IEdgeAttachConfiguration | IChromeAttachConfiguration; export type AnyChromiumConfiguration = AnyEdgeConfiguration | AnyChromeConfiguration; export type AnyLaunchConfiguration = | AnyChromiumConfiguration | AnyNodeConfiguration - | AnyCodeBrowserConfiguration; + | AnyEditorBrowserConfiguration; export type AnyTerminalConfiguration = | ITerminalDelegateConfiguration | ITerminalLaunchConfiguration; @@ -832,7 +832,9 @@ export type ResolvingNodeConfiguration = | ResolvingNodeLaunchConfiguration; export type ResolvingChromeConfiguration = ResolvingConfiguration; export type ResolvingEdgeConfiguration = ResolvingConfiguration; -export type ResolvingCodeBrowserConfiguration = ResolvingConfiguration; +export type ResolvingEditorBrowserConfiguration = ResolvingConfiguration< + AnyEditorBrowserConfiguration +>; export type AnyResolvingConfiguration = | ResolvingExtensionHostConfiguration | ResolvingChromeConfiguration @@ -840,7 +842,7 @@ export type AnyResolvingConfiguration = | ResolvingNodeLaunchConfiguration | ResolvingTerminalConfiguration | ResolvingEdgeConfiguration - | ResolvingCodeBrowserConfiguration; + | ResolvingEditorBrowserConfiguration; export const AnyLaunchConfiguration = Symbol('AnyLaunchConfiguration'); @@ -1002,7 +1004,7 @@ export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = { useWebView: false, }; -const codeBrowserBaseDefaults: IChromiumBaseConfiguration = { +const editorBrowserBaseDefaults: IChromiumBaseConfiguration = { ...baseDefaults, disableNetworkCache: true, pathMapping: {}, @@ -1015,15 +1017,15 @@ const codeBrowserBaseDefaults: IChromiumBaseConfiguration = { perScriptSourcemaps: 'auto', }; -export const codeBrowserAttachConfigDefaults: ICodeBrowserAttachConfiguration = { - ...codeBrowserBaseDefaults, - type: DebugType.CodeBrowser, +export const editorBrowserAttachConfigDefaults: IEditorBrowserAttachConfiguration = { + ...editorBrowserBaseDefaults, + type: DebugType.EditorBrowser, request: 'attach', }; -export const codeBrowserLaunchConfigDefaults: ICodeBrowserLaunchConfiguration = { - ...codeBrowserBaseDefaults, - type: DebugType.CodeBrowser, +export const editorBrowserLaunchConfigDefaults: IEditorBrowserLaunchConfiguration = { + ...editorBrowserBaseDefaults, + type: DebugType.EditorBrowser, request: 'launch', url: 'http://localhost:8080', }; @@ -1098,12 +1100,12 @@ export function applyEdgeDefaults( : { ...edgeLaunchConfigDefaults, browserLaunchLocation: browserLocation, ...config }; } -export function applyCodeBrowserDefaults( - config: ResolvingCodeBrowserConfiguration, -): AnyCodeBrowserConfiguration { +export function applyEditorBrowserDefaults( + config: ResolvingEditorBrowserConfiguration, +): AnyEditorBrowserConfiguration { return config.request === 'attach' - ? { ...codeBrowserAttachConfigDefaults, ...config } - : { ...codeBrowserLaunchConfigDefaults, ...config }; + ? { ...editorBrowserAttachConfigDefaults, ...config } + : { ...editorBrowserLaunchConfigDefaults, ...config }; } export function applyExtensionHostDefaults( @@ -1148,8 +1150,8 @@ export function applyDefaults( case DebugType.Terminal: configWithDefaults = applyTerminalDefaults(config); break; - case DebugType.CodeBrowser: - configWithDefaults = applyCodeBrowserDefaults(config); + case DebugType.EditorBrowser: + configWithDefaults = applyEditorBrowserDefaults(config); break; default: throw assertNever(config, 'Unknown config: {value}'); diff --git a/src/ioc.ts b/src/ioc.ts index 7628ba9c1..12da62962 100644 --- a/src/ioc.ts +++ b/src/ioc.ts @@ -94,8 +94,6 @@ import { } from './ioc-extras'; import { BrowserAttacher } from './targets/browser/browserAttacher'; import { ChromeLauncher } from './targets/browser/chromeLauncher'; -import { CodeBrowserAttacher } from './targets/browser/codeBrowserAttacher'; -import { CodeBrowserLauncher } from './targets/browser/codeBrowserLauncher'; import { EdgeLauncher } from './targets/browser/edgeLauncher'; import { RemoteBrowserAttacher } from './targets/browser/remoteBrowserAttacher'; import { RemoteBrowserHelper } from './targets/browser/remoteBrowserHelper'; @@ -285,8 +283,6 @@ export const createTopLevelSessionContainer = (parent: Container) => { .onActivation(trackDispose); container.bind(ILauncher).to(BrowserAttacher).onActivation(trackDispose); - container.bind(ILauncher).to(CodeBrowserLauncher).onActivation(trackDispose); - container.bind(ILauncher).to(CodeBrowserAttacher).onActivation(trackDispose); container .bind(ILauncher) .toDynamicValue(() => diff --git a/src/targets/browser/browserLaunchParams.ts b/src/targets/browser/browserLaunchParams.ts index 49d4f9e51..2630bc91f 100644 --- a/src/targets/browser/browserLaunchParams.ts +++ b/src/targets/browser/browserLaunchParams.ts @@ -4,10 +4,10 @@ import { URL } from 'url'; import { absolutePathToFileUrlWithDetection } from '../../common/urlUtils'; -import { AnyChromiumConfiguration, AnyCodeBrowserConfiguration } from '../../configuration'; +import { AnyChromiumConfiguration, AnyEditorBrowserConfiguration } from '../../configuration'; export function baseURL( - params: AnyChromiumConfiguration | AnyCodeBrowserConfiguration, + params: AnyChromiumConfiguration | AnyEditorBrowserConfiguration, ): string | undefined { if ('file' in params && params.file) { return absolutePathToFileUrlWithDetection(params.file); diff --git a/src/targets/browser/codeBrowserAttacher.ts b/src/targets/browser/editorBrowserAttacher.ts similarity index 96% rename from src/targets/browser/codeBrowserAttacher.ts rename to src/targets/browser/editorBrowserAttacher.ts index b2065ba64..8de19987f 100644 --- a/src/targets/browser/codeBrowserAttacher.ts +++ b/src/targets/browser/editorBrowserAttacher.ts @@ -16,10 +16,10 @@ import { ProtocolError } from '../../dap/protocolError'; import { VSCodeApi } from '../../ioc-extras'; import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets'; import { BrowserTargetManager } from './browserTargetManager'; -import { CodeBrowserSessionTransport } from './codeBrowserSessionTransport'; +import { EditorBrowserSessionTransport } from './editorBrowserSessionTransport'; @injectable() -export class CodeBrowserAttacher implements ILauncher { +export class EditorBrowserAttacher implements ILauncher { private _targetManager: BrowserTargetManager | undefined; private _onTerminatedEmitter = new EventEmitter(); readonly onTerminated = this._onTerminatedEmitter.event; @@ -43,7 +43,7 @@ export class CodeBrowserAttacher implements ILauncher { params: AnyLaunchConfiguration, context: ILaunchContext, ): Promise { - if (params.type !== DebugType.CodeBrowser || params.request !== 'attach') { + if (params.type !== DebugType.EditorBrowser || params.request !== 'attach') { return { blockSessionTermination: false }; } @@ -176,7 +176,7 @@ export class CodeBrowserAttacher implements ILauncher { const session = await tab.startCDPSession(); - const transport = new CodeBrowserSessionTransport(session); + const transport = new EditorBrowserSessionTransport(session); const connection = new Connection(transport, this.logger, context.telemetryReporter); connection.onDisconnected(() => { diff --git a/src/targets/browser/codeBrowserLauncher.ts b/src/targets/browser/editorBrowserLauncher.ts similarity index 91% rename from src/targets/browser/codeBrowserLauncher.ts rename to src/targets/browser/editorBrowserLauncher.ts index cd0d9fa04..08f36e009 100644 --- a/src/targets/browser/codeBrowserLauncher.ts +++ b/src/targets/browser/editorBrowserLauncher.ts @@ -13,17 +13,17 @@ import { ISourcePathResolver } from '../../common/sourcePathResolver'; import { AnyChromiumConfiguration, AnyLaunchConfiguration, - ICodeBrowserLaunchConfiguration, + IEditorBrowserLaunchConfiguration, } from '../../configuration'; import { browserLaunchFailed } from '../../dap/errors'; import { ProtocolError } from '../../dap/protocolError'; import { VSCodeApi } from '../../ioc-extras'; import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets'; import { BrowserTargetManager } from './browserTargetManager'; -import { CodeBrowserSessionTransport } from './codeBrowserSessionTransport'; +import { EditorBrowserSessionTransport } from './editorBrowserSessionTransport'; @injectable() -export class CodeBrowserLauncher implements ILauncher { +export class EditorBrowserLauncher implements ILauncher { private _targetManager: BrowserTargetManager | undefined; private _onTerminatedEmitter = new EventEmitter(); readonly onTerminated = this._onTerminatedEmitter.event; @@ -47,7 +47,7 @@ export class CodeBrowserLauncher implements ILauncher { params: AnyLaunchConfiguration, context: ILaunchContext, ): Promise { - if (params.type !== DebugType.CodeBrowser || params.request !== 'launch') { + if (params.type !== DebugType.EditorBrowser || params.request !== 'launch') { return { blockSessionTermination: false }; } @@ -65,7 +65,7 @@ export class CodeBrowserLauncher implements ILauncher { ); } - const launchParams = params as ICodeBrowserLaunchConfiguration; + const launchParams = params as IEditorBrowserLaunchConfiguration; const url = launchParams.url; if (!url) { throw new ProtocolError( @@ -78,7 +78,7 @@ export class CodeBrowserLauncher implements ILauncher { const tab = await vscodeWindow.openBrowserTab(url); const session = await tab.startCDPSession(); - const transport = new CodeBrowserSessionTransport(session); + const transport = new EditorBrowserSessionTransport(session); const connection = new Connection(transport, this.logger, context.telemetryReporter); connection.onDisconnected(() => { diff --git a/src/targets/browser/codeBrowserSessionTransport.ts b/src/targets/browser/editorBrowserSessionTransport.ts similarity index 94% rename from src/targets/browser/codeBrowserSessionTransport.ts rename to src/targets/browser/editorBrowserSessionTransport.ts index 616c8e451..9e2993fb6 100644 --- a/src/targets/browser/codeBrowserSessionTransport.ts +++ b/src/targets/browser/editorBrowserSessionTransport.ts @@ -10,7 +10,7 @@ import { HrTime } from '../../common/hrnow'; /** * A CDP transport that wraps a VS Code BrowserCDPSession. */ -export class CodeBrowserSessionTransport implements ITransport { +export class EditorBrowserSessionTransport implements ITransport { private readonly messageEmitter = new EventEmitter<[string, HrTime]>(); private readonly endEmitter = new EventEmitter(); diff --git a/src/test/extension/codeBrowserConfigurationProvider.test.ts b/src/test/extension/editorBrowserConfigurationProvider.test.ts similarity index 72% rename from src/test/extension/codeBrowserConfigurationProvider.test.ts rename to src/test/extension/editorBrowserConfigurationProvider.test.ts index 9856d9e8a..25af8b5ad 100644 --- a/src/test/extension/codeBrowserConfigurationProvider.test.ts +++ b/src/test/extension/editorBrowserConfigurationProvider.test.ts @@ -7,15 +7,15 @@ import * as vscode from 'vscode'; import { DebugType } from '../../common/contributionUtils'; import { upcastPartial } from '../../common/objUtils'; import { - codeBrowserAttachConfigDefaults, - codeBrowserLaunchConfigDefaults, + editorBrowserAttachConfigDefaults, + editorBrowserLaunchConfigDefaults, } from '../../configuration'; -import { CodeBrowserDebugConfigurationResolver } from '../../ui/configuration/codeBrowserDebugConfigurationProvider'; +import { EditorBrowserDebugConfigurationResolver } from '../../ui/configuration/editorBrowserDebugConfigurationProvider'; import { testFixturesDir } from '../test'; import { TestMemento } from '../testMemento'; -describe('CodeBrowserDebugConfigurationProvider', () => { - let provider: CodeBrowserDebugConfigurationResolver; +describe('EditorBrowserDebugConfigurationProvider', () => { + let provider: EditorBrowserDebugConfigurationResolver; const folder: vscode.WorkspaceFolder = { uri: vscode.Uri.file(testFixturesDir), name: 'test-dir', @@ -23,7 +23,7 @@ describe('CodeBrowserDebugConfigurationProvider', () => { }; beforeEach(() => { - provider = new CodeBrowserDebugConfigurationResolver( + provider = new EditorBrowserDebugConfigurationResolver( upcastPartial({ logPath: testFixturesDir, workspaceState: new TestMemento(), @@ -43,24 +43,24 @@ describe('CodeBrowserDebugConfigurationProvider', () => { it('applies launch defaults', async () => { const result = await provider.resolveDebugConfiguration(folder, { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, name: 'test', request: 'launch', url: 'http://localhost:3000', }); expect(result).to.containSubset({ - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'launch', url: 'http://localhost:3000', - webRoot: codeBrowserLaunchConfigDefaults.webRoot, - disableNetworkCache: codeBrowserLaunchConfigDefaults.disableNetworkCache, + webRoot: editorBrowserLaunchConfigDefaults.webRoot, + disableNetworkCache: editorBrowserLaunchConfigDefaults.disableNetworkCache, }); }); it('user config overrides defaults', async () => { const result = await provider.resolveDebugConfiguration(folder, { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, name: 'test', request: 'launch', url: 'http://localhost:9000', @@ -77,29 +77,29 @@ describe('CodeBrowserDebugConfigurationProvider', () => { describe('attach config', () => { it('applies attach defaults', async () => { const result = await provider.resolveDebugConfiguration(folder, { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, name: 'test', request: 'attach', }); expect(result).to.containSubset({ - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'attach', - webRoot: codeBrowserAttachConfigDefaults.webRoot, - disableNetworkCache: codeBrowserAttachConfigDefaults.disableNetworkCache, + webRoot: editorBrowserAttachConfigDefaults.webRoot, + disableNetworkCache: editorBrowserAttachConfigDefaults.disableNetworkCache, }); }); it('user config overrides attach defaults', async () => { const result = await provider.resolveDebugConfiguration(folder, { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, name: 'test', request: 'attach', webRoot: '/my/root', }); expect(result).to.containSubset({ - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'attach', webRoot: '/my/root', }); diff --git a/src/test/extension/codeBrowserIntegration.test.ts b/src/test/extension/editorBrowserIntegration.test.ts similarity index 98% rename from src/test/extension/codeBrowserIntegration.test.ts rename to src/test/extension/editorBrowserIntegration.test.ts index f25ada420..164fea391 100644 --- a/src/test/extension/codeBrowserIntegration.test.ts +++ b/src/test/extension/editorBrowserIntegration.test.ts @@ -62,7 +62,7 @@ describe('integrated browser debugging', function() { const sessionStarted = waitForChildSession(); await vscode.debug.startDebugging(undefined, { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'launch', name: 'Launch Test', url: serverUrl, @@ -119,7 +119,7 @@ describe('integrated browser debugging', function() { const sessionStarted = waitForChildSession(); await vscode.debug.startDebugging(undefined, { - type: DebugType.CodeBrowser, + type: DebugType.EditorBrowser, request: 'attach', name: 'Attach Test', }); diff --git a/src/ui/configuration/codeBrowserDebugConfigurationProvider.ts b/src/ui/configuration/editorBrowserDebugConfigurationProvider.ts similarity index 63% rename from src/ui/configuration/codeBrowserDebugConfigurationProvider.ts rename to src/ui/configuration/editorBrowserDebugConfigurationProvider.ts index 1054a3fb7..cb68166d8 100644 --- a/src/ui/configuration/codeBrowserDebugConfigurationProvider.ts +++ b/src/ui/configuration/editorBrowserDebugConfigurationProvider.ts @@ -6,10 +6,10 @@ import { injectable } from 'inversify'; import * as vscode from 'vscode'; import { DebugType } from '../../common/contributionUtils'; import { - AnyCodeBrowserConfiguration, - codeBrowserAttachConfigDefaults, - codeBrowserLaunchConfigDefaults, - ResolvingCodeBrowserConfiguration, + AnyEditorBrowserConfiguration, + editorBrowserAttachConfigDefaults, + editorBrowserLaunchConfigDefaults, + ResolvingEditorBrowserConfiguration, } from '../../configuration'; import { BaseConfigurationResolver } from './baseConfigurationResolver'; @@ -18,14 +18,14 @@ import { BaseConfigurationResolver } from './baseConfigurationResolver'; * Only available on desktop. */ @injectable() -export class CodeBrowserDebugConfigurationResolver - extends BaseConfigurationResolver +export class EditorBrowserDebugConfigurationResolver + extends BaseConfigurationResolver implements vscode.DebugConfigurationProvider { protected async resolveDebugConfigurationAsync( _folder: vscode.WorkspaceFolder | undefined, - config: ResolvingCodeBrowserConfiguration, - ): Promise { + config: ResolvingEditorBrowserConfiguration, + ): Promise { if (vscode.env.uiKind === vscode.UIKind.Web) { vscode.window.showErrorMessage( 'Integrated Browser debugging is only available on VS Code Desktop.', @@ -38,15 +38,15 @@ export class CodeBrowserDebugConfigurationResolver } return config.request === 'attach' - ? { ...codeBrowserAttachConfigDefaults, ...config } - : { ...codeBrowserLaunchConfigDefaults, ...config }; + ? { ...editorBrowserAttachConfigDefaults, ...config } + : { ...editorBrowserLaunchConfigDefaults, ...config }; } protected getType() { - return DebugType.CodeBrowser as const; + return DebugType.EditorBrowser as const; } - protected getSuggestedWorkspaceFolders(config: AnyCodeBrowserConfiguration) { + protected getSuggestedWorkspaceFolders(config: AnyEditorBrowserConfiguration) { return [config.rootPath, config.webRoot]; } } diff --git a/src/ui/configuration/index.ts b/src/ui/configuration/index.ts index f9b84114c..a4e4a3bd5 100644 --- a/src/ui/configuration/index.ts +++ b/src/ui/configuration/index.ts @@ -7,11 +7,11 @@ import { ChromeDebugConfigurationProvider, ChromeDebugConfigurationResolver, } from './chromeDebugConfigurationProvider'; -import { CodeBrowserDebugConfigurationResolver } from './codeBrowserDebugConfigurationProvider'; import { EdgeDebugConfigurationProvider, EdgeDebugConfigurationResolver, } from './edgeDebugConfigurationProvider'; +import { EditorBrowserDebugConfigurationResolver } from './editorBrowserDebugConfigurationProvider'; import { ExtensionHostConfigurationResolver } from './extensionHostConfigurationResolver'; import { NodeDynamicDebugConfigurationProvider, @@ -23,7 +23,7 @@ import { TerminalDebugConfigurationResolver } from './terminalDebugConfiguration export const allConfigurationResolvers = [ ChromeDebugConfigurationResolver, EdgeDebugConfigurationResolver, - CodeBrowserDebugConfigurationResolver, + EditorBrowserDebugConfigurationResolver, ExtensionHostConfigurationResolver, NodeConfigurationResolver, TerminalDebugConfigurationResolver, diff --git a/src/ui/ui-ioc.extensionOnly.ts b/src/ui/ui-ioc.extensionOnly.ts index f432e2bb8..2ebfb9160 100644 --- a/src/ui/ui-ioc.extensionOnly.ts +++ b/src/ui/ui-ioc.extensionOnly.ts @@ -12,6 +12,8 @@ import { trackDispose, VSCodeApi, } from '../ioc-extras'; +import { EditorBrowserAttacher } from '../targets/browser/editorBrowserAttacher'; +import { EditorBrowserLauncher } from '../targets/browser/editorBrowserLauncher'; import { TerminalNodeLauncher } from '../targets/node/terminalNodeLauncher'; import { ILauncher } from '../targets/targets'; import { IExperimentationService } from '../telemetry/experimentationService'; @@ -110,6 +112,8 @@ export const registerUiComponents = (container: Container) => { export const registerTopLevelSessionComponents = (container: Container) => { container.bind(ILauncher).to(TerminalNodeLauncher).onActivation(trackDispose); + container.bind(ILauncher).to(EditorBrowserLauncher).onActivation(trackDispose); + container.bind(ILauncher).to(EditorBrowserAttacher).onActivation(trackDispose); // request options: container.bind(IRequestOptionsProvider).to(SettingRequestOptionsProvider).inSingletonScope(); From de4f89cfd974a19e9f3d0f6f5fc78f1e399f97a2 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Thu, 12 Mar 2026 17:26:14 -0700 Subject: [PATCH 3/3] url filters --- src/targets/browser/editorBrowserAttacher.ts | 32 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/targets/browser/editorBrowserAttacher.ts b/src/targets/browser/editorBrowserAttacher.ts index 8de19987f..633f78f9e 100644 --- a/src/targets/browser/editorBrowserAttacher.ts +++ b/src/targets/browser/editorBrowserAttacher.ts @@ -10,7 +10,12 @@ import { DebugType } from '../../common/contributionUtils'; import { EventEmitter } from '../../common/events'; import { ILogger } from '../../common/logging'; import { ISourcePathResolver } from '../../common/sourcePathResolver'; -import { AnyChromiumConfiguration, AnyLaunchConfiguration } from '../../configuration'; +import { createTargetFilter } from '../../common/urlUtils'; +import { + AnyChromiumConfiguration, + AnyLaunchConfiguration, + IEditorBrowserAttachConfiguration, +} from '../../configuration'; import { browserAttachFailed } from '../../dap/errors'; import { ProtocolError } from '../../dap/protocolError'; import { VSCodeApi } from '../../ioc-extras'; @@ -61,15 +66,27 @@ export class EditorBrowserAttacher implements ILauncher { ); } + const attachParams = params as IEditorBrowserAttachConfiguration; + const urlFilter = attachParams.urlFilter; + + if (urlFilter) { + const matchesUrl = createTargetFilter(urlFilter); + const matchingTabs = browserTabs.filter(tab => matchesUrl(tab.url)); + if (matchingTabs.length === 1) { + return this.attachToTab(matchingTabs[0], params, context); + } + } + type BrowserPickItem = vscodeType.QuickPickItem & { tab?: vscodeType.BrowserTab; openNew?: true; }; const buildItems = (): BrowserPickItem[] => { + const matchesUrl = urlFilter ? createTargetFilter(urlFilter) : undefined; const activeBrowserTab = vscodeWindow.activeBrowserTab; const items: BrowserPickItem[] = []; - if (activeBrowserTab) { + if (activeBrowserTab && (!matchesUrl || matchesUrl(activeBrowserTab.url))) { items.push({ label: l10n.t('Active'), kind: vscode.QuickPickItemKind.Separator }); items.push({ label: activeBrowserTab.title, @@ -79,7 +96,8 @@ export class EditorBrowserAttacher implements ILauncher { }); } - const otherTabs = (vscodeWindow.browserTabs ?? []).filter(tab => tab !== activeBrowserTab); + const otherTabs = (vscodeWindow.browserTabs ?? []) + .filter(tab => tab !== activeBrowserTab && (!matchesUrl || matchesUrl(tab.url))); if (otherTabs.length > 0) { items.push({ label: l10n.t('Other'), kind: vscode.QuickPickItemKind.Separator }); for (const tab of otherTabs) { @@ -174,6 +192,14 @@ export class EditorBrowserAttacher implements ILauncher { tab = selected.tab!; } + return this.attachToTab(tab, params, context); + } + + private async attachToTab( + tab: vscodeType.BrowserTab, + params: AnyLaunchConfiguration, + context: ILaunchContext, + ): Promise { const session = await tab.startCDPSession(); const transport = new EditorBrowserSessionTransport(session);