diff --git a/CHANGELOG.md b/CHANGELOG.md index a54fac1..c30f6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to DebugMCP will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed +- Launch named RSpec examples using exact debugger CodeLens targets; preserve the first stop and the existing Testing API route for other languages. + ## [2.3.5] - 2026-09-09 ### Fixed diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index 50a83c8..a4f59a2 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -38,6 +38,13 @@ VS Code's debug API is powerful but requires careful handling. `DebuggingExecuto ## Key Concepts +### Single-test dispatch + +For `*_spec.rb`, exact example names outrank suffix-only provider names. The requested definition line or containing +range disambiguates candidates; otherwise ambiguous matches raise an error rather than launching another example. Modern Ruby RSpec CodeLenses omit the launch program, so the executor combines the configured +RSpec command with the entire `file:line` quoted as one POSIX shell argument and starts `ruby_lsp` directly. Every other language and test type retains +the original `testing.debugAtCursor` path without CodeLens interception. + ### Startup Failure Diagnostics `src/utils/debugStartup.ts` observes task lifecycle events before dispatching a diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index f5d55e3..07dcd72 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -111,6 +111,10 @@ than being interpreted as successful test completion. Expression evaluation also distinguishes an adapter error from a successful command whose result/output was not captured. +## RSpec stops + +Named RSpec examples use exact debugger CodeLens dispatch. The first stopped frame is returned unchanged; the handler never infers an entry pause from source-breakpoint mismatch or automatically continues it. See [RSpec debugging](../rspec-debugging.md). + ## Variable inspection For `ruby_lsp` sessions, Ruby scalar values keep their result even when rdbg attaches metadata children. Synthetic `#class` and `%ancestors` children are omitted only for Ruby. Existing secret redaction and names/types-only descendant rendering remain in force. diff --git a/docs/rspec-debugging.md b/docs/rspec-debugging.md new file mode 100644 index 0000000..6b5073a --- /dev/null +++ b/docs/rspec-debugging.md @@ -0,0 +1,38 @@ +# RSpec debugging + +Requires Shopify Ruby LSP, the `debug` gem, and `ruby-lsp-rspec` in the active bundle, with CodeLens enabled. + +## Debugging one RSpec example + +Set the breakpoint in the application or example, then call `start_debugging` with: + +- `fileFullPath` set to the spec file; +- `workingDirectory` set to the bundle root; +- `testName` set to the example you want to debug. + +DebugMCP prefers the matching Ruby LSP debugger CodeLens and preserves its exact `file:line` target. This matters for +nested example groups and files containing many examples; a whole-file launch can exercise unrelated setup and hide the +original failure. Exact names take priority over suffix matches; ambiguous matches fail with a diagnostic instead +of selecting an unrelated example. Generated `file:line` targets are quoted as a single POSIX shell argument, including +embedded quotes and expansion characters. Provider-supplied programs and the configured runner command stay unchanged. + +If the debugger CodeLens is missing: + +1. Confirm `ruby-lsp-rspec` is in the active bundle and run `bundle install`. +2. Confirm Ruby LSP's `codeLens` feature is enabled. +3. Run **Ruby LSP: Restart** and inspect the **Ruby LSP** Output channel for activation or bundle errors. +4. If the project needs a wrapper, container command, or non-default bundle, configure the add-on's `rspecCommand`: + +```json +{ + "rubyLsp.addonSettings": { + "Ruby LSP RSpec": { + "rspecCommand": "bin/rspec" + } + } +} +``` + +`rdbg` may pause before the requested breakpoint. DebugMCP returns that first stop unchanged. Inspect it before +calling `continue_execution`: an unmatched source breakpoint does not distinguish entry from an exception or an +explicit `debugger` stop. Automatic continuation is deliberately not part of this dispatch path. diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index c499067..54d6fb6 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -7,7 +7,8 @@ import { withTimeout } from './utils/withTimeout'; import { getDebugStartupContext, startDebuggingWithDiagnostics } from './utils/debugStartup'; /** - * Outcome of dispatching `testing.debugAtCursor`. + * Outcome of dispatching a test debugger through the RSpec CodeLens or + * `testing.debugAtCursor`. * * `started` indicates the command was dispatched successfully. * `runComplete` resolves when the underlying test run *finishes* (pass, fail, @@ -19,6 +20,99 @@ import { getDebugStartupContext, startDebuggingWithDiagnostics } from './utils/d export interface TestDebugDispatch { started: boolean; runComplete: Promise; + description?: string; +} + +interface PositionedTest { + uri: vscode.Uri; + codeLensTarget: vscode.Position; + target: vscode.Position; +} + +export function findDebugCodeLens( + codeLenses: readonly vscode.CodeLens[], + target: vscode.Position, + testName?: string +): vscode.CodeLens | undefined { + const debuggerCodeLenses = codeLenses.filter(codeLens => + codeLens.command && /debug/i.test(codeLens.command.command) + ); + const exactMatches = testName + ? debuggerCodeLenses.filter(codeLens => codeLensCommandMatchesTest(codeLens.command, testName, true)) + : []; + const suffixMatches = testName && exactMatches.length === 0 + ? debuggerCodeLenses.filter(codeLens => codeLensCommandMatchesTest(codeLens.command, testName, false)) + : []; + const namedMatches = exactMatches.length > 0 ? exactMatches : suffixMatches; + const candidates = namedMatches.length > 0 ? namedMatches : debuggerCodeLenses; + const sameLine = candidates.filter(codeLens => codeLens.range.start.line === target.line); + const containing = candidates.filter(codeLens => codeLens.range.contains(target)); + const positioned = sameLine.length > 0 ? sameLine : containing; + const ranked = (positioned.length > 0 ? positioned : namedMatches) + .slice().sort((left, right) => rangeWeight(left.range) - rangeWeight(right.range)); + + // A smaller range only disambiguates lenses at the requested position. + // Never select an unrelated example merely because its range is shorter. + if (ranked.length > 1 && (positioned.length === 0 || + rangeWeight(ranked[0].range) === rangeWeight(ranked[1].range))) { + throw new Error(`Ambiguous debugger CodeLens for test '${testName ?? ''}'; use a unique example name or an explicit launch configuration.`); + } + return ranked[0]; +} + +export function addRubyRspecProgram( + command: vscode.Command, + fileFullPath: string, + line: number, + rspecCommand: string +): vscode.Command { + const existingProgram = command.arguments?.[2]; + if ( + command.command !== 'rubyLsp.debugTest' || + !fileFullPath.endsWith('_spec.rb') || + (typeof existingProgram === 'string' && existingProgram.trim().length > 0) + ) { + return command; + } + + const args = [ ...(command.arguments ?? []) ]; + while (args.length < 2) { + args.push(undefined); + } + const target = `${fileFullPath}:${line}`; + // Ruby LSP passes program verbatim to a shell. Quote the complete selector, + // including the line number; embedded single quotes must leave/re-enter it. + const quotedTarget = `'${target.replace(/'/g, "'\"'\"'")}'`; + args[2] = `${rspecCommand} ${quotedTarget}`; + return { ...command, arguments: args }; +} + +export function rubyRspecDebugConfiguration(program: string): vscode.DebugConfiguration { + return { + type: 'ruby_lsp', + name: 'Debug', + request: 'launch', + program, + env: { DISABLE_SPRING: '1' } + }; +} + +export function shouldUseDebuggerCodeLens(fileFullPath: string): boolean { + return fileFullPath.endsWith('_spec.rb'); +} + +function codeLensCommandMatchesTest(command: vscode.Command | undefined, testName: string, exact: boolean): boolean { + return command?.arguments?.some(argument => + typeof argument === 'string' && (exact ? argument === testName : argument.endsWith(testName)) + ) ?? false; +} + +function rangeWeight(range: vscode.Range): number { + const lineSpan = range.end.line - range.start.line; + const characterSpan = lineSpan === 0 + ? range.end.character - range.start.character + : range.end.character + range.start.character; + return lineSpan * 1_000_000 + characterSpan; } /** @@ -98,7 +192,8 @@ export class DebuggingExecutor implements IDebuggingExecutor { } /** - * Debug a single test by routing through VS Code's Testing API. + * Debug a single RSpec example through its debugger CodeLens. Preserve the + * original VS Code Testing API path for every other language and test type. * * Works for any language whose extension registers a TestController * (Python, Jest/Mocha, JUnit, C# Dev Kit, Go, Rust, ...). This is the @@ -109,7 +204,8 @@ export class DebuggingExecutor implements IDebuggingExecutor { * Implementation strategy: * 1. Open the file in an editor. * 2. Place the cursor on the test method's definition line. - * 3. Execute the built-in `testing.debugAtCursor` command. + * 3. For `*_spec.rb`, execute the narrowest matching debugger CodeLens. + * 4. Otherwise execute the built-in `testing.debugAtCursor` command. * * The handler's existing readiness wait picks up the resulting session. */ @@ -122,6 +218,13 @@ export class DebuggingExecutor implements IDebuggingExecutor { ); } + if (shouldUseDebuggerCodeLens(fileFullPath)) { + const codeLensDispatch = await this.debugTestWithCodeLens(positioned, testName); + if (codeLensDispatch) { + return codeLensDispatch; + } + } + // Trigger test discovery before dispatching. Some controllers (notably // Python's) lazily discover tests on first Test Explorer open; without // this, testing.debugAtCursor silently no-ops because no TestItem exists @@ -147,7 +250,148 @@ export class DebuggingExecutor implements IDebuggingExecutor { logger.error(`testing.debugAtCursor failed: ${err}`); throw err; }); - return { started: true, runComplete }; + return { started: true, runComplete, description: 'testing.debugAtCursor' }; + } + + private async debugTestWithCodeLens( + positioned: PositionedTest, + testName: string + ): Promise { + let codeLenses: vscode.CodeLens[] | undefined; + try { + codeLenses = await vscode.commands.executeCommand( + 'vscode.executeCodeLensProvider', + positioned.uri, + 1_000 + ); + } catch { + return undefined; + } + + const codeLens = findDebugCodeLens(codeLenses ?? [], positioned.codeLensTarget, testName); + if (!codeLens?.command) { + logger.info( + `No debugger CodeLens found for test '${testName}'; ` + + `the provider returned ${codeLenses?.length ?? 0} CodeLenses.` + ); + return undefined; + } + + let command = codeLens.command; + if (command.command === 'rubyLsp.debugTest' && positioned.uri.fsPath.endsWith('_spec.rb')) { + command = addRubyRspecProgram( + command, + positioned.uri.fsPath, + codeLens.range.start.line + 1, + await this.rubyLspRspecCommand(positioned.uri) + ); + } + const editor = vscode.window.activeTextEditor; + if (editor?.document.uri.toString() === positioned.uri.toString()) { + // Older CodeLens providers may resolve their command from the active + // line. Modern Ruby LSP receives the exact program directly below; + // the body line remains reserved for testing.debugAtCursor fallback. + const selection = new vscode.Selection(codeLens.range.start, codeLens.range.start); + editor.selection = selection; + editor.revealRange(selection, vscode.TextEditorRevealType.InCenter); + } + + const rubyRspecProgram = command.command === 'rubyLsp.debugTest' + ? command.arguments?.[2] + : undefined; + if (typeof rubyRspecProgram === 'string' && rubyRspecProgram.length > 0) { + logger.info(`Starting exact Ruby RSpec debugger: ${rubyRspecProgram}`); + return this.startRubyRspecDebug(positioned.uri, rubyRspecProgram); + } + + logger.info(`Dispatching test debugger through CodeLens command ${command.command}`); + + // A CodeLens command may resolve only after the entire test run, so do + // not await it here before the debugger reaches its first stop. + const runComplete = Promise.resolve( + vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])) + ) + .then(() => undefined) + .catch(err => { + logger.error(`Debugger CodeLens command ${command.command} failed: ${err}`); + }); + + return { started: true, runComplete, description: 'debugger CodeLens' }; + } + + private async startRubyRspecDebug(uri: vscode.Uri, program: string): Promise { + let targetSessionId: string | undefined; + let resolveComplete: (() => void) | undefined; + const runComplete = new Promise(resolve => { + resolveComplete = resolve; + }); + const matches = (session: vscode.DebugSession) => + session.type === 'ruby_lsp' && session.configuration.program === program; + const startSubscription = vscode.debug.onDidStartDebugSession(session => { + if (matches(session)) { + targetSessionId = session.id; + } + }); + const terminateSubscription = vscode.debug.onDidTerminateDebugSession(session => { + if (session.id === targetSessionId) { + cleanup(); + resolveComplete?.(); + } + }); + const cleanup = () => { + startSubscription.dispose(); + terminateSubscription.dispose(); + }; + + try { + const started = await vscode.debug.startDebugging( + vscode.workspace.getWorkspaceFolder(uri), + rubyRspecDebugConfiguration(program) + ); + if (!started) { + cleanup(); + throw new Error(`Failed to start exact RSpec debugging for ${program}`); + } + + if (!targetSessionId && vscode.debug.activeDebugSession && matches(vscode.debug.activeDebugSession)) { + targetSessionId = vscode.debug.activeDebugSession.id; + } + return { started: true, runComplete, description: 'debugger CodeLens' }; + } catch (error) { + cleanup(); + throw error; + } + } + + private async rubyLspRspecCommand(uri: vscode.Uri): Promise { + const addonSettings = vscode.workspace + .getConfiguration('rubyLsp', uri) + .get>('addonSettings'); + const configuredCommand = addonSettings?.['Ruby LSP RSpec']?.rspecCommand; + if (typeof configuredCommand === 'string' && configuredCommand.trim().length > 0) { + return configuredCommand; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (!workspaceFolder) { + return 'bundle exec rspec'; + } + + const binstub = await this.uriExists(vscode.Uri.joinPath(workspaceFolder.uri, 'bin', 'rspec')) + ? 'bin/rspec' + : 'rspec'; + return await this.uriExists(vscode.Uri.joinPath(workspaceFolder.uri, 'Gemfile')) + ? `bundle exec ${binstub}` + : binstub; + } + + private async uriExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } } /** @@ -166,7 +410,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { * showTextDocument so it's applied atomically with the open — separate * `editor.selection = ...` writes race testing.debugAtCursor. */ - private async positionCursorAtTest(fileFullPath: string, testName: string): Promise { + private async positionCursorAtTest(fileFullPath: string, testName: string): Promise { const uri = vscode.Uri.file(fileFullPath); const doc = await vscode.workspace.openTextDocument(uri); @@ -181,11 +425,13 @@ export class DebuggingExecutor implements IDebuggingExecutor { ]; let target: vscode.Position | undefined; + let codeLensTarget: vscode.Position | undefined; for (const pattern of patterns) { for (let i = 0; i < doc.lineCount; i++) { const line = doc.lineAt(i).text; const match = pattern.exec(line); if (match) { + codeLensTarget = new vscode.Position(i, match.index); // Place cursor one line below the method signature, inside // the body. The method-name line itself can be outside the // TestItem range used by some test controllers (notably @@ -204,8 +450,8 @@ export class DebuggingExecutor implements IDebuggingExecutor { } } - if (!target) { - return false; + if (!target || !codeLensTarget) { + return undefined; } const selection = new vscode.Range(target, target); @@ -225,7 +471,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { // reads the active editor synchronously, so without this small wait the // command can race and pick whichever editor was previously focused. await this.waitForActiveEditor(uri); - return true; + return { uri, codeLensTarget, target }; } private async waitForActiveEditor(uri: vscode.Uri, timeoutMs = 1500): Promise { diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index 363c084..5b9869c 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -96,13 +96,12 @@ export class DebuggingHandler implements IDebuggingHandler { if (testName && !hasExplicitConfig) { readyPromise = this.executor.waitForDebugSessionReady( this.timeoutInSeconds * 1000, readinessAbort.signal); - // Route through VS Code's Testing API. This works for any language - // whose extension registers a TestController and correctly handles - // child-process attach for runners like `dotnet test`. + // RSpec needs its exact debugger CodeLens. Every other test + // retains the original VS Code Testing API dispatch. const dispatch = await this.executor.debugTestAtCursor(fileFullPath, testName); started = dispatch.started; testRunComplete = dispatch.runComplete; - configDescription = `testing.debugAtCursor (test: ${testName})`; + configDescription = dispatch.description ?? 'testing.debugAtCursor'; } else { const debugConfig = await this.configManager.getDebugConfig( workingDirectory, diff --git a/src/test/debuggingExecutor.test.ts b/src/test/debuggingExecutor.test.ts new file mode 100644 index 0000000..00da8f9 --- /dev/null +++ b/src/test/debuggingExecutor.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import { execFileSync } from 'child_process'; +import * as vscode from 'vscode'; +import { + addRubyRspecProgram, + findDebugCodeLens, + rubyRspecDebugConfiguration, + shouldUseDebuggerCodeLens +} from '../debuggingExecutor'; + +suite('DebuggingExecutor CodeLens selection', () => { + test('selects the narrowest debugger CodeLens for a nested test', () => { + const target = new vscode.Position(6, 6); + const group = codeLens('rubyLsp.debugTest', new vscode.Range(4, 0, 30, 3)); + const example = codeLens('rubyLsp.debugTest', new vscode.Range(6, 2, 6, 80)); + const run = codeLens('rubyLsp.runTest', new vscode.Range(6, 2, 6, 80)); + + assert.strictEqual(findDebugCodeLens([ group, run, example ], target), example); + }); + + test('ignores debugger CodeLenses outside the selected test', () => { + const target = new vscode.Position(6, 6); + const other = codeLens('rubyLsp.debugTest', new vscode.Range(20, 0, 25, 3)); + + assert.strictEqual(findDebugCodeLens([ other ], target), undefined); + }); + + test('selects a debugger CodeLens by exact test name regardless of range', () => { + const target = new vscode.Position(6, 6); + const first = codeLens('rubyLsp.debugTest', new vscode.Range(5, 2, 12, 3), [ 'first test' ]); + const selected = codeLens( + 'rubyLsp.debugTest', + new vscode.Range(20, 2, 20, 3), + [ 'RequestTelegramNotifier#test_0005_selected test' ] + ); + + assert.strictEqual(findDebugCodeLens([ first, selected ], target, 'selected test'), selected); + }); + + test('selects a one-character debugger CodeLens on the test definition line', () => { + const target = new vscode.Position(6, 6); + const selected = codeLens('rubyLsp.debugTest', new vscode.Range(6, 2, 6, 3)); + + assert.strictEqual(findDebugCodeLens([ selected ], target), selected); + }); + + test('prefers saves over an earlier also saves CodeLens with an equal range size', () => { + const target = new vscode.Position(6, 6); + const suffix = codeLens('rubyLsp.debugTest', new vscode.Range(2, 2, 2, 3), [ 'also saves' ]); + const exact = codeLens('rubyLsp.debugTest', new vscode.Range(6, 2, 6, 3), [ 'saves' ]); + assert.strictEqual(findDebugCodeLens([ suffix, exact ], target, 'saves'), exact); + assert.strictEqual(findDebugCodeLens([ exact, suffix ], target, 'saves'), exact); + assert.strictEqual(findDebugCodeLens([ suffix, exact ], suffix.range.start, 'saves'), exact); + }); + + test('uses the requested position to disambiguate identical example names', () => { + const first = codeLens('rubyLsp.debugTest', new vscode.Range(2, 2, 2, 3), [ 'saves' ]); + const selected = codeLens('rubyLsp.debugTest', new vscode.Range(6, 2, 6, 3), [ 'saves' ]); + assert.strictEqual(findDebugCodeLens([ first, selected ], new vscode.Position(6, 6), 'saves'), selected); + }); + + for (const names of [ [ 'saves', 'saves' ], [ 'group saves', 'another group saves' ] ]) { + test(`rejects ambiguous names away from the requested position: ${names.join(', ')}`, () => { + const first = codeLens('rubyLsp.debugTest', new vscode.Range(2, 2, 2, 3), [ names[0] ]); + const second = codeLens('rubyLsp.debugTest', new vscode.Range(6, 2, 6, 40), [ names[1] ]); + assert.throws(() => findDebugCodeLens([ first, second ], new vscode.Position(20, 0), 'saves'), + /Ambiguous debugger CodeLens/); + }); + } + + test('rejects equally ranked debugger lenses at the same position', () => { + const range = new vscode.Range(6, 2, 6, 3); + const first = codeLens('rubyLsp.debugTest', range, [ 'saves' ]); + const second = codeLens('rubyLsp.debugTest', range, [ 'saves' ]); + assert.throws(() => findDebugCodeLens([ first, second ], range.start, 'saves'), + /Ambiguous debugger CodeLens/); + }); + + test('disambiguates suffix-only provider names by the requested definition line', () => { + const first = codeLens('rubyLsp.debugTest', new vscode.Range(2, 2, 2, 3), [ 'group also saves' ]); + const selected = codeLens('rubyLsp.debugTest', new vscode.Range(6, 2, 6, 3), [ 'group saves' ]); + assert.strictEqual(findDebugCodeLens([ first, selected ], new vscode.Position(6, 6), 'saves'), selected); + }); + + test('passes a spaced or shell-sensitive file:line to a real shell as exactly one literal argument', function () { + if (process.platform === 'win32') { + this.skip(); // This integration check requires a POSIX shell. + } + for (const file of [ + '/repo/with spaces/spec/example_spec.rb', + '/repo/with\'single"double/spec/example_spec.rb', + '/repo/$(printf injected)/`printf injected`/$PATH/example_spec.rb', + '/repo/a;b&c|d>e { + const command = vscodeCommand('rubyLsp.debugTest', [ + '/repo/spec/example_spec.rb', + './spec/example_spec.rb:4::./spec/example_spec.rb:6' + ]); + + assert.deepStrictEqual( + addRubyRspecProgram(command, '/repo/spec/example_spec.rb', 6, 'bin/rspec-lsp').arguments, + [ + '/repo/spec/example_spec.rb', + './spec/example_spec.rb:4::./spec/example_spec.rb:6', + "bin/rspec-lsp '/repo/spec/example_spec.rb:6'" + ] + ); + }); + + test('preserves a ready RSpec command from an older Ruby LSP CodeLens', () => { + const command = vscodeCommand('rubyLsp.debugTest', [ + '/repo/spec/example_spec.rb', + 'example', + 'custom-rspec /repo/spec/example_spec.rb:6' + ]); + + assert.strictEqual( + addRubyRspecProgram(command, '/repo/spec/example_spec.rb', 6, 'bin/rspec-lsp'), + command + ); + }); + + test('builds an exact ruby_lsp launch configuration without Test Explorer', () => { + assert.deepStrictEqual( + rubyRspecDebugConfiguration("bin/rspec-lsp '/repo/spec/example_spec.rb:6'"), + { + type: 'ruby_lsp', + name: 'Debug', + request: 'launch', + program: "bin/rspec-lsp '/repo/spec/example_spec.rb:6'", + env: { DISABLE_SPRING: '1' } + } + ); + }); + + test('limits debugger CodeLens dispatch to RSpec files', () => { + assert.strictEqual(shouldUseDebuggerCodeLens('/repo/spec/example_spec.rb'), true); + assert.strictEqual(shouldUseDebuggerCodeLens('/repo/src/number_pattern.rs'), false); + assert.strictEqual(shouldUseDebuggerCodeLens('/repo/test/example_test.rb'), false); + }); +}); + +function codeLens(command: string, range: vscode.Range, args: unknown[] = []): vscode.CodeLens { + return new vscode.CodeLens(range, vscodeCommand(command, args)); +} + +function vscodeCommand(command: string, args: unknown[] = []): vscode.Command { + return { title: command, command, arguments: args }; +} + +suite('DebuggingExecutor RSpec dispatch', () => { + test('launches the selected CodeLens file:line and waits for session termination', async () => { + const { DebuggingExecutor } = await import('../debuggingExecutor.js'); + const executor = new DebuggingExecutor(); + const uri = vscode.Uri.file('/repo/spec/example_spec.rb'); + (executor as any).positionCursorAtTest = async () => ({ + uri, target: new vscode.Position(6, 2), codeLensTarget: new vscode.Position(5, 2) + }); + (executor as any).rubyLspRspecCommand = async () => 'bin/rspec-lsp'; + const execute = vscode.commands.executeCommand; + const start = vscode.debug.startDebugging; + const onStart = Object.getOwnPropertyDescriptor(vscode.debug, 'onDidStartDebugSession')!; + const onTerminate = Object.getOwnPropertyDescriptor(vscode.debug, 'onDidTerminateDebugSession')!; + let started: ((session: vscode.DebugSession) => void) | undefined; + let terminated: ((session: vscode.DebugSession) => void) | undefined; + let disposed = 0; + const disposable = () => new vscode.Disposable(() => { disposed++; }); + Object.defineProperty(vscode.debug, 'onDidStartDebugSession', { configurable: true, + value: (listener: (session: vscode.DebugSession) => void) => { started = listener; return disposable(); } }); + Object.defineProperty(vscode.debug, 'onDidTerminateDebugSession', { configurable: true, + value: (listener: (session: vscode.DebugSession) => void) => { terminated = listener; return disposable(); } }); + vscode.commands.executeCommand = (async (command: string) => { + assert.strictEqual(command, 'vscode.executeCodeLensProvider'); + return [ codeLens('rubyLsp.debugTest', new vscode.Range(5, 2, 5, 3), [ uri.fsPath, 'selected' ]) ]; + }) as typeof execute; + let launched: vscode.DebugConfiguration | undefined; + vscode.debug.startDebugging = async (_folder, config) => { + assert.notStrictEqual(typeof config, 'string'); + launched = config as vscode.DebugConfiguration; + started?.({ id: 'selected-session', type: 'ruby_lsp', configuration: launched } as vscode.DebugSession); + return true; + }; + try { + const dispatch = await executor.debugTestAtCursor(uri.fsPath, 'selected'); + assert.strictEqual(dispatch.started, true); + assert.strictEqual(launched?.program, "bin/rspec-lsp '/repo/spec/example_spec.rb:6'"); + let complete = false; + void dispatch.runComplete.then(() => { complete = true; }); + terminated?.({ id: 'unrelated-session' } as vscode.DebugSession); + await Promise.resolve(); + assert.strictEqual(complete, false); + terminated?.({ id: 'selected-session' } as vscode.DebugSession); + await dispatch.runComplete; + assert.strictEqual(complete, true); + assert.strictEqual(disposed, 2); + } finally { + vscode.commands.executeCommand = execute; + vscode.debug.startDebugging = start; + Object.defineProperty(vscode.debug, 'onDidStartDebugSession', onStart); + Object.defineProperty(vscode.debug, 'onDidTerminateDebugSession', onTerminate); + } + }); +}); diff --git a/src/test/rspecStops.test.ts b/src/test/rspecStops.test.ts new file mode 100644 index 0000000..2fb5f16 --- /dev/null +++ b/src/test/rspecStops.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { DebuggingExecutor } from '../debuggingExecutor'; +import { DebuggingHandler } from '../debuggingHandler'; +import { DebugState } from '../debugState'; + +suite('RSpec first-stop preservation with real readiness', () => { + for (const reason of [ 'entry', 'exception', 'breakpoint', 'pause' ]) { + test(`returns ${reason} without continuing an unmatched stopped frame`, async () => { + const descriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem')!; + // Keep the old stopped frame present throughout the readiness check. + // The former auto-continue path incorrectly reused this same frame. + Object.defineProperty(vscode.debug, 'activeStackItem', { + configurable: true, + get: () => ({ frameId: 1, threadId: 1, session: { type: 'ruby_lsp' } }) + }); + const state = new DebugState(); + state.sessionActive = true; + state.updateContext(1, 1); + state.updateLocation('/repo/example_spec.rb', 'example_spec.rb', 5, 'debugger', []); + state.updateFrameName(reason); + state.breakpoints = [ 'example_spec.rb:12' ]; + const executor = new DebuggingExecutor(); + executor.debugTestAtCursor = async () => ({ + started: true, description: 'debugger CodeLens', + runComplete: new Promise(() => { /* pending while paused */ }) + }); + executor.getCurrentDebugState = async () => state; + executor.getActiveSession = () => ({ type: 'ruby_lsp' }) as vscode.DebugSession; + let continueCalls = 0; + executor.continue = async () => { continueCalls++; }; + try { + const output = await new DebuggingHandler(executor, {} as any, 1).handleStartDebugging({ + fileFullPath: '/repo/example_spec.rb', workingDirectory: '/repo', testName: 'selected example' + }); + assert.match(output, /"currentLine": 5/); + assert.match(output, new RegExp(`"frameName": "${reason}"`)); + assert.strictEqual(continueCalls, 0); + } finally { + Object.defineProperty(vscode.debug, 'activeStackItem', descriptor); + } + }); + } +}); diff --git a/src/utils/debugConfigurationManager.ts b/src/utils/debugConfigurationManager.ts index 54e4102..bab1b53 100644 --- a/src/utils/debugConfigurationManager.ts +++ b/src/utils/debugConfigurationManager.ts @@ -25,10 +25,10 @@ export interface IDebugConfigurationManager { * - The language extension's DebugConfigurationProvider.resolveDebugConfiguration * (which fills in cwd, console, env, and other defaults for ad-hoc launches) * - * Test launches go through DebuggingExecutor.debugTestAtCursor instead — VS Code's - * Testing API knows how to debug a specific test for any language whose extension - * registers a TestController, including the parent/child process handoff that - * `dotnet test` requires. + * Test launches go through DebuggingExecutor.debugTestAtCursor. It uses the + * exact RSpec debugger CodeLens for `*_spec.rb` and preserves VS Code's Testing + * API for every other test, including the parent/child process handoff required + * by `dotnet test`. */ export class DebugConfigurationManager implements IDebugConfigurationManager { private static readonly AUTO_LAUNCH_CONFIG = 'Default Configuration';