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
28 changes: 19 additions & 9 deletions extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from "vscode";
import { EventEmitter } from "vscode";
import * as fs from "fs";
import { createDebugAdapterTracker, AppHostRestartHandler } from "./adapterTracker";
import { createDebugAdapterTracker, AppHostOutputHandler, AppHostRestartHandler } from "./adapterTracker";
import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProjectLaunchConfiguration, StartAppHostOptions } from "../dcp/types";
import { extensionLogOutputChannel } from "../utils/logging";
import AspireDcpServer, { generateDcpIdPrefix } from "../dcp/AspireDcpServer";
Expand Down Expand Up @@ -180,7 +180,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
},
stderrCallback: (data) => {
for (const line of trimMessage(data)) {
this.sendMessageWithEmoji("❌", line, false);
this.sendMessageWithEmoji("❌", line, false, 'stderr');
}
},
errorCallback: (error) => {
Expand Down Expand Up @@ -217,13 +217,13 @@ export class AspireDebugSession implements vscode.DebugAdapter {
}
}

createDebugAdapterTrackerCore(debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler) {
createDebugAdapterTrackerCore(debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler, onAppHostOutput?: AppHostOutputHandler) {
if (this._trackedDebugAdapters.includes(debugAdapter)) {
return;
}

this._trackedDebugAdapters.push(debugAdapter);
this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, onAppHostRestartRequested));
this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, onAppHostRestartRequested, onAppHostOutput));
}

private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts'];
Expand All @@ -249,7 +249,8 @@ export class AspireDebugSession implements vscode.DebugAdapter {
return true; // suppress VS Code's child restart
}
return false;
}
},
(output, category) => this.sendMessage(output, false, category)
);

let appHostArgs: string[];
Expand Down Expand Up @@ -316,8 +317,13 @@ export class AspireDebugSession implements vscode.DebugAdapter {
this._disposables.push(disposable);
}
catch (err) {
extensionLogOutputChannel.error(`Error starting AppHost debug session: ${err}`);
vscode.window.showErrorMessage(String(err));
const errorMessage = err instanceof Error ? err.message : String(err);
const errorDetails = err instanceof Error ? (err.stack ?? err.message) : String(err);
extensionLogOutputChannel.error(`Error starting AppHost debug session: ${errorDetails}`);
if (!isErrorWithStreamedDebugConsoleOutput(err)) {
this.sendMessageWithEmoji("❌", errorDetails, true, 'stderr');
}
vscode.window.showErrorMessage(errorMessage);
this.dispose();
}
}
Expand Down Expand Up @@ -504,8 +510,8 @@ export class AspireDebugSession implements vscode.DebugAdapter {
this._onDidSendMessage.fire(event);
}

sendMessageWithEmoji(emoji: string, message: string, addNewLine: boolean = true) {
this.sendMessage(`${emoji} ${message}`, addNewLine);
sendMessageWithEmoji(emoji: string, message: string, addNewLine: boolean = true, category: 'stdout' | 'stderr' = 'stdout') {
this.sendMessage(`${emoji} ${message}`, addNewLine, category);
}

sendMessage(message: string, addNewLine: boolean = true, category: 'stdout' | 'stderr' = 'stdout') {
Expand All @@ -524,3 +530,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
extensionLogOutputChannel.info(`AppHost startup completed and dashboard is running.`);
}
}

function isErrorWithStreamedDebugConsoleOutput(err: unknown): boolean {
return err instanceof Error && (err as Error & { debugConsoleOutputAlreadyWritten?: boolean }).debugConsoleOutputAlreadyWritten === true;
}
9 changes: 7 additions & 2 deletions extension/src/debugger/adapterTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { dcpServerNotInitialized } from '../loc/strings';
* Return `true` to suppress VS Code's automatic child session restart.
*/
export type AppHostRestartHandler = (debugSessionId: string) => boolean;
export type AppHostOutputHandler = (output: string, category: 'stdout' | 'stderr') => void;

