From 211ed2fca37923b468eba827e234841ca780cb51 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 14 Jul 2026 16:54:09 +0800 Subject: [PATCH 1/2] 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). --- src/runners/baseRunner/BaseRunner.ts | 30 ++++++++++++++++- .../baseRunner/RunnerResultAnalyzer.ts | 33 +++++++++++++++++++ .../junitRunner/JUnitRunnerResultAnalyzer.ts | 10 +++++- .../TestNGRunnerResultAnalyzer.ts | 13 ++------ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/runners/baseRunner/BaseRunner.ts b/src/runners/baseRunner/BaseRunner.ts index b27c4390..354f96b5 100644 --- a/src/runners/baseRunner/BaseRunner.ts +++ b/src/runners/baseRunner/BaseRunner.ts @@ -4,7 +4,7 @@ import * as iconv from 'iconv-lite'; import { AddressInfo, createServer, Server, Socket } from 'net'; import * as os from 'os'; -import { CancellationToken, debug, DebugConfiguration, DebugSession, Disposable } from 'vscode'; +import { CancellationToken, debug, DebugAdapterTracker, DebugConfiguration, DebugSession, Disposable, ProviderResult } from 'vscode'; import { sendError } from 'vscode-extension-telemetry-wrapper'; import { Configurations } from '../../constants'; import { IProgressReporter } from '../../debugger.api'; @@ -56,6 +56,34 @@ export abstract class BaseRunner implements ITestRunnerInternal { // So we force to use internal console here to make sure the session is still under debugger's control. launchConfiguration.console = 'internalConsole'; + // The debuggee's stdout/stderr are delivered by java-debug as standard DAP `output` events, + // which by default only surface in the Debug Console. Attach a tracker to the test's own debug + // session and forward those output events into the Test Results view, so the program output shows + // up next to the test results instead of being split across two separate surfaces. + this.disposables.push(debug.registerDebugAdapterTrackerFactory('java', { + createDebugAdapterTracker: (session: DebugSession): ProviderResult => { + if (session.name !== launchConfiguration.name) { + return undefined; + } + return { + onDidSendMessage: (message: any): void => { + if (message?.type === 'event' && message.event === 'output') { + const category: string | undefined = message.body?.category; + // `telemetry` output events are not meant for the user. + if (category === 'telemetry') { + return; + } + const output: string | undefined = message.body?.output; + if (output) { + // Let the analyzer attribute the output to the running test when possible. + this.runnerResultAnalyzer.appendProgramOutput(output); + } + } + }, + }; + }, + })); + let debugSession: DebugSession | undefined; this.disposables.push(debug.onDidStartDebugSession((session: DebugSession) => { if (session.name === launchConfiguration.name) { diff --git a/src/runners/baseRunner/RunnerResultAnalyzer.ts b/src/runners/baseRunner/RunnerResultAnalyzer.ts index 1009b160..b005477d 100644 --- a/src/runners/baseRunner/RunnerResultAnalyzer.ts +++ b/src/runners/baseRunner/RunnerResultAnalyzer.ts @@ -10,12 +10,45 @@ export abstract class RunnerResultAnalyzer { // Track parent test item states to update them when all children complete protected parentStates: Map = new Map(); + // Test items that are currently executing. Used to attribute program output + // (captured from the debug session) to the running test when it is unambiguous. + private runningItems: Set = new Set(); + constructor(protected testContext: IRunTestContext) { } public abstract analyzeData(data: string): void; public abstract processData(data: string): void; protected testMessageLocation: Location | undefined; + /** + * Record that a test item has started executing. + */ + protected markItemStarted(item: TestItem): void { + this.runningItems.add(item); + } + + /** + * Record that a test item has finished executing. + */ + protected markItemFinished(item: TestItem): void { + this.runningItems.delete(item); + } + + /** + * Forward program output (captured from the test's debug session as DAP `output` + * events) into the Test Results view. When exactly one test is currently running, + * the output is attributed to that test item so it shows up under the test in the + * explorer; otherwise (idle, or several tests running in parallel where attribution + * would only be a guess) it is appended to the run as a whole. + */ + public appendProgramOutput(output: string): void { + const normalized: string = output.replace(/\r?\n/g, '\r\n'); + const item: TestItem | undefined = this.runningItems.size === 1 + ? this.runningItems.values().next().value + : undefined; + this.testContext.testRun.appendOutput(normalized, undefined, item); + } + /** * Return a string array which contains the stacktraces that need to be filtered. * All the stacktraces which include the element in the return array will be removed. diff --git a/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts b/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts index 06f5719c..a847bfb3 100644 --- a/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts +++ b/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts @@ -52,8 +52,14 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { public analyzeData(data: string): void { const lines: string[] = data.split(/\r?\n/); for (const line of lines) { + // The socket stream carries only the JUnit runner's control protocol + // (`%`-prefixed frames plus stack-trace / expected-actual payloads). + // The control frames are noise, and the failure payloads are already + // surfaced structurally as TestMessages on the failed items, so nothing + // here is echoed to the Test Results output. The user-facing program + // output is instead forwarded from the debug session's DAP `output` + // events (see BaseRunner). this.processData(line); - this.testContext.testRun.appendOutput(line + '\r\n'); } } @@ -70,6 +76,7 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { this.setDurationAtStart(this.getCurrentState(item)); setTestState(this.testContext.testRun, item, this.getCurrentState(item).resultState); this.updateParentOnChildStart(item); + this.markItemStarted(item); } else if (data.startsWith(MessageId.TestEnd)) { const item: TestItem | undefined = this.getTestItem(data.substr(MessageId.TestEnd.length)); if (!item) { @@ -79,6 +86,7 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { this.calcDurationAtEnd(currentState); this.determineResultStateAtEnd(data, currentState); setTestState(this.testContext.testRun, item, currentState.resultState, undefined, currentState.duration); + this.markItemFinished(item); const itemData: ITestItemData | undefined = dataCache.get(item); if (itemData?.testLevel === TestLevel.Method) { this.updateParentOnChildComplete(item, currentState.resultState); diff --git a/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts b/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts index 39f46d75..0a8c37fe 100644 --- a/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts +++ b/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts @@ -55,8 +55,6 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { public processData(data: string): void { const outputData: ITestNGOutputData = JSON.parse(data) as ITestNGOutputData; - this.testContext.testRun.appendOutput(this.unescape(data).replace(/\r?\n/g, '\r\n')); - const id: string = `${this.projectName}@${outputData.attributes.name}`; if (outputData.name === TEST_START) { this.initializeCache(); @@ -68,6 +66,7 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { this.currentTestState = TestResultState.Running; this.testContext.testRun.started(item); this.updateParentOnChildStart(item); + this.markItemStarted(item); } else if (outputData.name === TEST_FAIL) { const item: TestItem | undefined = this.getTestItem(id); if (!item) { @@ -105,6 +104,7 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { } const duration: number = Number.parseInt(outputData.attributes.duration, 10); setTestState(this.testContext.testRun, item, this.currentTestState, undefined, duration); + this.markItemFinished(item); const itemData: ITestItemData | undefined = dataCache.get(item); if (itemData?.testLevel === TestLevel.Method) { this.updateParentOnChildComplete(item, this.currentTestState); @@ -121,15 +121,6 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { return this.currentItem; } - protected unescape(content: string): string { - return content.replace(/\\r/gm, '\r') - .replace(/\\f/gm, '\f') - .replace(/\\n/gm, '\n') - .replace(/\\t/gm, '\t') - .replace(/\\b/gm, '\b') - .replace(/\\"/gm, '"'); - } - protected initializeCache(): void { this.currentTestState = TestResultState.Queued; this.currentItem = undefined; From 474ef23f1c91e371ebe3da0be7254a08abef04ed Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Wed, 15 Jul 2026 15:55:59 +0800 Subject: [PATCH 2/2] 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8db89d6b-bcb8-42d2-9f14-78aab176fcd0 --- src/runners/baseRunner/BaseRunner.ts | 47 ++++++------ .../baseRunner/RunnerResultAnalyzer.ts | 33 --------- .../junitRunner/JUnitRunnerResultAnalyzer.ts | 2 - .../TestNGRunnerResultAnalyzer.ts | 65 +++++++++++------ test/suite/TestNGAnalyzer.test.ts | 73 +++++++++++++++++++ test/suite/baseRunner.test.ts | 53 ++++++++++++++ 6 files changed, 196 insertions(+), 77 deletions(-) create mode 100644 test/suite/TestNGAnalyzer.test.ts diff --git a/src/runners/baseRunner/BaseRunner.ts b/src/runners/baseRunner/BaseRunner.ts index 354f96b5..7fb9e36e 100644 --- a/src/runners/baseRunner/BaseRunner.ts +++ b/src/runners/baseRunner/BaseRunner.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import * as iconv from 'iconv-lite'; +import { randomUUID } from 'crypto'; import { AddressInfo, createServer, Server, Socket } from 'net'; import * as os from 'os'; import { CancellationToken, debug, DebugAdapterTracker, DebugConfiguration, DebugSession, Disposable, ProviderResult } from 'vscode'; @@ -12,6 +13,8 @@ import { ITestRunnerInternal } from '../ITestRunner'; import { RunnerResultAnalyzer } from './RunnerResultAnalyzer'; import { IExecutionConfig, IRunTestContext } from '../../java-test-runner.api'; +const JAVA_TEST_RUN_ID: string = '__javaTestRunId'; + export abstract class BaseRunner implements ITestRunnerInternal { protected server: Server; protected socket: Socket; @@ -56,37 +59,26 @@ export abstract class BaseRunner implements ITestRunnerInternal { // So we force to use internal console here to make sure the session is still under debugger's control. launchConfiguration.console = 'internalConsole'; - // The debuggee's stdout/stderr are delivered by java-debug as standard DAP `output` events, - // which by default only surface in the Debug Console. Attach a tracker to the test's own debug - // session and forward those output events into the Test Results view, so the program output shows - // up next to the test results instead of being split across two separate surfaces. + const testRunId: string = randomUUID(); + launchConfiguration[JAVA_TEST_RUN_ID] = testRunId; + const isTestSession: (session: DebugSession) => boolean = (session: DebugSession): boolean => + session.configuration[JAVA_TEST_RUN_ID] === testRunId; + + // Mirror the test session's user-visible Debug Console output in Test Results. this.disposables.push(debug.registerDebugAdapterTrackerFactory('java', { createDebugAdapterTracker: (session: DebugSession): ProviderResult => { - if (session.name !== launchConfiguration.name) { + if (!isTestSession(session)) { return undefined; } return { - onDidSendMessage: (message: any): void => { - if (message?.type === 'event' && message.event === 'output') { - const category: string | undefined = message.body?.category; - // `telemetry` output events are not meant for the user. - if (category === 'telemetry') { - return; - } - const output: string | undefined = message.body?.output; - if (output) { - // Let the analyzer attribute the output to the running test when possible. - this.runnerResultAnalyzer.appendProgramOutput(output); - } - } - }, + onDidSendMessage: (message: any): void => this.handleDebugAdapterMessage(message), }; }, })); let debugSession: DebugSession | undefined; this.disposables.push(debug.onDidStartDebugSession((session: DebugSession) => { - if (session.name === launchConfiguration.name) { + if (!debugSession && isTestSession(session)) { debugSession = session; } })); @@ -108,7 +100,7 @@ export abstract class BaseRunner implements ITestRunnerInternal { return await new Promise((resolve: () => void): void => { this.disposables.push( debug.onDidTerminateDebugSession((session: DebugSession): void => { - if (launchConfiguration.name === session.name) { + if (session.id === debugSession?.id) { debugSession = undefined; this.tearDown(); if (data.length > 0) { @@ -125,6 +117,19 @@ export abstract class BaseRunner implements ITestRunnerInternal { })); } + protected handleDebugAdapterMessage(message: any): void { + if (message?.type !== 'event' || message.event !== 'output' || message.body?.category === 'telemetry') { + return; + } + + const output: unknown = message.body?.output; + if (typeof output !== 'string' || output.length === 0) { + return; + } + + this.testContext.testRun.appendOutput(output.replace(/\r?\n/g, '\r\n')); + } + public async tearDown(): Promise { try { if (this.socket) { diff --git a/src/runners/baseRunner/RunnerResultAnalyzer.ts b/src/runners/baseRunner/RunnerResultAnalyzer.ts index b005477d..1009b160 100644 --- a/src/runners/baseRunner/RunnerResultAnalyzer.ts +++ b/src/runners/baseRunner/RunnerResultAnalyzer.ts @@ -10,45 +10,12 @@ export abstract class RunnerResultAnalyzer { // Track parent test item states to update them when all children complete protected parentStates: Map = new Map(); - // Test items that are currently executing. Used to attribute program output - // (captured from the debug session) to the running test when it is unambiguous. - private runningItems: Set = new Set(); - constructor(protected testContext: IRunTestContext) { } public abstract analyzeData(data: string): void; public abstract processData(data: string): void; protected testMessageLocation: Location | undefined; - /** - * Record that a test item has started executing. - */ - protected markItemStarted(item: TestItem): void { - this.runningItems.add(item); - } - - /** - * Record that a test item has finished executing. - */ - protected markItemFinished(item: TestItem): void { - this.runningItems.delete(item); - } - - /** - * Forward program output (captured from the test's debug session as DAP `output` - * events) into the Test Results view. When exactly one test is currently running, - * the output is attributed to that test item so it shows up under the test in the - * explorer; otherwise (idle, or several tests running in parallel where attribution - * would only be a guess) it is appended to the run as a whole. - */ - public appendProgramOutput(output: string): void { - const normalized: string = output.replace(/\r?\n/g, '\r\n'); - const item: TestItem | undefined = this.runningItems.size === 1 - ? this.runningItems.values().next().value - : undefined; - this.testContext.testRun.appendOutput(normalized, undefined, item); - } - /** * Return a string array which contains the stacktraces that need to be filtered. * All the stacktraces which include the element in the return array will be removed. diff --git a/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts b/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts index a847bfb3..ac79b28a 100644 --- a/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts +++ b/src/runners/junitRunner/JUnitRunnerResultAnalyzer.ts @@ -76,7 +76,6 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { this.setDurationAtStart(this.getCurrentState(item)); setTestState(this.testContext.testRun, item, this.getCurrentState(item).resultState); this.updateParentOnChildStart(item); - this.markItemStarted(item); } else if (data.startsWith(MessageId.TestEnd)) { const item: TestItem | undefined = this.getTestItem(data.substr(MessageId.TestEnd.length)); if (!item) { @@ -86,7 +85,6 @@ export class JUnitRunnerResultAnalyzer extends RunnerResultAnalyzer { this.calcDurationAtEnd(currentState); this.determineResultStateAtEnd(data, currentState); setTestState(this.testContext.testRun, item, currentState.resultState, undefined, currentState.duration); - this.markItemFinished(item); const itemData: ITestItemData | undefined = dataCache.get(item); if (itemData?.testLevel === TestLevel.Method) { this.updateParentOnChildComplete(item, currentState.resultState); diff --git a/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts b/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts index 0a8c37fe..0998fecb 100644 --- a/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts +++ b/src/runners/testngRunner/TestNGRunnerResultAnalyzer.ts @@ -10,6 +10,7 @@ import { IRunTestContext, TestLevel, TestResultState } from '../../java-test-run const TEST_START: string = 'testStarted'; const TEST_FAIL: string = 'testFailed'; const TEST_FINISH: string = 'testFinished'; +const TEST_ERROR: string = 'error'; export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { @@ -47,7 +48,7 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { try { this.processData(match[1]); } catch (error) { - this.testContext.testRun.appendOutput(`[ERROR] Failed to parse output data: ${match[1]}\n`); + this.testContext.testRun.appendOutput(`[ERROR] Failed to parse output data: ${match[1]}\r\n`); } } } @@ -55,7 +56,17 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { public processData(data: string): void { const outputData: ITestNGOutputData = JSON.parse(data) as ITestNGOutputData; - const id: string = `${this.projectName}@${outputData.attributes.name}`; + if (outputData.name === TEST_ERROR) { + this.processRunnerError(outputData.attributes); + return; + } + + const attributes: ITestNGAttributes | undefined = outputData.attributes; + if (!attributes?.name) { + return; + } + + const id: string = `${this.projectName}@${attributes.name}`; if (outputData.name === TEST_START) { this.initializeCache(); const item: TestItem | undefined = this.getTestItem(id); @@ -66,7 +77,6 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { this.currentTestState = TestResultState.Running; this.testContext.testRun.started(item); this.updateParentOnChildStart(item); - this.markItemStarted(item); } else if (outputData.name === TEST_FAIL) { const item: TestItem | undefined = this.getTestItem(id); if (!item) { @@ -75,11 +85,11 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { this.currentTestState = TestResultState.Failed; const testMessages: TestMessage[] = []; - if (outputData.attributes.trace) { + if (attributes.trace) { const markdownTrace: MarkdownString = new MarkdownString(); markdownTrace.isTrusted = true; markdownTrace.supportHtml = true; - for (const line of outputData.attributes.trace.split(/\r?\n/)) { + for (const line of attributes.trace.split(/\r?\n/)) { this.processStackTrace(line, markdownTrace, this.currentItem, this.projectName); } @@ -92,19 +102,18 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { } testMessages.push(testMessage); } - const duration: number = Number.parseInt(outputData.attributes.duration, 10); + const duration: number | undefined = this.parseDuration(attributes.duration); setTestState(this.testContext.testRun, item, this.currentTestState, testMessages, duration); } else if (outputData.name === TEST_FINISH) { - const item: TestItem | undefined = this.getTestItem(data); + const item: TestItem | undefined = this.getTestItem(id); if (!item) { return; } if (this.currentTestState === TestResultState.Running) { this.currentTestState = TestResultState.Passed; } - const duration: number = Number.parseInt(outputData.attributes.duration, 10); + const duration: number | undefined = this.parseDuration(attributes.duration); setTestState(this.testContext.testRun, item, this.currentTestState, undefined, duration); - this.markItemFinished(item); const itemData: ITestItemData | undefined = dataCache.get(item); if (itemData?.testLevel === TestLevel.Method) { this.updateParentOnChildComplete(item, this.currentTestState); @@ -126,6 +135,26 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { this.currentItem = undefined; } + private processRunnerError(attributes: ITestNGAttributes | undefined): void { + let message: string = attributes?.message || 'Failed to run TestNG tests.'; + if (attributes?.trace) { + message += `\n${attributes.trace}`; + } + const testMessage: TestMessage = new TestMessage(message); + for (const item of this.testContext.testItems) { + this.testContext.testRun.errored(item, testMessage); + } + } + + private parseDuration(duration: string | undefined): number | undefined { + if (!duration) { + return undefined; + } + + const parsed: number = Number.parseInt(duration, 10); + return Number.isNaN(parsed) ? undefined : parsed; + } + protected getStacktraceFilter(): string[] { return [ 'com.microsoft.java.test.runner.', @@ -142,20 +171,14 @@ export class TestNGRunnerResultAnalyzer extends RunnerResultAnalyzer { } interface ITestNGOutputData { - attributes: ITestNGAttributes; - type: TestOutputType; + attributes?: ITestNGAttributes; name: string; } -enum TestOutputType { - Info, - Error, -} - interface ITestNGAttributes { - name: string; - duration: string; - location: string; - message: string; - trace: string; + name?: string; + duration?: string; + location?: string; + message?: string; + trace?: string; } diff --git a/test/suite/TestNGAnalyzer.test.ts b/test/suite/TestNGAnalyzer.test.ts new file mode 100644 index 00000000..511eebc6 --- /dev/null +++ b/test/suite/TestNGAnalyzer.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +'use strict'; + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { TestController, TestMessage, TestRunRequest, tests, workspace } from 'vscode'; +import { TestNGRunnerResultAnalyzer } from '../../src/runners/testngRunner/TestNGRunnerResultAnalyzer'; +import { IRunTestContext, TestKind } from '../../src/java-test-runner.api'; +import { generateTestItem } from './utils'; + +// tslint:disable: only-arrow-functions +suite('TestNG Runner Analyzer Tests', () => { + + let testController: TestController; + + setup(() => { + testController = tests.createTestController('testngTestController', 'Mock TestNG'); + }); + + teardown(() => { + testController.dispose(); + }); + + test('surfaces runner errors as structured test errors', () => { + const testItem = generateTestItem(testController, 'testng@example.SampleTest#test', TestKind.TestNG); + const testRun = testController.createTestRun(new TestRunRequest([testItem], [])); + const erroredSpy = sinon.spy(testRun, 'errored'); + const runnerContext: IRunTestContext = { + isDebug: false, + kind: TestKind.TestNG, + projectName: 'testng', + testItems: [testItem], + testRun, + workspaceFolder: workspace.workspaceFolders?.[0]!, + }; + const analyzer = new TestNGRunnerResultAnalyzer(runnerContext); + const trace = 'java.lang.ClassNotFoundException: example.SampleTest'; + + analyzer.processData(JSON.stringify({ + name: 'error', + attributes: { + message: 'Failed to run TestNG tests', + trace, + }, + })); + + sinon.assert.calledOnce(erroredSpy); + sinon.assert.calledWith(erroredSpy, testItem, sinon.match.instanceOf(TestMessage)); + const testMessage = erroredSpy.firstCall.args[1] as TestMessage; + assert.strictEqual(testMessage.message, `Failed to run TestNG tests\n${trace}`); + }); + + test('ignores control messages without test attributes', () => { + const testItem = generateTestItem(testController, 'testng@example.SampleTest#test', TestKind.TestNG); + const testRun = testController.createTestRun(new TestRunRequest([testItem], [])); + const appendOutputSpy = sinon.spy(testRun, 'appendOutput'); + const runnerContext: IRunTestContext = { + isDebug: false, + kind: TestKind.TestNG, + projectName: 'testng', + testItems: [testItem], + testRun, + workspaceFolder: workspace.workspaceFolders?.[0]!, + }; + const analyzer = new TestNGRunnerResultAnalyzer(runnerContext); + + analyzer.analyzeData('@@'); + + sinon.assert.notCalled(appendOutputSpy); + }); +}); diff --git a/test/suite/baseRunner.test.ts b/test/suite/baseRunner.test.ts index 8b940846..960ee870 100644 --- a/test/suite/baseRunner.test.ts +++ b/test/suite/baseRunner.test.ts @@ -4,12 +4,17 @@ 'use strict'; import * as assert from 'assert'; +import * as sinon from 'sinon'; import { AddressInfo } from 'net'; import { BaseRunner } from '../../src/runners/baseRunner/BaseRunner'; import { RunnerResultAnalyzer } from '../../src/runners/baseRunner/RunnerResultAnalyzer'; import { IRunTestContext, TestKind } from '../../src/java-test-runner.api'; class TestableBaseRunner extends BaseRunner { + public handleMessage(message: any): void { + this.handleDebugAdapterMessage(message); + } + protected getAnalyzer(): RunnerResultAnalyzer { return {} as RunnerResultAnalyzer; } @@ -63,4 +68,52 @@ suite('BaseRunner Tests', () => { } }); }); + + suite('handleDebugAdapterMessage', () => { + test('forwards non-telemetry output at run level with CRLF newlines', () => { + const appendOutput = sinon.spy(); + const runner = new TestableBaseRunner({ + kind: TestKind.JUnit, + isDebug: false, + projectName: 'test-project', + testItems: [], + testRun: { appendOutput } as any, + workspaceFolder: {} as any, + } as IRunTestContext); + + runner.handleMessage({ + type: 'event', + event: 'output', + body: { + category: 'console', + output: 'first\nsecond\r\n', + }, + }); + + sinon.assert.calledOnceWithExactly(appendOutput, 'first\r\nsecond\r\n'); + }); + + test('ignores telemetry output', () => { + const appendOutput = sinon.spy(); + const runner = new TestableBaseRunner({ + kind: TestKind.JUnit, + isDebug: false, + projectName: 'test-project', + testItems: [], + testRun: { appendOutput } as any, + workspaceFolder: {} as any, + } as IRunTestContext); + + runner.handleMessage({ + type: 'event', + event: 'output', + body: { + category: 'telemetry', + output: 'internal event', + }, + }); + + sinon.assert.notCalled(appendOutput); + }); + }); });