Skip to content
Merged
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Run and debug Cucumber BDD scenarios from VS Code's native Test Explorer.

## Requirements

- **Maven** — the project must use Maven with `maven-surefire-plugin` 3.x+
- **Java** — `JAVA_HOME` must be set, or `java` must be on your PATH
- **Maven** — the project must use Maven with `maven-surefire-plugin`
- **`cucumber-junit-platform-engine`** — Cucumber's JUnit Platform integration must be in your test dependencies
- **Debugger for Java** (`vscjava.vscode-java-debug`) — required only for debug mode

Expand Down Expand Up @@ -111,9 +112,10 @@ If the version in `package.json` is unchanged, CI skips publishing. Only version
## How It Works

1. **Discovery**: Parses `.feature` files using the official `@cucumber/gherkin` parser (the same one the Cucumber VS Code extension uses)
2. **Execution**: Runs `mvn test` with `-Dcucumber.features=path/to/file.feature:lineNumber` to target specific scenarios
3. **Results**: Parses Cucumber's JSON reporter output and maps results back to test items by feature URI and line number
4. **Debug**: Starts Maven Surefire with JDWP debug arguments, polls the debug port, then attaches VS Code's Java debugger
2. **Execution (specific scenarios)**: Compiles with `mvn test-compile`, then runs `io.cucumber.core.cli.Main` directly — bypasses Surefire for reliable single-scenario execution
3. **Execution (Run All)**: Runs `mvn test` with the detected runner class
4. **Results**: Parses Cucumber's JSON reporter output and maps results back to test items by feature URI and line number
5. **Debug**: Runs `io.cucumber.core.cli.Main` with JDWP debug arguments, polls the debug port, then attaches VS Code's Java debugger

## License

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "cucumber-java-test-runner",
"displayName": "Cucumber Test Runner for Java",
"description": "Run and debug Cucumber BDD scenarios from VS Code's native Test Explorer. Integrates with Maven and cucumber-junit-platform-engine.",
"version": "0.1.3",
"version": "0.2.0",
"publisher": "arunkris",
"license": "MIT",
"repository": {
Expand Down
82 changes: 65 additions & 17 deletions src/execution/mavenRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BuildToolRunner, RunOptions, CommandSpec } from './types';
import * as config from '../config/configuration';

const RESULTS_FILENAME = 'cucumber-vscode-results.json';
const CLASSPATH_FILENAME = 'cp.txt';
const JUNIT_PLATFORM_PROPERTIES = 'junit-platform.properties';

