Skip to content

Commit cc6a833

Browse files
authored
feat: route real test program output to Test Results (#1887)
* feat: route real test program output to Test Results The debuggee's stdout/stderr are delivered by java-debug as standard DAP `output` events, which by default only surface in the Debug Console. This splits program output (Debug Console) from test results (Test Results view) across two separate surfaces, and the runner previously echoed the raw JUnit/TestNG control protocol frames to Test Results as noise. - Attach a DebugAdapterTracker to the test's own debug session and forward its DAP `output` events into the Test Results view (BaseRunner). - Stop echoing the socket control protocol to Test Results: JUnit no longer appends every raw line, and TestNG no longer echoes its JSON frames. - Attribute forwarded output to the running test when exactly one test is executing; fall back to run-level output when idle or when several tests run in parallel (attribution would only be a guess). * fix: route debug output at test run level Correlate debug sessions with a unique launch marker, mirror non-telemetry DAP output at run level, and preserve structured TestNG runner errors while suppressing control protocol noise.
1 parent 56feff7 commit cc6a833

5 files changed

Lines changed: 212 additions & 33 deletions

File tree

src/runners/baseRunner/BaseRunner.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
// Licensed under the MIT license.
33

44
import * as iconv from 'iconv-lite';
5+
import { randomUUID } from 'crypto';
56
import { AddressInfo, createServer, Server, Socket } from 'net';
67
import * as os from 'os';
7-
import { CancellationToken, debug, DebugConfiguration, DebugSession, Disposable } from 'vscode';
8+
import { CancellationToken, debug, DebugAdapterTracker, DebugConfiguration, DebugSession, Disposable, ProviderResult } from 'vscode';
89
import { sendError } from 'vscode-extension-telemetry-wrapper';
910
import { Configurations } from '../../constants';
1011
import { IProgressReporter } from '../../debugger.api';
1112
import { ITestRunnerInternal } from '../ITestRunner';
1213
import { RunnerResultAnalyzer } from './RunnerResultAnalyzer';
1314
import { IExecutionConfig, IRunTestContext } from '../../java-test-runner.api';
1415

16+
const JAVA_TEST_RUN_ID: string = '__javaTestRunId';
17+
1518
export abstract class BaseRunner implements ITestRunnerInternal {
1619
protected server: Server;
1720
protected socket: Socket;
@@ -56,9 +59,26 @@ export abstract class BaseRunner implements ITestRunnerInternal {
5659
// So we force to use internal console here to make sure the session is still under debugger's control.
5760
launchConfiguration.console = 'internalConsole';
5861

62+
const testRunId: string = randomUUID();
63+
launchConfiguration[JAVA_TEST_RUN_ID] = testRunId;
64+
const isTestSession: (session: DebugSession) => boolean = (session: DebugSession): boolean =>
65+
session.configuration[JAVA_TEST_RUN_ID] === testRunId;
66+
67+
// Mirror the test session's user-visible Debug Console output in Test Results.
68+
this.disposables.push(debug.registerDebugAdapterTrackerFactory('java', {
69+
createDebugAdapterTracker: (session: DebugSession): ProviderResult<DebugAdapterTracker> => {
70+
if (!isTestSession(session)) {
71+
return undefined;
72+
}
73+
return {
74+
onDidSendMessage: (message: any): void => this.handleDebugAdapterMessage(message),
75+
};
76+
},
77+
}));
78+
5979
let debugSession: DebugSession | undefined;
6080
this.disposables.push(debug.onDidStartDebugSession((session: DebugSession) => {
61-
if (session.name === launchConfiguration.name) {
81+
if (!debugSession && isTestSession(session)) {
6282
debugSession = session;
6383
}
6484
}));
@@ -80,7 +100,7 @@ export abstract class BaseRunner implements ITestRunnerInternal {
80100
return await new Promise<void>((resolve: () => void): void => {
81101
this.disposables.push(
82102
debug.onDidTerminateDebugSession((session: DebugSession): void => {
83-
if (launchConfiguration.name === session.name) {
103+
if (session.id === debugSession?.id) {
84104
debugSession = undefined;
85105
this.tearDown();
86106
if (data.length > 0) {
@@ -97,6 +117,19 @@ export abstract class BaseRunner implements ITestRunnerInternal {
97117
}));
98118
}
99119

120+
protected handleDebugAdapterMessage(message: any): void {
121+
if (message?.type !== 'event' || message.event !== 'output' || message.body?.category === 'telemetry') {
122+
return;
123+
}
124+
125+
const output: unknown = message.body?.output;
126+
if (typeof output !== 'string' || output.length === 0) {
127+
return;
128+
}
129+
130+
this.testContext.testRun.appendOutput(output.replace(/\r?\n/g, '\r\n'));
131+
}
132+
100133
public async tearDown(): Promise<void> {
101134
try {
102135
if (this.socket) {

src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,14 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer {
5252
public analyzeData(data: string): void {
5353
const lines: string[] = data.split(/\r?\n/);
5454
for (const line of lines) {
55+
// The socket stream carries only the JUnit runner's control protocol
56+
// (`%`-prefixed frames plus stack-trace / expected-actual payloads).
57+
// The control frames are noise, and the failure payloads are already
58+
// surfaced structurally as TestMessages on the failed items, so nothing
59+
// here is echoed to the Test Results output. The user-facing program
60+
// output is instead forwarded from the debug session's DAP `output`
61+
// events (see BaseRunner).
5562
this.processData(line);
56-
this.testContext.testRun.appendOutput(line + '\r\n');
5763
}
5864
}
5965

src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { IRunTestContext, TestLevel, TestResultState } from '../../java-test-run
1010
const TEST_START: string = 'testStarted';
1111
const TEST_FAIL: string = 'testFailed';
1212
const TEST_FINISH: string = 'testFinished';
13+
const TEST_ERROR: string = 'error';
1314

1415
export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer {
1516

@@ -47,17 +48,25 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer {
4748
try {
4849
this.processData(match[1]);
4950
} catch (error) {
50-
this.testContext.testRun.appendOutput(`[ERROR] Failed to parse output data: ${match[1]}\n`);
51+
this.testContext.testRun.appendOutput(`[ERROR] Failed to parse output data: ${match[1]}\r\n`);
5152
}
5253
}
5354
}
5455

5556
public processData(data: string): void {
5657
const outputData: ITestNGOutputData = JSON.parse(data) as ITestNGOutputData;
5758

58-
this.testContext.testRun.appendOutput(this.unescape(data).replace(/\r?\n/g, '\r\n'));
59+
if (outputData.name === TEST_ERROR) {
60+
this.processRunnerError(outputData.attributes);
61+
return;
62+
}
63+
64+
const attributes: ITestNGAttributes | undefined = outputData.attributes;
65+
if (!attributes?.name) {
66+
return;
67+
}
5968

60-
const id: string = `${this.projectName}@${outputData.attributes.name}`;
69+
const id: string = `${this.projectName}@${attributes.name}`;
6170
if (outputData.name === TEST_START) {
6271
this.initializeCache();
6372
const item: TestItem | undefined = this.getTestItem(id);
@@ -76,11 +85,11 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer {
7685
this.currentTestState = TestResultState.Failed;
7786
const testMessages: TestMessage[] = [];
7887

79-
if (outputData.attributes.trace) {
88+
if (attributes.trace) {
8089
const markdownTrace: MarkdownString = new MarkdownString();
8190
markdownTrace.isTrusted = true;
8291
markdownTrace.supportHtml = true;
83-
for (const line of outputData.attributes.trace.split(/\r?\n/)) {
92+
for (const line of attributes.trace.split(/\r?\n/)) {
8493
this.processStackTrace(line, markdownTrace, this.currentItem, this.projectName);
8594
}
8695

@@ -93,17 +102,17 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer {
93102
}
94103
testMessages.push(testMessage);
95104
}
96-
const duration: number = Number.parseInt(outputData.attributes.duration, 10);
105+
const duration: number | undefined = this.parseDuration(attributes.duration);
97106
setTestState(this.testContext.testRun, item, this.currentTestState, testMessages, duration);
98107
} else if (outputData.name === TEST_FINISH) {
99-
const item: TestItem | undefined = this.getTestItem(data);
108+
const item: TestItem | undefined = this.getTestItem(id);
100109
if (!item) {
101110
return;
102111
}
103112
if (this.currentTestState === TestResultState.Running) {
104113
this.currentTestState = TestResultState.Passed;
105114
}
106-
const duration: number = Number.parseInt(outputData.attributes.duration, 10);
115+
const duration: number | undefined = this.parseDuration(attributes.duration);
107116
setTestState(this.testContext.testRun, item, this.currentTestState, undefined, duration);
108117
const itemData: ITestItemData | undefined = dataCache.get(item);
109118
if (itemData?.testLevel === TestLevel.Method) {
@@ -121,20 +130,31 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer {
121130
return this.currentItem;
122131
}
123132

124-
protected unescape(content: string): string {
125-
return content.replace(/\\r/gm, '\r')
126-
.replace(/\\f/gm, '\f')
127-
.replace(/\\n/gm, '\n')
128-
.replace(/\\t/gm, '\t')
129-
.replace(/\\b/gm, '\b')
130-
.replace(/\\"/gm, '"');
131-
}
132-
133133
protected initializeCache(): void {
134134
this.currentTestState = TestResultState.Queued;
135135
this.currentItem = undefined;
136136
}
137137

138+
private processRunnerError(attributes: ITestNGAttributes | undefined): void {
139+
let message: string = attributes?.message || 'Failed to run TestNG tests.';
140+
if (attributes?.trace) {
141+
message += `\n${attributes.trace}`;
142+
}
143+
const testMessage: TestMessage = new TestMessage(message);
144+
for (const item of this.testContext.testItems) {
145+
this.testContext.testRun.errored(item, testMessage);
146+
}
147+
}
148+
149+
private parseDuration(duration: string | undefined): number | undefined {
150+
if (!duration) {
151+
return undefined;
152+
}
153+
154+
const parsed: number = Number.parseInt(duration, 10);
155+
return Number.isNaN(parsed) ? undefined : parsed;
156+
}
157+
138158
protected getStacktraceFilter(): string[] {
139159
return [
140160
'com.microsoft.java.test.runner.',
@@ -151,20 +171,14 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer {
151171
}
152172

153173
interface ITestNGOutputData {
154-
attributes: ITestNGAttributes;
155-
type: TestOutputType;
174+
attributes?: ITestNGAttributes;
156175
name: string;
157176
}
158177

159-
enum TestOutputType {
160-
Info,
161-
Error,
162-
}
163-
164178
interface ITestNGAttributes {
165-
name: string;
166-
duration: string;
167-
location: string;
168-
message: string;
169-
trace: string;
179+
name?: string;
180+
duration?: string;
181+
location?: string;
182+
message?: string;
183+
trace?: string;
170184
}

test/suite/TestNGAnalyzer.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
'use strict';
5+
6+
import * as assert from 'assert';
7+
import * as sinon from 'sinon';
8+
import { TestController, TestMessage, TestRunRequest, tests, workspace } from 'vscode';
9+
import { TestNGRunnerResultAnalyzer } from '../../src/runners/testngRunner/TestNGRunnerResultAnalyzer';
10+
import { IRunTestContext, TestKind } from '../../src/java-test-runner.api';
11+
import { generateTestItem } from './utils';
12+
13+
// tslint:disable: only-arrow-functions
14+
suite('TestNG Runner Analyzer Tests', () => {
15+
16+
let testController: TestController;
17+
18+
setup(() => {
19+
testController = tests.createTestController('testngTestController', 'Mock TestNG');
20+
});
21+
22+
teardown(() => {
23+
testController.dispose();
24+
});
25+
26+
test('surfaces runner errors as structured test errors', () => {
27+
const testItem = generateTestItem(testController, 'testng@example.SampleTest#test', TestKind.TestNG);
28+
const testRun = testController.createTestRun(new TestRunRequest([testItem], []));
29+
const erroredSpy = sinon.spy(testRun, 'errored');
30+
const runnerContext: IRunTestContext = {
31+
isDebug: false,
32+
kind: TestKind.TestNG,
33+
projectName: 'testng',
34+
testItems: [testItem],
35+
testRun,
36+
workspaceFolder: workspace.workspaceFolders?.[0]!,
37+
};
38+
const analyzer = new TestNGRunnerResultAnalyzer(runnerContext);
39+
const trace = 'java.lang.ClassNotFoundException: example.SampleTest';
40+
41+
analyzer.processData(JSON.stringify({
42+
name: 'error',
43+
attributes: {
44+
message: 'Failed to run TestNG tests',
45+
trace,
46+
},
47+
}));
48+
49+
sinon.assert.calledOnce(erroredSpy);
50+
sinon.assert.calledWith(erroredSpy, testItem, sinon.match.instanceOf(TestMessage));
51+
const testMessage = erroredSpy.firstCall.args[1] as TestMessage;
52+
assert.strictEqual(testMessage.message, `Failed to run TestNG tests\n${trace}`);
53+
});
54+
55+
test('ignores control messages without test attributes', () => {
56+
const testItem = generateTestItem(testController, 'testng@example.SampleTest#test', TestKind.TestNG);
57+
const testRun = testController.createTestRun(new TestRunRequest([testItem], []));
58+
const appendOutputSpy = sinon.spy(testRun, 'appendOutput');
59+
const runnerContext: IRunTestContext = {
60+
isDebug: false,
61+
kind: TestKind.TestNG,
62+
projectName: 'testng',
63+
testItems: [testItem],
64+
testRun,
65+
workspaceFolder: workspace.workspaceFolders?.[0]!,
66+
};
67+
const analyzer = new TestNGRunnerResultAnalyzer(runnerContext);
68+
69+
analyzer.analyzeData('@@<TestRunner-{"name":"reporterAttached"}-TestRunner>');
70+
71+
sinon.assert.notCalled(appendOutputSpy);
72+
});
73+
});

test/suite/baseRunner.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
'use strict';
55

66
import * as assert from 'assert';
7+
import * as sinon from 'sinon';
78
import { AddressInfo } from 'net';
89
import { BaseRunner } from '../../src/runners/baseRunner/BaseRunner';
910
import { RunnerResultAnalyzer } from '../../src/runners/baseRunner/RunnerResultAnalyzer';
1011
import { IRunTestContext, TestKind } from '../../src/java-test-runner.api';
1112

1213
class TestableBaseRunner extends BaseRunner {
14+
public handleMessage(message: any): void {
15+
this.handleDebugAdapterMessage(message);
16+
}
17+
1318
protected getAnalyzer(): RunnerResultAnalyzer {
1419
return {} as RunnerResultAnalyzer;
1520
}
@@ -63,4 +68,52 @@ suite('BaseRunner Tests', () => {
6368
}
6469
});
6570
});
71+
72+
suite('handleDebugAdapterMessage', () => {
73+
test('forwards non-telemetry output at run level with CRLF newlines', () => {
74+
const appendOutput = sinon.spy();
75+
const runner = new TestableBaseRunner({
76+
kind: TestKind.JUnit,
77+
isDebug: false,
78+
projectName: 'test-project',
79+
testItems: [],
80+
testRun: { appendOutput } as any,
81+
workspaceFolder: {} as any,
82+
} as IRunTestContext);
83+
84+
runner.handleMessage({
85+
type: 'event',
86+
event: 'output',
87+
body: {
88+
category: 'console',
89+
output: 'first\nsecond\r\n',
90+
},
91+
});
92+
93+
sinon.assert.calledOnceWithExactly(appendOutput, 'first\r\nsecond\r\n');
94+
});
95+
96+
test('ignores telemetry output', () => {
97+
const appendOutput = sinon.spy();
98+
const runner = new TestableBaseRunner({
99+
kind: TestKind.JUnit,
100+
isDebug: false,
101+
projectName: 'test-project',
102+
testItems: [],
103+
testRun: { appendOutput } as any,
104+
workspaceFolder: {} as any,
105+
} as IRunTestContext);
106+
107+
runner.handleMessage({
108+
type: 'event',
109+
event: 'output',
110+
body: {
111+
category: 'telemetry',
112+
output: 'internal event',
113+
},
114+
});
115+
116+
sinon.assert.notCalled(appendOutput);
117+
});
118+
});
66119
});

0 commit comments

Comments
 (0)