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
2 changes: 2 additions & 0 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import { AspireDebugSession } from '../debugger/AspireDebugSession';
import { ServerReadyAction } from '../debugger/launchProfiles';

export interface ErrorResponse {
error: ErrorDetails;
Expand Down Expand Up @@ -110,6 +111,7 @@ export interface AspireResourceExtendedDebugConfiguration extends vscode.DebugCo
runId: string;
debugSessionId: string | null;
projectFile?: string;
serverReadyAction?: ServerReadyAction;
}

export interface AspireExtendedDebugConfiguration extends vscode.DebugConfiguration {
Expand Down
2 changes: 1 addition & 1 deletion extension/src/debugger/languages/dotnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess
// The apphost's application URL is the Aspire dashboard URL. We already get the dashboard login URL later on,
// so we should just avoid setting up serverReadyAction and manually open the browser ourselves.
if (!launchOptions.isApphost) {
debugConfiguration.serverReadyAction = determineServerReadyAction(baseProfile?.launchBrowser, baseProfile?.applicationUrl);
debugConfiguration.serverReadyAction = determineServerReadyAction(baseProfile?.launchBrowser, baseProfile?.applicationUrl, debugConfiguration.serverReadyAction);
}

// Temporarily disable GH Copilot on the dashboard before the extension implementation is approved
Expand Down
39 changes: 33 additions & 6 deletions extension/src/debugger/launchProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,14 @@ export async function readLaunchSettings(projectPath: string): Promise<LaunchSet
launchSettingsPath = path.join(projectDir, 'Properties', 'launchSettings.json');
}

