diff --git a/README.md b/README.md index 8102c99..4d0ac1f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/package.json b/package.json index 61bd666..ed7caf8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/execution/mavenRunner.ts b/src/execution/mavenRunner.ts index 35455de..5f78265 100644 --- a/src/execution/mavenRunner.ts +++ b/src/execution/mavenRunner.ts @@ -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 { @@ -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}`); } @@ -130,6 +115,49 @@ export class MavenRunner implements BuildToolRunner { return cmd; } + async assembleCompileCommand(options: RunOptions): Promise { + 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); } @@ -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); + } } diff --git a/src/execution/testExecutor.ts b/src/execution/testExecutor.ts index 61447ac..ed9bea0 100644 --- a/src/execution/testExecutor.ts +++ b/src/execution/testExecutor.ts @@ -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) { @@ -250,6 +260,83 @@ export class TestExecutor { return result.exitCode; } + private async compileProject( + options: RunOptions, + run: vscode.TestRun, + cancellation: vscode.CancellationToken, + ): Promise { + 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 { + 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 { + 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. */ diff --git a/src/execution/types.ts b/src/execution/types.ts index 0efe582..17b8671 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -92,6 +92,9 @@ export interface BuildToolRunner { resolveExecutable(projectRoot: string, workspaceRoot?: string): Promise; assembleCommand(options: RunOptions): Promise; assembleDebugCommand(options: RunOptions, debugPort: number): Promise; + assembleCompileCommand(options: RunOptions): Promise; + assembleCucumberCliCommand(options: RunOptions): CommandSpec; + assembleCucumberCliDebugCommand(options: RunOptions, debugPort: number): CommandSpec; getResultsFilePath(projectRoot: string): string; readExistingPlugins(projectRoot: string): Promise; } diff --git a/src/test/unit/mavenRunner.test.ts b/src/test/unit/mavenRunner.test.ts index 6f38f47..2b71688 100644 --- a/src/test/unit/mavenRunner.test.ts +++ b/src/test/unit/mavenRunner.test.ts @@ -209,66 +209,6 @@ describe('MavenRunner', () => { assert.ok(cmd.args.includes('-Dtest=com.example.RunCucumber')); }); - it('uses engine filtering when feature targets are provided (avoids double execution)', async () => { - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: ['src/test/resources/login.feature:10'], - runnerClass: 'CucumberTest', - }); - assert.ok(cmd.args.includes('-Dsurefire.includeJUnit5Engines=cucumber'), - 'Should include engine filter'); - assert.ok(!cmd.args.some(a => a.startsWith('-Dtest=!')), - 'Should NOT use -Dtest=! exclusion'); - }); - - it('uses engine filtering even without runnerClass when feature targets are provided', async () => { - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: ['src/test/resources/login.feature:10'], - }); - assert.ok(cmd.args.includes('-Dsurefire.includeJUnit5Engines=cucumber'), - 'Should include engine filter even without runnerClass'); - assert.ok(!cmd.args.some(a => a.startsWith('-Dtest=')), - 'Should not have any -Dtest arg'); - }); - - it('reads cucumber.glue from junit-platform.properties when feature targets are provided', async () => { - const propsDir = path.join(tmpDir, 'src', 'test', 'resources'); - mkdirp(propsDir); - fs.writeFileSync( - path.join(propsDir, 'junit-platform.properties'), - 'cucumber.glue = com.example.steps\n', - ); - - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: ['src/test/resources/login.feature:10'], - }); - assert.ok(cmd.args.includes('-Dcucumber.glue=com.example.steps'), - 'Should pass glue from properties file'); - }); - - it('omits -Dcucumber.glue when not configured anywhere', async () => { - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: ['src/test/resources/login.feature:10'], - }); - assert.ok(!cmd.args.some(a => a.startsWith('-Dcucumber.glue=')), - 'Should not have glue arg when not configured'); - }); - - it('does not use engine filtering when featureTargets is empty (Run All)', async () => { - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: [], - runnerClass: 'com.example.RunCucumber', - }); - assert.ok(!cmd.args.includes('-Dsurefire.includeJUnit5Engines=cucumber'), - 'Should not include engine filter for Run All'); - assert.ok(cmd.args.includes('-Dtest=com.example.RunCucumber'), - 'Should use -Dtest for Run All'); - }); - it('omits -Dtest when runnerClass is not provided', async () => { const cmd = await runner.assembleCommand({ projectRoot: tmpDir, @@ -277,24 +217,6 @@ describe('MavenRunner', () => { assert.ok(!cmd.args.some(a => a.startsWith('-Dtest='))); }); - it('includes -Dcucumber.features for feature targets', async () => { - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: ['src/test/resources/login.feature:10', 'src/test/resources/login.feature:20'], - }); - assert.ok(cmd.args.includes( - '-Dcucumber.features=src/test/resources/login.feature:10,src/test/resources/login.feature:20', - )); - }); - - it('omits -Dcucumber.features when featureTargets is empty', async () => { - const cmd = await runner.assembleCommand({ - projectRoot: tmpDir, - featureTargets: [], - }); - assert.ok(!cmd.args.some(a => a.startsWith('-Dcucumber.features='))); - }); - it('includes -Dcucumber.filter.tags when tag expression provided', async () => { const cmd = await runner.assembleCommand({ projectRoot: tmpDir, @@ -401,12 +323,12 @@ describe('MavenRunner', () => { it('includes all base command args plus debug arg', async () => { const baseCmd = await runner.assembleCommand({ projectRoot: tmpDir, - featureTargets: ['f.feature:1'], + featureTargets: [], runnerClass: 'com.example.Run', }); const debugCmd = await runner.assembleDebugCommand({ projectRoot: tmpDir, - featureTargets: ['f.feature:1'], + featureTargets: [], runnerClass: 'com.example.Run', }, 9999); @@ -417,4 +339,107 @@ describe('MavenRunner', () => { assert.equal(debugCmd.args.length, baseCmd.args.length + 1); }); }); + + // --- assembleCompileCommand --- + + describe('assembleCompileCommand()', () => { + it('runs test-compile and dependency:build-classpath', async () => { + const cmd = await runner.assembleCompileCommand({ + projectRoot: tmpDir, + featureTargets: [], + }); + assert.equal(cmd.args[0], 'test-compile'); + assert.equal(cmd.args[1], 'dependency:build-classpath'); + assert.ok(cmd.args[2].startsWith('-Dmdep.outputFile=')); + assert.ok(cmd.args[2].includes('cp.txt')); + assert.equal(cmd.cwd, tmpDir); + }); + }); + + // --- assembleCucumberCliCommand --- + + describe('assembleCucumberCliCommand()', () => { + it('invokes io.cucumber.core.cli.Main with feature targets', () => { + // Write a classpath file (simulates compile step output) + mkdirp(path.join(tmpDir, 'target')); + fs.writeFileSync(path.join(tmpDir, 'target', 'cp.txt'), '/some/dep.jar'); + + const cmd = runner.assembleCucumberCliCommand({ + projectRoot: tmpDir, + featureTargets: ['src/test/resources/login.feature:10'], + }); + assert.ok(cmd.args.includes('io.cucumber.core.cli.Main')); + assert.ok(cmd.args.includes('src/test/resources/login.feature:10')); + assert.ok(cmd.args.some(a => a.startsWith('json:')), + 'Should include json plugin'); + }); + + it('includes --glue from junit-platform.properties', () => { + mkdirp(path.join(tmpDir, 'target')); + fs.writeFileSync(path.join(tmpDir, 'target', 'cp.txt'), '/some/dep.jar'); + const propsDir = path.join(tmpDir, 'src', 'test', 'resources'); + mkdirp(propsDir); + fs.writeFileSync( + path.join(propsDir, 'junit-platform.properties'), + 'cucumber.glue = com.example.steps\n', + ); + + const cmd = runner.assembleCucumberCliCommand({ + projectRoot: tmpDir, + featureTargets: ['f.feature:1'], + }); + const glueIdx = cmd.args.indexOf('--glue'); + assert.ok(glueIdx >= 0, 'Should have --glue flag'); + assert.equal(cmd.args[glueIdx + 1], 'com.example.steps'); + }); + + it('includes --tags when tag expression provided', () => { + mkdirp(path.join(tmpDir, 'target')); + fs.writeFileSync(path.join(tmpDir, 'target', 'cp.txt'), '/some/dep.jar'); + + const cmd = runner.assembleCucumberCliCommand({ + projectRoot: tmpDir, + featureTargets: ['f.feature:1'], + tagExpression: 'not @wip', + }); + const tagsIdx = cmd.args.indexOf('--tags'); + assert.ok(tagsIdx >= 0, 'Should have --tags flag'); + assert.equal(cmd.args[tagsIdx + 1], 'not @wip'); + }); + + it('builds classpath from cp.txt + target dirs', () => { + mkdirp(path.join(tmpDir, 'target')); + fs.writeFileSync(path.join(tmpDir, 'target', 'cp.txt'), '/dep1.jar:/dep2.jar'); + + const cmd = runner.assembleCucumberCliCommand({ + projectRoot: tmpDir, + featureTargets: ['f.feature:1'], + }); + const cpIdx = cmd.args.indexOf('-cp'); + const cp = cmd.args[cpIdx + 1]; + assert.ok(cp.includes('test-classes'), 'Classpath should include target/test-classes'); + assert.ok(cp.includes('classes'), 'Classpath should include target/classes'); + assert.ok(cp.includes('dep1.jar'), 'Classpath should include dependencies'); + }); + }); + + // --- assembleCucumberCliDebugCommand --- + + describe('assembleCucumberCliDebugCommand()', () => { + it('inserts JDWP agent before main class', () => { + mkdirp(path.join(tmpDir, 'target')); + fs.writeFileSync(path.join(tmpDir, 'target', 'cp.txt'), '/some/dep.jar'); + + const cmd = runner.assembleCucumberCliDebugCommand({ + projectRoot: tmpDir, + featureTargets: ['f.feature:1'], + }, 5005); + + const mainIdx = cmd.args.indexOf('io.cucumber.core.cli.Main'); + const jdwpIdx = cmd.args.findIndex(a => a.includes('jdwp')); + assert.ok(jdwpIdx >= 0, 'Should have JDWP arg'); + assert.ok(jdwpIdx < mainIdx, 'JDWP arg should come before main class'); + assert.ok(cmd.args[jdwpIdx].includes('address=localhost:5005')); + }); + }); });