Skip to content

Implement debug handler based on command resolution #3354

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
9 changes: 6 additions & 3 deletions vscode/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ class ExperimentalCapabilities implements StaticFeature {
clear(): void {}
}

export interface ResolvedCommands {
commands: string[];
reporterPaths: string[] | undefined;
}

export default class Client extends LanguageClient implements ClientInterface {
public readonly ruby: Ruby;
public serverVersion?: string;
Expand Down Expand Up @@ -502,9 +507,7 @@ export default class Client extends LanguageClient implements ClientInterface {
});
}

async resolveTestCommands(
items: LspTestItem[],
): Promise<{ commands: string[]; reporterPaths: string[] | undefined }> {
async resolveTestCommands(items: LspTestItem[]): Promise<ResolvedCommands> {
return this.sendRequest("rubyLsp/resolveTestCommands", {
items,
});
Expand Down
77 changes: 77 additions & 0 deletions vscode/src/test/suite/testController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import sinon from "sinon";
import { TestController } from "../../testController";
import * as common from "../../common";
import { Workspace } from "../../workspace";
import { ManagerIdentifier } from "../../ruby";
import { Debugger } from "../../debugger";

import { FAKE_TELEMETRY } from "./fakeTelemetry";
import { createRubySymlinks } from "./helpers";

suite("TestController", () => {
const workspacePath = path.dirname(
Expand All @@ -31,6 +34,7 @@ suite("TestController", () => {
get: (_name: string) => undefined,
update: (_name: string, _value: any) => Promise.resolve(),
},
extensionUri: vscode.Uri.joinPath(workspaceUri, "vscode"),
} as unknown as vscode.ExtensionContext;
const workspace = new Workspace(
context,
Expand Down Expand Up @@ -1006,4 +1010,77 @@ suite("TestController", () => {
createRunStub.restore();
});
}).timeout(10000);

test("debugging a test", async () => {
const manager =
os.platform() === "win32"
? { identifier: ManagerIdentifier.None }
: { identifier: ManagerIdentifier.Chruby };

// eslint-disable-next-line no-process-env
if (process.env.CI) {
createRubySymlinks();
}

await workspace.ruby.activateRuby(manager);

await withController(async (controller) => {
const uri = vscode.Uri.joinPath(workspaceUri, "test", "server_test.rb");
const testItem = (await controller.findTestItem(
"ServerTest::NestedTest#test_something",
uri,
))!;

workspace.lspClient = {
resolveTestCommands: sinon.stub().resolves({
commands: [`ruby -e '1'`],
reporterPath: undefined,
}),
} as any;

const runStub = {
started: sinon.stub(),
passed: sinon.stub(),
enqueued: sinon.stub(),
end: sinon.stub(),
} as any;
const createRunStub = sinon
.stub(controller.testController, "createTestRun")
.returns(runStub);

const debug = new Debugger(context, () => workspace);
const cancellationSource = new vscode.CancellationTokenSource();
const startDebuggingSpy = sinon.spy(vscode.debug, "startDebugging");

const runRequest = new vscode.TestRunRequest(
[testItem],
[],
controller.testDebugProfile,
);
await controller.runTest(runRequest, cancellationSource.token);

assert.ok(runStub.end.calledWithExactly());
assert.ok(
startDebuggingSpy.calledOnceWith(
workspaceFolder,
{
type: "ruby_lsp",
name: "Debug",
request: "launch",
program: "ruby -e '1'",
env: {
...workspace.ruby.env,
DISABLE_SPRING: "1",
RUBY_LSP_TEST_RUNNER: "true",
},
},
{ testRun: runStub },
),
);

createRunStub.restore();
startDebuggingSpy.restore();
debug.dispose();
});
}).timeout(10000);
});
146 changes: 108 additions & 38 deletions vscode/src/testController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { CodeLens } from "vscode-languageclient/node";

import { Workspace } from "./workspace";
import { featureEnabled } from "./common";
import { LspTestItem, ServerTestItem } from "./client";
import { LspTestItem, ResolvedCommands, ServerTestItem } from "./client";

const asyncExec = promisify(exec);

Expand Down Expand Up @@ -41,13 +41,15 @@ const NOTIFICATION_TYPES = {
error: new rpc.NotificationType<TestEventWithMessage>("error"),
appendOutput: new rpc.NotificationType<{ message: string }>("append_output"),
};
const RUN_PROFILE_LABEL = "Run";
const DEBUG_PROFILE_LABEL = "Debug";

export class TestController {
// Only public for testing
readonly testController: vscode.TestController;
readonly testRunProfile: vscode.TestRunProfile;
readonly testDebugProfile: vscode.TestRunProfile;
private readonly testCommands: WeakMap<vscode.TestItem, string>;
private readonly testRunProfile: vscode.TestRunProfile;
private readonly testDebugProfile: vscode.TestRunProfile;
private terminal: vscode.Terminal | undefined;
private readonly telemetry: vscode.TelemetryLogger;
// We allow the timeout to be configured in seconds, but exec expects it in milliseconds
Expand Down Expand Up @@ -85,18 +87,18 @@ export class TestController {
this.testCommands = new WeakMap<vscode.TestItem, string>();

this.testRunProfile = this.testController.createRunProfile(
"Run",
RUN_PROFILE_LABEL,
vscode.TestRunProfileKind.Run,
this.fullDiscovery ? this.runTest.bind(this) : this.runHandler.bind(this),
true,
);

this.testDebugProfile = this.testController.createRunProfile(
"Debug",
DEBUG_PROFILE_LABEL,
vscode.TestRunProfileKind.Debug,
async (request, token) => {
await this.debugHandler(request, token);
},
this.fullDiscovery
? this.runTest.bind(this)
: this.debugHandler.bind(this),
false,
DEBUG_TAG,
);
Expand Down Expand Up @@ -334,38 +336,15 @@ export class TestController {
continue;
}

// Enqueue all of the test we're about to run
testItems.forEach((test) => run.enqueued(test));
const profile = request.profile;

// Require the custom JSON RPC reporters through RUBYOPT. We cannot use Ruby's `-r` flag because the moment the
// test framework is loaded, it might change which options are accepted. For example, if we append `-r` after the
// file path for Minitest, it will fail with unrecognized argument errors
const rubyOpt = workspace.ruby.env.RUBYOPT
? `${workspace.ruby.env.RUBYOPT} ${response.reporterPaths?.map((path) => `-r${path}`).join(" ")}`
: response.reporterPaths?.map((path) => `-r${path}`).join(" ");
if (!profile || profile.label === RUN_PROFILE_LABEL) {
// Enqueue all of the test we're about to run
testItems.forEach((test) => run.enqueued(test));

// For each command reported by the server spawn a new process with streaming updates
for (const command of response.commands) {
try {
workspace.outputChannel.debug(
`Running tests: "RUBYOPT=${rubyOpt} ${command}"`,
);
await this.runCommandWithStreamingUpdates(
run,
command,
{
...workspace.ruby.env,
RUBY_LSP_TEST_RUNNER: "true",
RUBYOPT: rubyOpt,
},
workspace.workspaceFolder.uri.fsPath,
token,
);
} catch (error: any) {
await vscode.window.showErrorMessage(
`Running ${command} failed: ${error.message}`,
);
}
await this.executeTestCommands(response, workspace, run, token);
} else if (profile.label === DEBUG_PROFILE_LABEL) {
await this.debugTestCommands(response, workspace, run, token);
}
}

Expand Down Expand Up @@ -408,6 +387,97 @@ export class TestController {
return this.findTestInGroup(id, testFileItem);
}

// Execute all of the test commands reported by the server in the background using JSON RPC to receive streaming
// updates
private async executeTestCommands(
response: ResolvedCommands,
workspace: Workspace,
run: vscode.TestRun,
token: vscode.CancellationToken,
) {
// Require the custom JSON RPC reporters through RUBYOPT. We cannot use Ruby's `-r` flag because the moment the
// test framework is loaded, it might change which options are accepted. For example, if we append `-r` after the
// file path for Minitest, it will fail with unrecognized argument errors
const rubyOpt = workspace.ruby.env.RUBYOPT
? `${workspace.ruby.env.RUBYOPT} ${response.reporterPaths?.map((path) => `-r${path}`).join(" ")}`
: response.reporterPaths?.map((path) => `-r${path}`).join(" ");

for (const command of response.commands) {
try {
workspace.outputChannel.debug(
`Running tests: "RUBYOPT=${rubyOpt} ${command}"`,
);
await this.runCommandWithStreamingUpdates(
run,
command,
{
...workspace.ruby.env,
RUBY_LSP_TEST_RUNNER: "true",
RUBYOPT: rubyOpt,
},
workspace.workspaceFolder.uri.fsPath,
token,
);
} catch (error: any) {
await vscode.window.showErrorMessage(
`Running ${command} failed: ${error.message}`,
);
}
}
}

// Launches the debugger for the test commands reported by the server. This mode of execution does not support the
// JSON RPC streaming updates as the debugger uses the stdio pipes to communicate with the editor
private async debugTestCommands(
response: ResolvedCommands,
workspace: Workspace,
run: vscode.TestRun,
token: vscode.CancellationToken,
) {
for (const command of response.commands) {
if (token.isCancellationRequested) {
break;
}

const disposables: vscode.Disposable[] = [];

// Here we only resolve the promise once the debugging session has ended. Notice that
// `vscode.debug.startDebugging` resolve immediately after successfully starting the debugger, not after it
// finishes
await new Promise<void>((resolve, reject) => {
disposables.push(
token.onCancellationRequested(vscode.debug.stopDebugging),
vscode.debug.onDidTerminateDebugSession(() => {
disposables.forEach((disposable) => disposable.dispose());
resolve();
}),
);

return vscode.debug
.startDebugging(
workspace.workspaceFolder,
{
type: "ruby_lsp",
name: "Debug",
request: "launch",
program: command,
env: {
...workspace.ruby.env,
DISABLE_SPRING: "1",
RUBY_LSP_TEST_RUNNER: "true",
},
},
{ testRun: run },
)
.then((successFullyStarted) => {
if (!successFullyStarted) {
reject();
}
});
});
}
}

private findTestInGroup(
id: string,
group: vscode.TestItem,
Expand Down