export class MavenRunner implements BuildToolRunner {
Expand Down Expand Up @@ -78,23 +79,7 @@ export class MavenRunner implements BuildToolRunner {

const args: string[] = ['test'];

if (options.featureTargets.length > 0) {
args.push(`-Dcucumber.features=${options.featureTargets.join(',')}`);

// Only run the Cucumber engine — prevents non-Cucumber tests from
// executing and avoids double execution through the Suite engine.
// User property name: surefire.includeJUnit5Engines (fixed in SUREFIRE-2059).
args.push('-Dsurefire.includeJUnit5Engines=cucumber');

// Pass glue so the Cucumber engine finds step definitions without
// scanning the entire classpath.
const glue = config.getGlue()
?? this.readJunitPlatformProperty(options.projectRoot, 'cucumber.glue');
if (glue) {
args.push(`-Dcucumber.glue=${glue}`);
}
} else if (options.runnerClass) {
// Running ALL tests — use the runner class to scope to Cucumber only.
if (options.runnerClass) {
args.push(`-Dtest=${options.runnerClass}`);
}

Expand Down Expand Up @@ -130,6 +115,49 @@ export class MavenRunner implements BuildToolRunner {
return cmd;
}

async assembleCompileCommand(options: RunOptions): Promise<CommandSpec> {
const executable = await this.resolveExecutable(options.projectRoot, options.workspaceRoot);
const cpFile = path.join(options.projectRoot, 'target', CLASSPATH_FILENAME);
return {
executable,
args: ['test-compile', 'dependency:build-classpath', `-Dmdep.outputFile=${cpFile}`],
cwd: options.projectRoot,
};
}

assembleCucumberCliCommand(options: RunOptions): CommandSpec {
const java = this.resolveJavaExecutable();
const classpath = this.resolveTestClasspath(options.projectRoot);
const resultsPath = this.getResultsFilePath(options.projectRoot);

const args: string[] = ['-cp', classpath, 'io.cucumber.core.cli.Main'];

args.push('--plugin', `json:${resultsPath.replace(/\\/g, '/')}`);

const glue = config.getGlue()
?? this.readJunitPlatformProperty(options.projectRoot, 'cucumber.glue');
if (glue) {
args.push('--glue', glue);
}

if (options.tagExpression) {
args.push('--tags', options.tagExpression);
}

args.push(...options.featureTargets);

return { executable: java, args, cwd: options.projectRoot };
}

assembleCucumberCliDebugCommand(options: RunOptions, debugPort: number): CommandSpec {
const cmd = this.assembleCucumberCliCommand(options);
const mainClassIdx = cmd.args.indexOf('io.cucumber.core.cli.Main');
cmd.args.splice(mainClassIdx, 0,
`-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=localhost:${debugPort}`,
);
return cmd;
}

getResultsFilePath(projectRoot: string): string {
return path.join(projectRoot, 'target', RESULTS_FILENAME);
}
Expand Down Expand Up @@ -158,4 +186,24 @@ export class MavenRunner implements BuildToolRunner {

return undefined;
}

private resolveJavaExecutable(): string {
const javaHome = process.env.JAVA_HOME;
if (javaHome) {
const javaBin = path.join(javaHome, 'bin', 'java');
if (fs.existsSync(javaBin) || fs.existsSync(javaBin + '.exe')) {
return javaBin;
}
}
return 'java';
}

private resolveTestClasspath(projectRoot: string): string {
const cpFile = path.join(projectRoot, 'target', CLASSPATH_FILENAME);
const deps = fs.readFileSync(cpFile, 'utf-8').trim();
const sep = process.platform === 'win32' ? ';' : ':';
const testClasses = path.join(projectRoot, 'target', 'test-classes');
const classes = path.join(projectRoot, 'target', 'classes');
return [testClasses, classes, deps].filter(Boolean).join(sep);
}
}
93 changes: 90 additions & 3 deletions src/execution/testExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,20 @@ export class TestExecutor {
const resultsPath = this.buildToolRunner.getResultsFilePath(projectRoot);
this.deleteFileIfExists(resultsPath);

if (debug) {
await this.executeDebug(runOptions, projectRoot, run, cancellation);
if (featureTargets.length > 0) {
// Specific scenarios: compile then run Cucumber CLI directly
if (debug) {
await this.executeCucumberCliDebug(runOptions, run, cancellation);
} else {
await this.executeCucumberCli(runOptions, run, cancellation);
}
} else {
await this.executeRun(runOptions, run, cancellation);
// Run All: Maven test with runner class
if (debug) {
await this.executeDebug(runOptions, projectRoot, run, cancellation);
} else {
await this.executeRun(runOptions, run, cancellation);
}
}

if (!cancellation.isCancellationRequested) {
Expand Down Expand Up @@ -250,6 +260,83 @@ export class TestExecutor {
return result.exitCode;
}

private async compileProject(
options: RunOptions,
run: vscode.TestRun,
cancellation: vscode.CancellationToken,
): Promise<number> {
const compileCmd = await this.buildToolRunner.assembleCompileCommand(options);
this.logger.info(`Compiling: ${compileCmd.executable} ${compileCmd.args.join(' ')}`);
run.appendOutput(`> ${compileCmd.executable} ${compileCmd.args.join(' ')}\r\n\r\n`);

const result = await spawnProcess(compileCmd.executable, compileCmd.args, {
cwd: compileCmd.cwd,
onStdout: (line) => run.appendOutput(line + '\r\n'),
onStderr: (line) => run.appendOutput(line + '\r\n'),
cancellation,
});

if (result.exitCode !== 0 || result.killed) {
this.logger.info(`Compilation failed (exit code ${result.exitCode})`);
}

return result.exitCode;
}

private async executeCucumberCli(
options: RunOptions,
run: vscode.TestRun,
cancellation: vscode.CancellationToken,
): Promise<number> {
const compileExit = await this.compileProject(options, run, cancellation);
if (compileExit !== 0) return compileExit;

const cmd = this.buildToolRunner.assembleCucumberCliCommand(options);
this.logger.info(`Running: ${cmd.executable} ${cmd.args.join(' ')}`);
run.appendOutput(`\r\n> ${cmd.executable} ${cmd.args.join(' ')}\r\n\r\n`);

const result = await spawnProcess(cmd.executable, cmd.args, {
cwd: cmd.cwd,
onStdout: (line) => run.appendOutput(line + '\r\n'),
onStderr: (line) => run.appendOutput(line + '\r\n'),
cancellation,
});

if (result.killed) {
this.logger.info('Test execution was cancelled');
} else {
this.logger.info(`Cucumber CLI exited with code ${result.exitCode}`);
}

return result.exitCode;
}

private async executeCucumberCliDebug(
options: RunOptions,
run: vscode.TestRun,
cancellation: vscode.CancellationToken,
): Promise<number> {
const compileExit = await this.compileProject(options, run, cancellation);
if (compileExit !== 0) return compileExit;

const workspaceFolder = vscode.workspace.getWorkspaceFolder(
vscode.Uri.file(options.projectRoot),
) ?? vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
throw new Error('No workspace folder found for debug session');
}
const port = await this.debugManager.findAvailablePort();
const cmd = this.buildToolRunner.assembleCucumberCliDebugCommand(options, port);
this.logger.info(`Debug: ${cmd.executable} ${cmd.args.join(' ')}`);
run.appendOutput(`\r\n> [DEBUG] ${cmd.executable} ${cmd.args.join(' ')}\r\n\r\n`);

const result = await this.debugManager.executeWithDebug(
cmd, port, workspaceFolder, run, cancellation,
);

return result.exitCode;
}

/**
* Parses the Cucumber JSON results file and reports results to the TestRun.
*/
Expand Down
3 changes: 3 additions & 0 deletions src/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export interface BuildToolRunner {
resolveExecutable(projectRoot: string, workspaceRoot?: string): Promise<string>;
assembleCommand(options: RunOptions): Promise<CommandSpec>;
assembleDebugCommand(options: RunOptions, debugPort: number): Promise<CommandSpec>;
assembleCompileCommand(options: RunOptions): Promise<CommandSpec>;
assembleCucumberCliCommand(options: RunOptions): CommandSpec;
assembleCucumberCliDebugCommand(options: RunOptions, debugPort: number): CommandSpec;
getResultsFilePath(projectRoot: string): string;
readExistingPlugins(projectRoot: string): Promise<string[]>;
}
Expand Down
Loading