export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler): vscode.Disposable {
export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string, onAppHostRestartRequested?: AppHostRestartHandler, onAppHostOutput?: AppHostOutputHandler): vscode.Disposable {
return vscode.debug.registerDebugAdapterTrackerFactory(debugAdapter, {
createDebugAdapterTracker(session: vscode.DebugSession) {
return {
Expand Down Expand Up @@ -43,7 +44,11 @@ export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapt
}

const { category, output } = message.body;
if (category === 'stdout' || category === 'stderr') {
if (typeof output === 'string' && category !== 'telemetry') {
if (session.configuration.isApphost && onAppHostOutput) {
onAppHostOutput(output, category === 'stderr' ? 'stderr' : 'stdout');
}

const notification: ServiceLogsNotification = {
notification_type: 'serviceLogs',
session_id: session.configuration.runId,
Expand Down
12 changes: 10 additions & 2 deletions extension/src/debugger/languages/dotnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ class DotNetService implements IDotNetService {
if (code === 0) {
// if build succeeds, simply return. otherwise throw to trigger error handling
if (stderrOutput) {
reject(new Error(stderrOutput));
reject(createErrorWithStreamedDebugConsoleOutput(stderrOutput));
} else {
resolve();
}
} else {
reject(new Error(buildFailedForProjectWithError(projectFile, stdoutOutput || stderrOutput || `Exit code ${code}`)));
reject(createErrorWithStreamedDebugConsoleOutput(buildFailedForProjectWithError(projectFile, stdoutOutput || stderrOutput || `Exit code ${code}`)));
}
});
});
Expand Down Expand Up @@ -191,6 +191,14 @@ function getRunApiConfigFromOutput(runApiOutput: string): RunApiOutput {
};
}

function createErrorWithStreamedDebugConsoleOutput(message: string): Error {
// Mark build errors whose output was already streamed to avoid replaying the transcript in AppHost startup handling.
const error = new Error(message) as Error & { debugConsoleOutputAlreadyWritten?: boolean };
error.debugConsoleOutputAlreadyWritten = true;

return error;
}

export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension {
return {
resourceType: 'project',
Expand Down
6 changes: 4 additions & 2 deletions extension/src/debugger/languages/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ function getProjectFile(launchConfig: ExecutableLaunchConfiguration): string {

export const nodeDebuggerExtension: ResourceDebuggerExtension = {
resourceType: 'node',
debugAdapter: 'node',
// Use js-debug's pwa-node adapter so outputCapture emits stdout/stderr DAP output events for dashboard log forwarding.
debugAdapter: 'pwa-node',
extensionId: null,
getDisplayName: (launchConfiguration: ExecutableLaunchConfiguration) => {
if (isNodeLaunchConfiguration(launchConfiguration)) {
Expand All @@ -33,7 +34,8 @@ export const nodeDebuggerExtension: ResourceDebuggerExtension = {
throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig)));
}

debugConfiguration.type = 'node';
debugConfiguration.type = 'pwa-node';
debugConfiguration.outputCapture = 'std';

// Use working_directory for cwd if available
if (launchConfig.working_directory) {
Expand Down
107 changes: 106 additions & 1 deletion extension/src/test/adapterTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as sinon from 'sinon';
import * as vscode from 'vscode';
import { createDebugAdapterTracker } from '../debugger/adapterTracker';
import AspireDcpServer from '../dcp/AspireDcpServer';
import { AspireResourceExtendedDebugConfiguration, SessionTerminatedNotification } from '../dcp/types';
import { AspireResourceExtendedDebugConfiguration, ServiceLogsNotification, SessionTerminatedNotification } from '../dcp/types';

suite('Debug Adapter Tracker Tests', () => {
let dcpServer: sinon.SinonStubbedInstance<AspireDcpServer>;
Expand Down Expand Up @@ -188,6 +188,111 @@ suite('Debug Adapter Tracker Tests', () => {
disposable.dispose();
});

test('non-telemetry output events are sent as service logs', async () => {
const disposable = createDebugAdapterTracker(dcpServer as any, 'node');
const factory = registerFactoryStub.lastCall.args[1];
const tracker = factory.createDebugAdapterTracker(debugSession);

const testCases: { category?: string, output: string, expectedIsStdErr: boolean, expectedLogMessage: string }[] = [
{ category: 'stdout', output: 'stdout line\n', expectedIsStdErr: false, expectedLogMessage: 'stdout line' },
{ category: 'stderr', output: 'stderr line\n', expectedIsStdErr: true, expectedLogMessage: 'stderr line' },
{ category: 'console', output: 'VITE ready\n', expectedIsStdErr: false, expectedLogMessage: 'VITE ready' },
{ category: 'important', output: 'important line\n', expectedIsStdErr: false, expectedLogMessage: 'important line' },
{ category: 'debug', output: 'debug line\n', expectedIsStdErr: false, expectedLogMessage: 'debug line' },
{ output: 'default console line\n', expectedIsStdErr: false, expectedLogMessage: 'default console line' }
];

for (const testCase of testCases) {
dcpServer.sendNotification.resetHistory();

tracker.onDidSendMessage({
type: 'event',
event: 'output',
body: {
category: testCase.category,
output: testCase.output
}
});

assert.strictEqual(dcpServer.sendNotification.calledOnce, true);
const notification = dcpServer.sendNotification.firstCall.args[0] as ServiceLogsNotification;
assert.strictEqual(notification.notification_type, 'serviceLogs');
assert.strictEqual(notification.session_id, 'run-123');
assert.strictEqual(notification.dcp_id, 'debug-456');
assert.strictEqual(notification.is_std_err, testCase.expectedIsStdErr);
assert.strictEqual(notification.log_message, testCase.expectedLogMessage);
}

disposable.dispose();
});

test('telemetry output event is not sent as service log', async () => {
const disposable = createDebugAdapterTracker(dcpServer as any, 'node');
const factory = registerFactoryStub.lastCall.args[1];
const tracker = factory.createDebugAdapterTracker(debugSession);

tracker.onDidSendMessage({
type: 'event',
event: 'output',
body: {
category: 'telemetry',
output: '{"eventName":"nodeTelemetry"}'
}
});

assert.strictEqual(dcpServer.sendNotification.called, false);

disposable.dispose();
});

test('apphost output events are mirrored to output callback', async () => {
const outputCallback = sinon.stub();
const disposable = createDebugAdapterTracker(dcpServer as any, 'pwa-node', undefined, outputCallback);
const factory = registerFactoryStub.lastCall.args[1];
const tracker = factory.createDebugAdapterTracker({
...debugSession,
configuration: {
...debugSession.configuration,
isApphost: true
}
});

tracker.onDidSendMessage({
type: 'event',
event: 'output',
body: {
category: 'stderr',
output: 'tsx compile error\n'
}
});

assert.strictEqual(outputCallback.calledOnceWith('tsx compile error\n', 'stderr'), true);
assert.strictEqual(dcpServer.sendNotification.calledOnce, true);

disposable.dispose();
});

test('resource output events are not mirrored to output callback', async () => {
const outputCallback = sinon.stub();
const disposable = createDebugAdapterTracker(dcpServer as any, 'pwa-node', undefined, outputCallback);
const factory = registerFactoryStub.lastCall.args[1];
const tracker = factory.createDebugAdapterTracker(debugSession);

tracker.onDidSendMessage({
type: 'event',
event: 'output',
body: {
category: 'stderr',
output: 'resource error\n'
}
});

assert.strictEqual(outputCallback.called, false);
assert.strictEqual(dcpServer.sendNotification.calledOnce, true);

disposable.dispose();
});

test('does not send notification if debug session lacks runId', async () => {
// Create session without proper configuration
const invalidSession = {
Expand Down
76 changes: 76 additions & 0 deletions extension/src/test/dotnetDebugger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,82 @@ suite('Dotnet Debugger Extension Tests', () => {
const fakeDotNetService = new TestDotNetService(outputPath, rejectBuild, hasDevKit);
return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) };
}

test('failed AppHost start writes error to debug console', async () => {
const parentDebugSession = {
id: 'aspire-session',
type: 'aspire',
name: 'Aspire',
workspaceFolder: undefined,
configuration: {
type: 'aspire',
request: 'launch',
name: 'Aspire',
program: '/workspace/apphost.ts'
},
customRequest: sinon.stub(),
getDebugProtocolBreakpoint: sinon.stub()
} as unknown as vscode.DebugSession;
const aspireDebugSession = new AspireDebugSession(parentDebugSession, {} as any, {} as any, {} as any, () => { });
const outputEvents: any[] = [];
const outputSubscription = aspireDebugSession.onDidSendMessage(message => outputEvents.push(message));
const startError = new Error('AppHost build failed');

sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore');
sinon.stub(aspireDebugSession, 'startAndGetDebugSession').rejects(startError);
const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined);
sinon.stub(vscode.debug, 'stopDebugging').resolves();

await aspireDebugSession.startAppHost('/workspace/apphost.ts', ['node', 'apphost.ts'], [], true, { forceBuild: false });

assert.ok(showErrorMessageStub.calledWith(startError.message));
assert.ok(startError.stack);
assert.ok(outputEvents.some(message =>
message.type === 'event'
&& message.event === 'output'
&& message.body.category === 'stderr'
&& message.body.output.includes(startError.stack)));

outputSubscription.dispose();
});

test('failed AppHost start does not duplicate already streamed build output', async () => {
const parentDebugSession = {
id: 'aspire-session',
type: 'aspire',
name: 'Aspire',
workspaceFolder: undefined,
configuration: {
type: 'aspire',
request: 'launch',
name: 'Aspire',
program: '/workspace/apphost.ts'
},
customRequest: sinon.stub(),
getDebugProtocolBreakpoint: sinon.stub()
} as unknown as vscode.DebugSession;
const aspireDebugSession = new AspireDebugSession(parentDebugSession, {} as any, {} as any, {} as any, () => { });
const outputEvents: any[] = [];
const outputSubscription = aspireDebugSession.onDidSendMessage(message => outputEvents.push(message));
const startError = new Error('Build FAILED.');
(startError as Error & { debugConsoleOutputAlreadyWritten?: boolean }).debugConsoleOutputAlreadyWritten = true;

sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore');
sinon.stub(aspireDebugSession, 'startAndGetDebugSession').rejects(startError);
const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined);
sinon.stub(vscode.debug, 'stopDebugging').resolves();

await aspireDebugSession.startAppHost('/workspace/apphost.ts', ['node', 'apphost.ts'], [], true, { forceBuild: false });

assert.ok(showErrorMessageStub.calledWith(startError.message));
assert.strictEqual(outputEvents.some(message =>
message.type === 'event'
&& message.event === 'output'
&& message.body.output.includes(startError.message)), false);

outputSubscription.dispose();
});

test('project is built when C# dev kit is installed and executable not found', async () => {
const outputPath = 'C:\\temp\\bin\\Debug\\net7.0\\TestProject.dll';
const { extension, dotNetService } = createDebuggerExtension(outputPath, null, true, false);
Expand Down
Loading
Loading