From f22cc90f2a386ca7c56b1591da2ded7c4eaa41c2 Mon Sep 17 00:00:00 2001 From: AlexKovynev Date: Wed, 9 Sep 2026 13:09:06 +0300 Subject: [PATCH] Debug exact RSpec examples --- CHANGELOG.md | 7 +- docs/architecture/debuggingExecutor.md | 7 + docs/architecture/debuggingHandler.md | 4 + docs/rspec-debugging.md | 36 ++++ src/debuggingExecutor.ts | 250 ++++++++++++++++++++++++- src/debuggingHandler.ts | 7 +- src/test/debuggingExecutor.test.ts | 157 ++++++++++++++++ src/test/rspecStops.test.ts | 46 +++++ src/utils/debugConfigurationManager.ts | 8 +- 9 files changed, 505 insertions(+), 17 deletions(-) create mode 100644 docs/rspec-debugging.md create mode 100644 src/test/debuggingExecutor.test.ts create mode 100644 src/test/rspecStops.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cc55e09..3c241a1 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.4] - 2026-09-03 ### Added @@ -117,4 +122,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Initial release - Core debugging capabilities via MCP protocol - VS Code Debug Adapter Protocol integration -- Automatic MCP server startup on extension activation \ No newline at end of file +- Automatic MCP server startup on extension activation diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index d2aaea0..2918e80 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`, the executor selects the debugger CodeLens that names the requested example, contains it, or starts +on its definition line. Modern Ruby RSpec CodeLenses omit the launch program, so the executor combines the configured +RSpec command with the exact `file:line` and starts `ruby_lsp` directly. Every other language and test type retains +the original `testing.debugAtCursor` path without CodeLens interception. + ### VS Code Debug Commands Stepping and control operations use VS Code's command system: diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index 4c737b5..e8f391f 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -105,3 +105,7 @@ Recursive expansion is bounded to 100 child fields total per response, shared ac All operations wrap errors with context about what operation failed, enabling AI agents to understand and potentially recover from failures. 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). diff --git a/docs/rspec-debugging.md b/docs/rspec-debugging.md new file mode 100644 index 0000000..bbd7ef0 --- /dev/null +++ b/docs/rspec-debugging.md @@ -0,0 +1,36 @@ +# 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. + +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 5ec5333..386f859 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -6,7 +6,8 @@ import { logger } from './utils/logger'; import { withTimeout } from './utils/withTimeout'; /** - * 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, @@ -18,6 +19,87 @@ import { withTimeout } from './utils/withTimeout'; 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 namedCodeLenses = testName + ? debuggerCodeLenses.filter(codeLens => codeLensCommandMatchesTest(codeLens.command, testName)) + : []; + const containingCodeLenses = debuggerCodeLenses.filter(codeLens => codeLens.range.contains(target)); + const sameLineCodeLenses = debuggerCodeLenses.filter(codeLens => codeLens.range.start.line === target.line); + const candidates = namedCodeLenses.length > 0 + ? namedCodeLenses + : containingCodeLenses.length > 0 + ? containingCodeLenses + : sameLineCodeLenses; + + return candidates + .sort((left, right) => rangeWeight(left.range) - rangeWeight(right.range))[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); + } + args[2] = `${rspecCommand} ${fileFullPath}:${line}`; + 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): boolean { + return command?.arguments?.some(argument => + typeof argument === 'string' && (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; } /** @@ -90,7 +172,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 @@ -101,7 +184,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. */ @@ -114,6 +198,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 @@ -138,7 +229,148 @@ export class DebuggingExecutor implements IDebuggingExecutor { .catch(err => { logger.error(`testing.debugAtCursor failed: ${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; + } } /** @@ -157,7 +389,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); @@ -172,11 +404,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 @@ -195,8 +429,8 @@ export class DebuggingExecutor implements IDebuggingExecutor { } } - if (!target) { - return false; + if (!target || !codeLensTarget) { + return undefined; } const selection = new vscode.Range(target, target); @@ -216,7 +450,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 12fdc69..bf86d67 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -98,13 +98,12 @@ export class DebuggingHandler implements IDebuggingHandler { let testRunComplete: Promise | undefined; if (testName && !hasExplicitConfig) { - // 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..5af354a --- /dev/null +++ b/src/test/debuggingExecutor.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'assert'; +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('adds an exact RSpec command to a modern Ruby LSP CodeLens', () => { + 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 096267d..44a5412 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';