Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions OPTIONS.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"browser.profileStartup.description": "If true, will start profiling soon as the process launches",
"browser.restart": "Whether to reconnect if the browser connection is closed",
"browser.revealPage": "Focus Tab",
"browser.ephemeralUserDataDir.description": "When true, the profile directory given by (or created for) `userDataDir` is deleted before the browser launches, so every debug session starts from a clean browser state.",
"browser.extensionPath.description": "Absolute path to an unpacked browser extension directory (containing manifest.json) to load and debug. The debugger will attach to the extension's background script or service worker.",
"browser.runtimeArgs.description": "Optional arguments passed to the runtime executable.",
"browser.runtimeExecutable.description": "Either 'canary', 'stable', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or CHROME_PATH environment variable.",
"browser.runtimeExecutable.edge.description": "Either 'canary', 'stable', 'dev', 'custom' or path to the browser executable. Custom means a custom wrapper, custom build or EDGE_PATH environment variable.",
Expand All @@ -47,6 +49,8 @@
"chrome.label": "Web App (Chrome)",
"chrome.launch.description": "Launch Chrome to debug a URL",
"chrome.launch.label": "Chrome: Launch",
"chrome.launch.extension.description": "Launch Chrome to debug an unpacked browser extension",
"chrome.launch.extension.label": "Chrome: Launch Extension",
"editorBrowser.attach.description": "Attach to an open VS Code integrated browser",
"editorBrowser.attach.label": "Integrated Browser: Attach",
"editorBrowser.label": "Web App (Integrated Browser)",
Expand Down
13 changes: 13 additions & 0 deletions src/adapter/resourceProvider/basicResourceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ export class BasicResourceProvider implements IResourceProvider {
): Promise<Response<string>> {
const parsed = new URL(url);

// Schemes the HTTP client can't speak (chrome-extension:, webpack:, ...)
// would make it throw an opaque "Unsupported protocol" error. Return a
// failed response instead, so subclasses can fall back to loading the
// resource through the browser, which does understand them.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
ok: false,
url,
statusCode: 0,
error: new Error(`Cannot fetch ${parsed.protocol} URLs over HTTP`),
};
}

const isSecure = parsed.protocol !== 'http:';
const port = Number(parsed.port) ?? (isSecure ? 443 : 80);
const options: OptionsOfTextResponseBody = { headers, followRedirect: true };
Expand Down
17 changes: 13 additions & 4 deletions src/adapter/sourceContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,10 +809,19 @@ export class SourceContainer {
return; // already removed
}

this.logger.assert(
source === existing,
'Expected source to be the same as the existing reference',
);
if (existing !== source) {
// The reference was reassigned. If the replacement source has the same
// URL, this is an expected lifecycle event (e.g. an extension service
// worker restarted and re-registered the same URL under the freed
// reference id) — silently ignore. Any other mismatch is unexpected
// and still flagged so genuine state bugs surface.
this.logger.assert(
existing.url === source.url,
'Expected source to be the same as the existing reference',
);
return;
}

this._sourceByReference.delete(source.sourceReference);

// check for overwrites:
Expand Down
21 changes: 21 additions & 0 deletions src/build/generate-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,11 @@ const chromiumBaseConfigurationAttributes: ConfigurationAttributes<IChromiumBase
enum: ['yes', 'no', 'auto'],
description: refString('browser.perScriptSourcemaps.description'),
},
extensionPath: {
type: ['string', 'null'],
description: refString('browser.extensionPath.description'),
default: null,
},
};

/**
Expand Down Expand Up @@ -854,6 +859,17 @@ const chromeLaunchConfig: IDebugger<IChromeLaunchConfiguration> = {
webRoot: '^"${2:\\${workspaceFolder\\}}"',
},
},
{
label: refString('chrome.launch.extension.label'),
description: refString('chrome.launch.extension.description'),
body: {
type: DebugType.Chrome,
request: 'launch',
name: 'Launch Chrome Extension',
extensionPath: '^"${1:\\${workspaceFolder\\}}"',
webRoot: '^"${2:\\${workspaceFolder\\}}"',
},
},
],
configurationAttributes: {
...chromiumBaseConfigurationAttributes,
Expand All @@ -873,6 +889,11 @@ const chromeLaunchConfig: IDebugger<IChromeLaunchConfiguration> = {
description: refString('browser.userDataDir.description'),
default: true,
},
ephemeralUserDataDir: {
type: 'boolean',
description: refString('browser.ephemeralUserDataDir.description'),
default: false,
},
includeDefaultArgs: {
type: 'boolean',
description: refString('browser.includeDefaultArgs.description'),
Expand Down
17 changes: 16 additions & 1 deletion src/cdp/connection.ts
Comment thread
noorez marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,23 @@ export default class Connection {
if (!session) {
const disposedDate = this._disposedSessions.get(object.sessionId);
if (!disposedDate) {
if (object.method) {
// Event (not a response) on an unregistered session. This can happen
// when Chrome pushes events (e.g. Inspector.workerScriptLoaded) on a
// new session before our createSession() call completes — a race
// between Target.attachToTarget's response and early CDP events.
// Safe to drop: events are fire-and-forget with no waiting caller.
this.logger.warn(
LogTag.Internal,
`Got event for unknown session, ignoring`,
{ sessionId: object.sessionId, method: object.method },
);
return;
}
// A command response on an unregistered session is a real bug — we
// only send commands after createSession(), so this should never happen.
throw new Error(
`Unknown session id: ${object.sessionId} while processing: ${object.method}`,
`Unknown session id: ${object.sessionId} while processing response`,
);
} else {
const secondsAgo = (Date.now() - disposedDate.getTime()) / 1000.0;
Expand Down
19 changes: 19 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,14 @@ export interface INodeLaunchConfiguration extends INodeBaseConfiguration, IConfi
export type PathMapping = Readonly<{ [key: string]: string }>;

export interface IChromiumBaseConfiguration extends IBaseConfiguration {
/**
* Absolute path to the root directory of an unpacked browser extension to
* load and debug. When set, the debugger automatically passes
* --load-extension to Chrome/Edge (launch only) and attaches to the
* extension's background script or service worker rather than a web page.
*/
extensionPath: string | null;