if (!fs.existsSync(launchSettingsPath)) {
const launchSettingsExists = fs.existsSync(launchSettingsPath);
extensionLogOutputChannel.debug('[launchSettings] Resolved launchSettings path', {
projectPath,
resolvedPath: launchSettingsPath,
exists: launchSettingsExists,
});

if (!launchSettingsExists) {
extensionLogOutputChannel.debug(`Launch settings file not found at: ${launchSettingsPath}`);
return null;
}
Expand All @@ -60,6 +67,9 @@ export async function readLaunchSettings(projectPath: string): Promise<LaunchSet
content = stripComments(content);
const launchSettings = JSON.parse(content) as LaunchSettings;

const profileNames = launchSettings?.profiles ? Object.keys(launchSettings.profiles) : [];
extensionLogOutputChannel.debug(`[launchSettings] parsed ${profileNames.length} profiles: ${profileNames.join(', ')}`);

extensionLogOutputChannel.debug(`Successfully read launch settings from: ${launchSettingsPath}`);
return launchSettings;
} catch (error) {
Expand All @@ -75,6 +85,14 @@ export function determineBaseLaunchProfile(
launchConfig: ProjectLaunchConfiguration,
launchSettings: LaunchSettings | null
): LaunchProfileResult {
const debugMessage =
`[launchProfile] determineBaseLaunchProfile:
disable_launch_profile=${!!launchConfig.disable_launch_profile}
launch_profile='${launchConfig.launch_profile ?? ''}'
hasLaunchSettings=${!!launchSettings}
profileCount=${launchSettings?.profiles ? Object.keys(launchSettings.profiles).length : 0}`;
extensionLogOutputChannel.debug(debugMessage);

// If disable_launch_profile property is set to true in project launch configuration, there is no base profile, regardless of the value of launch_profile property.
if (launchConfig.disable_launch_profile === true) {
extensionLogOutputChannel.debug('Launch profile disabled via disable_launch_profile=true');
Expand Down Expand Up @@ -195,22 +213,31 @@ export function determineWorkingDirectory(
return projectDir;
}

interface ServerReadyAction {
export interface ServerReadyAction {
action: "openExternally";
pattern: "\\bNow listening on:\\s+https?://\\S+";
pattern: string;
uriFormat: string;
}

export function determineServerReadyAction(launchBrowser?: boolean, applicationUrl?: string): ServerReadyAction | undefined {
if (!launchBrowser || !applicationUrl) {
export function determineServerReadyAction(launchBrowser?: boolean, applicationUrl?: string, debugConfigurationServerReadyAction?: ServerReadyAction): ServerReadyAction | undefined {
if (launchBrowser === false) {
return undefined;
}

// A serverReadyAction may already have been defined in the aspire launch configuration. In that case, it applies to all resources unless launchBrowser is false (resource explicitly has opted out of browser launch).
if (debugConfigurationServerReadyAction) {
return debugConfigurationServerReadyAction;
}

if (launchBrowser === undefined || !applicationUrl) {
return undefined;
}

let uriFormat = applicationUrl.includes(';') ? applicationUrl.split(';')[0] : applicationUrl;

return {
action: "openExternally",
pattern: "\\bNow listening on:\\s+https?://\\S+",
pattern: "\\bNow listening on:\\s+(https?://\\S+)",
uriFormat: uriFormat
};
}
6 changes: 4 additions & 2 deletions extension/src/test/dotnetDebugger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,13 @@ suite('Dotnet Debugger Extension Tests', () => {
assert.strictEqual(debugConfig.executablePath, 'exePath');
assert.strictEqual(debugConfig.checkForDevCert, true);

// serverReadyAction should be present with the applicationUrl
// serverReadyAction should be present when launchBrowser is true
assert.notStrictEqual(debugConfig.serverReadyAction, undefined);
assert.strictEqual(debugConfig.serverReadyAction.uriFormat, 'https://localhost:5001');
assert.strictEqual(debugConfig.serverReadyAction?.uriFormat, 'https://localhost:5001');
assert.strictEqual(debugConfig.serverReadyAction?.pattern, '\\bNow listening on:\\s+(https?://\\S+)');

// cleanup
fs.rmSync(tempDir, { recursive: true, force: true });
});

});
55 changes: 43 additions & 12 deletions extension/src/test/launchProfiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ suite('Launch Profile Tests', () => {

const result = determineBaseLaunchProfile(launchConfig, sampleLaunchSettings);

assert.strictEqual(result.profile, null);
assert.strictEqual(result.profileName, null);
assert.strictEqual(result.profile, null);
assert.strictEqual(result.profileName, null);
});

test('returns first profile with commandName=Project when no explicit profile specified', () => {
Expand Down Expand Up @@ -335,17 +335,22 @@ suite('Launch Profile Tests', () => {
});

suite('determineWorkingDirectory', () => {
const projectPath = path.join('C:', 'project', 'MyApp.csproj');
// Keep these tests cross-platform by deriving the platform's root from the current process.
// On Windows this is typically something like "C:\\" (drive-dependent), and on POSIX it's "/".
const systemRoot = path.parse(process.cwd()).root;
const projectPath = path.join(systemRoot, 'project', 'MyApp.csproj');
const projectDir = path.dirname(projectPath);
const absoluteWorkingDir = path.join(systemRoot, 'custom', 'working', 'dir');

test('uses absolute working directory from launch profile', () => {
const baseProfile: LaunchProfile = {
commandName: 'Project',
workingDirectory: path.join('C:', 'custom', 'working', 'dir')
workingDirectory: absoluteWorkingDir
};

const result = determineWorkingDirectory(projectPath, baseProfile);

assert.strictEqual(result, path.join('C:', 'custom', 'working', 'dir'));
assert.strictEqual(result, absoluteWorkingDir);
});

test('resolves relative working directory from launch profile', () => {
Expand All @@ -356,7 +361,7 @@ suite('Launch Profile Tests', () => {

const result = determineWorkingDirectory(projectPath, baseProfile);

assert.strictEqual(result, path.join('C:', 'project', 'custom'));
assert.strictEqual(result, path.join(projectDir, 'custom'));
});

test('uses project directory when no working directory specified', () => {
Expand All @@ -366,13 +371,13 @@ suite('Launch Profile Tests', () => {

const result = determineWorkingDirectory(projectPath, baseProfile);

assert.strictEqual(result, path.join('C:', 'project'));
assert.strictEqual(result, projectDir);
});

test('uses project directory when base profile is null', () => {
const result = determineWorkingDirectory(projectPath, null);

assert.strictEqual(result, path.join('C:', 'project'));
assert.strictEqual(result, projectDir);
});
});

Expand All @@ -382,8 +387,34 @@ suite('Launch Profile Tests', () => {
assert.strictEqual(result, undefined);
});

test('returns undefined when applicationUrl is undefined', () => {
const result = determineServerReadyAction(true, undefined);
test('returns undefined when applicationUrl is undefined and no launch config serverReadyAction', () => {
const result = determineServerReadyAction(true, undefined, undefined);
assert.strictEqual(result, undefined);
});

test('returns existing when launchBrowser is undefined, applicationUrl is undefined and existing launch config serverReadyAction', () => {
const result = determineServerReadyAction(undefined, undefined, { action: 'openExternally', uriFormat: 'https://localhost:5001', pattern: '\\bNow listening on:\\s+(https?://\\S+)' });
assert.notStrictEqual(result, undefined);
assert.strictEqual(result?.action, 'openExternally');
assert.strictEqual(result?.uriFormat, 'https://localhost:5001');
assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+(https?://\\S+)');
});

test('returns existing when launchBrowser is true, applicationUrl is undefined and existing launch config serverReadyAction', () => {
const result = determineServerReadyAction(true, undefined, { action: 'openExternally', uriFormat: 'https://localhost:5001', pattern: '\\bNow listening on:\\s+(https?://\\S+)' });
assert.notStrictEqual(result, undefined);
assert.strictEqual(result?.action, 'openExternally');
assert.strictEqual(result?.uriFormat, 'https://localhost:5001');
assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+(https?://\\S+)');
});

test('returns undefined when launchBrowser is false, applicationUrl is undefined and existing launch config serverReadyAction', () => {
const result = determineServerReadyAction(false, undefined, { action: 'openExternally', uriFormat: 'https://localhost:5001', pattern: '\\bNow listening on:\\s+(https?://\\S+)' });
assert.strictEqual(result, undefined);
});

test('returns undefined when launchBrowser is false, applicationUrl is not undefined and existing launch config serverReadyAction', () => {
const result = determineServerReadyAction(false, 'https://localhost:5001', { action: 'openExternally', uriFormat: 'https://localhost:5001', pattern: '\\bNow listening on:\\s+(https?://\\S+)' });
assert.strictEqual(result, undefined);
});

Expand All @@ -394,7 +425,7 @@ suite('Launch Profile Tests', () => {
assert.notStrictEqual(result, undefined);
assert.strictEqual(result?.action, 'openExternally');
assert.strictEqual(result?.uriFormat, applicationUrl);
assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+https?://\\S+');
assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+(https?://\\S+)');
});

test('returns serverReadyAction with first URL when multiple URLs separated by semicolon', () => {
Expand All @@ -404,7 +435,7 @@ suite('Launch Profile Tests', () => {
assert.notStrictEqual(result, undefined);
assert.strictEqual(result?.action, 'openExternally');
assert.strictEqual(result?.uriFormat, 'https://localhost:5001');
assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+https?://\\S+');
assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+(https?://\\S+)');
});
});

Expand Down
Loading