Skip to content
Draft
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: 1 addition & 1 deletion extension/src/Extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ export class Extension {
if (testExtension) {
const testRunnerApi = await testExtension.activate();
if (testRunnerApi) {
const testRunner: GradleTestRunner = this.buildServerController.getGradleTestRunner(testRunnerApi);
const testRunner = new GradleTestRunner(testRunnerApi, this.taskServerClient);
testRunnerApi.registerTestProfile("Delegate Test to Gradle", vscode.TestRunProfileKind.Run, testRunner);
testRunnerApi.registerTestProfile(
"Delegate Test to Gradle (Debug)",
Expand Down
30 changes: 9 additions & 21 deletions extension/src/bs/BuildServerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import { OpenBuildOutputValue, getOpenBuildOutput } from "../util/config";
import * as path from "path";
import * as fse from "fs-extra";
import { GradleTestRunner } from "./GradleTestRunner";

const APPEND_BUILD_LOG_CMD = "_java.gradle.buildServer.appendBuildLog";
const LOG_CMD = "_java.gradle.buildServer.log";
Expand All @@ -26,7 +25,6 @@
private disposable: Disposable;
private buildOutputChannel: OutputChannel;
private logOutputChannel: OutputChannel;
private gradleTestRunner: GradleTestRunner | undefined;

public constructor(readonly context: ExtensionContext) {
this.buildOutputChannel = window.createOutputChannel("Build Server for Gradle (Build)", "gradle-build");
Expand Down Expand Up @@ -62,7 +60,7 @@
}
}),
commands.registerCommand(SEND_TELEMETRY_CMD, (data: string | object | Error) => {
let jsonObj: { [key: string]: any };

Check warning on line 63 in extension/src/bs/BuildServerController.ts

View workflow job for this annotation

GitHub Actions / Build & Analyse

Unexpected any. Specify a different type
if (typeof data === "string") {
jsonObj = JSON.parse(data);
} else {
Expand All @@ -85,18 +83,15 @@
});
}
}),
commands.registerCommand(
"java.gradle.buildServer.onDidFinishTestRun",
(status: number, message?: string) => {
this.gradleTestRunner?.finishTestRun(status, message);
}
),
commands.registerCommand(
"java.gradle.buildServer.onDidChangeTestItemStatus",
(testParts: string[], state: number, displayName?: string, message?: string, duration?: number) => {
this.gradleTestRunner?.updateTestItem(testParts, state, displayName, message, duration);
}
),
// BSP test result callbacks are no longer used — test execution now goes
// through Gradle's runBuild API directly. These commands are kept as no-ops
// for backward compatibility with older JDT LS importer plugins.
commands.registerCommand("java.gradle.buildServer.onDidFinishTestRun", () => {
/* no-op: test results are now parsed from JUnit XML */
}),
commands.registerCommand("java.gradle.buildServer.onDidChangeTestItemStatus", () => {
/* no-op: test results are now parsed from JUnit XML */
}),
workspace.onDidChangeConfiguration((e: ConfigurationChangeEvent) => {
if (e.affectsConfiguration("java.gradle.buildServer.enabled")) {
const storagePath = context.storageUri?.fsPath;
Expand Down Expand Up @@ -124,13 +119,6 @@
this.checkMachineStatus();
}

public getGradleTestRunner(testRunnerApi: any): GradleTestRunner {
if (!this.gradleTestRunner) {
this.gradleTestRunner = new GradleTestRunner(testRunnerApi);
}
return this.gradleTestRunner;
}

public dispose() {
this.disposable.dispose();
}
Expand Down
142 changes: 100 additions & 42 deletions extension/src/bs/GradleTestRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
TestRunner,
TestItemStatusChangeEvent,
TestFinishEvent,
TestResultState,
IRunTestContext,
TestIdParts,
} from "../java-test-runner.api";
import { TaskServerClient } from "../client";
import { parseTestResults, TestCaseResult } from "./testResultParser";
import * as getPort from "get-port";
import { waitOnTcp } from "../util";
import * as os from "os";
Expand All @@ -15,39 +18,60 @@
private readonly _onDidChangeTestItemStatus = new vscode.EventEmitter<TestItemStatusChangeEvent>();
private readonly _onDidFinishTestRun = new vscode.EventEmitter<TestFinishEvent>();
private context: IRunTestContext;
private testRunnerApi: any;

Check warning on line 21 in extension/src/bs/GradleTestRunner.ts

View workflow job for this annotation

GitHub Actions / Build & Analyse

Unexpected any. Specify a different type
private testInitScriptPath: string;

public onDidChangeTestItemStatus: vscode.Event<TestItemStatusChangeEvent> = this._onDidChangeTestItemStatus.event;
public onDidFinishTestRun: vscode.Event<TestFinishEvent> = this._onDidFinishTestRun.event;

constructor(testRunnerApi: any) {
constructor(testRunnerApi: any, private readonly client: TaskServerClient) {

Check warning on line 27 in extension/src/bs/GradleTestRunner.ts

View workflow job for this annotation

GitHub Actions / Build & Analyse

Unexpected any. Specify a different type
this.testRunnerApi = testRunnerApi;
this.testInitScriptPath = path.join(os.tmpdir(), "testInitScript.gradle");
}

public async launch(context: IRunTestContext): Promise<void> {
this.context = context;
const tests: Map<string, string[]> = new Map();

// Build --tests filter arguments from test items
const testFilters: string[] = [];
context.testItems.forEach((testItem) => {
const id = testItem.id;
const parts: TestIdParts = this.testRunnerApi.parsePartsFromTestId(id);
if (!parts.class) {
return;
}
const testMethods = tests.get(parts.class) || [];
if (parts.invocations?.length) {
let methodId = parts.invocations[0];
if (methodId.includes("(")) {
methodId = methodId.slice(0, methodId.indexOf("(")); // gradle test task doesn't support method with parameters
methodId = methodId.slice(0, methodId.indexOf("("));
}
testMethods.push(methodId);
testFilters.push(`${parts.class}.${methodId}`);
} else {
testFilters.push(parts.class);
}
tests.set(parts.class, testMethods);
});

const agrs = context.testConfig?.args ?? [];
if (testFilters.length === 0) {
this.finishTestRun(0);
return;
}

// Build gradle args: test --tests "filter1" --tests "filter2" ...
const gradleArgs: string[] = ["test"];
for (const filter of testFilters) {
gradleArgs.push("--tests", filter);
}

const userArgs = context.testConfig?.args ?? [];
gradleArgs.push(...userArgs);

const vmArgs = context.testConfig?.vmArgs;
if (vmArgs?.length) {
for (const vmArg of vmArgs) {
gradleArgs.push(`-Dorg.gradle.jvmargs=${vmArg}`);
}
}

const isDebug = context.isDebug && !!vscode.extensions.getExtension("vscjava.vscode-java-debug");
let debugPort = -1;
if (isDebug) {
Expand All @@ -57,49 +81,83 @@
vscode.Uri.file(this.testInitScriptPath),
Buffer.from(initScriptContent)
);
agrs.unshift("--init-script", this.testInitScriptPath);
gradleArgs.unshift("--init-script", this.testInitScriptPath);
}
const env = context.testConfig?.env;

const projectFolder = context.workspaceFolder.uri.fsPath;

// Mark all test items as running
context.testItems.forEach((testItem) => {
const id = testItem.id;
const parts: TestIdParts = this.testRunnerApi.parsePartsFromTestId(id);
if (parts.class) {
const testId = this.testRunnerApi.parseTestIdFromParts({
project: context.projectName,
class: parts.class,
invocations: parts.invocations,
});
this._onDidChangeTestItemStatus.fire({
testId,
state: TestResultState.Running,
});
}
});

// Start debug attachment concurrently — the init script sets suspend=y,
// so the test JVM blocks until the debugger connects. We must start
// waiting for the debug port BEFORE runBuild, otherwise it's a deadlock.
if (isDebug) {
this.startJavaDebug(debugPort);
}

try {
await vscode.commands.executeCommand(
"java.execute.workspaceCommand",
"java.gradle.delegateTest",
context.projectName,
JSON.stringify([...tests]),
agrs,
vmArgs,
env
await this.client.runBuild(
projectFolder,
`gradleTestRun-${Date.now()}`,
gradleArgs,
"",
isDebug ? debugPort : 0
);
if (isDebug) {
this.startJavaDebug(debugPort);
}

// Parse JUnit XML results and emit status events
const results = await parseTestResults(context.workspaceFolder.uri);
this.emitTestResults(results);
this.finishTestRun(0);
} catch (error) {
this.finishTestRun(-1, error.message);
// Gradle exits with non-zero when tests fail — still parse results
try {
const results = await parseTestResults(context.workspaceFolder.uri);
if (results.length > 0) {
this.emitTestResults(results);
this.finishTestRun(0);
} else {
this.finishTestRun(1, error.message || "Gradle test execution failed");
}
} catch {
this.finishTestRun(1, error.message || "Gradle test execution failed");
}
}
}

public updateTestItem(
testParts: string[],
state: number,
displayName?: string,
message?: string,
duration?: number
): void {
if (message) {
message = this.filterStackTrace(message);
private emitTestResults(results: TestCaseResult[]): void {
for (const result of results) {
let message = result.message;
if (message) {
message = this.filterStackTrace(message);
}
const testId = this.testRunnerApi.parseTestIdFromParts({
project: this.context.projectName,
class: result.className,
invocations: [result.methodName],
});
this._onDidChangeTestItemStatus.fire({
testId,
state: result.state,
displayName: result.displayName,
message,
duration: result.duration,
});
}
const testId = this.testRunnerApi.parseTestIdFromParts({
project: this.context.projectName,
class: testParts[0],
invocations: testParts.slice(1),
});
this._onDidChangeTestItemStatus.fire({
testId,
state,
displayName,
message,
duration,
});
}

public finishTestRun(statusCode: number, message?: string): void {
Expand Down
116 changes: 116 additions & 0 deletions extension/src/bs/testResultParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as vscode from "vscode";
import { TestResultState } from "../java-test-runner.api";

export interface TestCaseResult {
className: string;
methodName: string;
displayName?: string;
duration?: number;
state: TestResultState;
message?: string;
}

/**
* Parses JUnit XML test result files from the Gradle build output directory.
* Gradle writes standard JUnit XML to `build/test-results/<taskName>/`.
*/
export async function parseTestResults(projectDir: vscode.Uri, taskName = "test"): Promise<TestCaseResult[]> {
const resultsDir = vscode.Uri.joinPath(projectDir, "build", "test-results", taskName);
const results: TestCaseResult[] = [];

let files: [string, vscode.FileType][];
try {
files = await vscode.workspace.fs.readDirectory(resultsDir);
} catch {
return results;
}

for (const [fileName, fileType] of files) {
if (fileType !== vscode.FileType.File || !fileName.endsWith(".xml")) {
continue;
}
const fileUri = vscode.Uri.joinPath(resultsDir, fileName);
try {
const content = Buffer.from(await vscode.workspace.fs.readFile(fileUri)).toString("utf-8");
results.push(...parseJUnitXml(content));
} catch {
// skip unreadable files
}
}

return results;
}

/**
* Parses a single JUnit XML file content into test case results.
*
* JUnit XML format:
* <testsuite name="com.example.MyTest" tests="2" failures="1" errors="0" skipped="0" time="0.123">
* <testcase name="testMethod" classname="com.example.MyTest" time="0.05">
* <failure message="expected ...">stack trace</failure>
* </testcase>
* <testcase name="testOther" classname="com.example.MyTest" time="0.01"/>
* </testsuite>
*/
function parseJUnitXml(xml: string): TestCaseResult[] {
const results: TestCaseResult[] = [];
const testCaseRegex = /<testcase\s+([^>]*)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
let match: RegExpExecArray | null;

while ((match = testCaseRegex.exec(xml)) !== null) {
const attrs = match[1];
const body = match[2] || "";

const methodName = getAttr(attrs, "name");
const className = getAttr(attrs, "classname");
if (!methodName || !className) {
continue;
}

const timeStr = getAttr(attrs, "time");
const duration = timeStr ? Math.round(parseFloat(timeStr) * 1000) : undefined;

let state: TestResultState = TestResultState.Passed;
let message: string | undefined;

const failureMatch = /<failure\b[^>]*>([\s\S]*?)<\/failure>/i.exec(body);
const errorMatch = /<error\b[^>]*>([\s\S]*?)<\/error>/i.exec(body);
const skippedMatch = /<skipped\b/i.exec(body);

if (failureMatch) {
state = TestResultState.Failed;
message = failureMatch[1]?.trim();
} else if (errorMatch) {
state = TestResultState.Errored;
message = errorMatch[1]?.trim();
} else if (skippedMatch) {
state = TestResultState.Skipped;
}

results.push({
className,
methodName,
displayName: methodName,
duration,
state,
message,
});
}

return results;
}

function getAttr(attrs: string, name: string): string | undefined {
const regex = new RegExp(`${name}\\s*=\\s*"([^"]*)"`, "i");
const match = regex.exec(attrs);
return match ? decodeXmlEntities(match[1]) : undefined;
}

function decodeXmlEntities(str: string): string {
return str
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
Loading