/**
* Controls whether to skip the network cache for each request.
*/
Expand Down Expand Up @@ -631,6 +639,13 @@ export interface IChromiumLaunchConfiguration extends IChromiumBaseConfiguration
*/
userDataDir: string | boolean;

/**
* When true, the profile directory given by (or created for) userDataDir is
* deleted before the browser launches, so every debug session starts from a
* clean browser state.
*/
ephemeralUserDataDir: boolean;

/**
* The debug adapter is running elevated. Launch Chrome unelevated to avoid the security restrictions of running Chrome elevated
*/
Expand Down Expand Up @@ -959,6 +974,7 @@ export const chromeAttachConfigDefaults: IChromeAttachConfiguration = {
request: 'attach',
address: 'localhost',
port: 0,
extensionPath: null,
disableNetworkCache: true,
pathMapping: {},
url: null,
Expand Down Expand Up @@ -992,10 +1008,12 @@ export const chromeLaunchConfigDefaults: IChromeLaunchConfiguration = {
runtimeArgs: null,
runtimeExecutable: '*',
userDataDir: true,
ephemeralUserDataDir: false,
browserLaunchLocation: 'workspace',
profileStartup: false,
cleanUp: 'wholeBrowser',
killBehavior: KillBehavior.Forceful,
extensionPath: null,
};

export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {
Expand All @@ -1006,6 +1024,7 @@ export const edgeLaunchConfigDefaults: IEdgeLaunchConfiguration = {

const editorBrowserBaseDefaults: IChromiumBaseConfiguration = {
...baseDefaults,
extensionPath: null,
disableNetworkCache: true,
pathMapping: {},
url: null,
Expand Down
34 changes: 31 additions & 3 deletions src/targets/browser/browserAttacher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import {
import { AnyChromiumAttachConfiguration, AnyLaunchConfiguration } from '../../configuration';
import { browserAttachFailed, targetPageNotFound } from '../../dap/errors';
import { ProtocolError } from '../../dap/protocolError';
import { VSCodeApi } from '../../ioc-extras';
import { FS, FsPromises, VSCodeApi } from '../../ioc-extras';
import { ITelemetryReporter } from '../../telemetry/telemetryReporter';
import { ILaunchContext, ILauncher, ILaunchResult, IStopMetadata, ITarget } from '../targets';
import { BrowserTargetManager } from './browserTargetManager';
import { BrowserTargetType } from './browserTargets';
import { createExtensionTargetFilter, resolveExpectedExtensionId } from './extensionId';
import * as launcher from './launcher';

@injectable()
Expand All @@ -46,6 +47,7 @@ export class BrowserAttacher<
constructor(
@inject(ILogger) protected readonly logger: ILogger,
@inject(ISourcePathResolver) private readonly pathResolver: ISourcePathResolver,
@inject(FS) private readonly fs: FsPromises,
@optional() @inject(VSCodeApi) private readonly vscode?: typeof vscodeType,
) {}

Expand Down Expand Up @@ -158,15 +160,33 @@ export class BrowserAttacher<
});
targetManager.onTargetRemoved(() => {
this._onTargetListChangedEmitter.fire();
if (!targetManager.targetList().length && this.closeWhenTargetsEmpty) {
// For extension debugging an empty target list is a normal, transient
// state (idle worker, closed popup); stay attached and wait for the
// next target instead of ending the session.
if (
!targetManager.targetList().length && this.closeWhenTargetsEmpty && !params.extensionPath
) {
// graceful exit
this._onTerminatedEmitter.fire({ killed: true, code: 0 });
this._connection?.close();
}
});

const filter = await this.getTargetFilter(targetManager, params);
const waitForTarget = targetManager.waitForMainTarget(filter);

// An idle extension service worker has no target to attach to, so ask the
// browser to start it. Done after waitForMainTarget has subscribed so we
// don't miss the target it creates.
if (params.extensionPath) {
const extensionId = await resolveExpectedExtensionId(params.extensionPath, this.fs);
if (extensionId) {
await targetManager.wakeExtensionServiceWorker(extensionId);
}
}

const result = await Promise.race([
targetManager.waitForMainTarget(await this.getTargetFilter(targetManager, params)),
waitForTarget,
delay(params.timeout).then(() => {
throw new ProtocolError(targetPageNotFound());
}),
Expand All @@ -182,6 +202,14 @@ export class BrowserAttacher<
manager: BrowserTargetManager,
params: AnyChromiumAttachConfiguration,
): Promise<TargetFilter> {
if (params.extensionPath) {
return createExtensionTargetFilter(
params.extensionPath,
await resolveExpectedExtensionId(params.extensionPath, this.fs),
this.logger,
);
}

const rawFilter = createTargetFilterForConfig(params);
const baseFilter = requirePageTarget(rawFilter);
if (params.targetSelection !== 'pick') {
Expand Down
Loading