Skip to content

Commit 4d9efcc

Browse files
committed
extension/src: add rr test code lens for record and replay debugging
Add an opt-in "rr test" code lens that records a test with Mozilla rr and immediately opens a dlv replay debug session. The lens is gated behind go.enableCodeLens.rrtest (default false) and requires Linux with rr installed. When clicked, the extension compiles the test binary with go test -c, spawns rr record in a pseudoterminal so output is visible, then launches a debug session with mode: replay once recording completes. Both code lens paths are updated: the legacy provider (goRunTestCodelens.ts) and the gopls middleware in goLanguageServer.ts. Made-with: Cursor
1 parent 7360da7 commit 4d9efcc

5 files changed

Lines changed: 152 additions & 8 deletions

File tree

extension/package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1638,11 +1638,17 @@
16381638
"type": "boolean",
16391639
"default": true,
16401640
"description": "If true, enables code lens for running and debugging tests"
1641+
},
1642+
"rrtest": {
1643+
"type": "boolean",
1644+
"default": false,
1645+
"description": "If true, enables 'rr test' code lens for recording and replaying tests with Mozilla rr. Requires Linux and rr to be installed."
16411646
}
16421647
},
16431648
"additionalProperties": false,
16441649
"default": {
1645-
"runtest": true
1650+
"runtest": true,
1651+
"rrtest": false
16461652
},
16471653
"description": "Feature level setting to enable/disable code lens for references and run/debug tests",
16481654
"scope": "resource"
@@ -3899,4 +3905,4 @@
38993905
}
39003906
]
39013907
}
3902-
}
3908+
}

extension/src/goMain.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<ExtensionA
174174
registerCommand('go.subtest.cursor', commands.subTestAtCursor('test'));
175175
registerCommand('go.debug.cursor', commands.testAtCursor('debug'));
176176
registerCommand('go.debug.subtest.cursor', commands.subTestAtCursor('debug'));
177+
registerCommand('go.rr.cursor', commands.rrAtCursor);
177178
registerCommand('go.benchmark.cursor', commands.testAtCursor('benchmark'));
178179
registerCommand('go.test.package', commands.testCurrentPackage(false));
179180
registerCommand('go.benchmark.package', commands.testCurrentPackage(true));

extension/src/goRunTestCodelens.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,14 @@ export class GoRunTestCodeLensProvider extends GoBaseCodeLensProvider {
109109
return codelens;
110110
}
111111

112+
const codeLensConfig = getGoConfig(document.uri).get<{ [key: string]: any }>('enableCodeLens');
113+
const rrEnabled = codeLensConfig?.['rrtest'] ?? false;
112114
const simpleRunRegex = /t.Run\("([^"]+)",/;
113115

114116
for (const f of testFunctions) {
115117
const functionName = f.name;
116118

117-
codelens.push(
119+
const lensesForFn: CodeLens[] = [
118120
new CodeLens(f.range, {
119121
title: 'run test',
120122
command: 'go.test.cursor',
@@ -125,7 +127,17 @@ export class GoRunTestCodeLensProvider extends GoBaseCodeLensProvider {
125127
command: 'go.debug.cursor',
126128
arguments: [{ functionName }]
127129
})
128-
);
130+
];
131+
if (rrEnabled) {
132+
lensesForFn.push(
133+
new CodeLens(f.range, {
134+
title: 'rr test',
135+
command: 'go.rr.cursor',
136+
arguments: [{ functionName }]
137+
})
138+
);
139+
}
140+
codelens.push(...lensesForFn);
129141

130142
for (let i = f.range.start.line; i < f.range.end.line; i++) {
131143
const line = document.lineAt(i);

extension/src/goTest.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
*--------------------------------------------------------*/
66
'use strict';
77

8+
import cp = require('child_process');
9+
import os = require('os');
810
import path = require('path');
11+
import util = require('util');
912
import vscode = require('vscode');
1013
import { CommandFactory } from './commands';
1114
import { getGoConfig } from './config';
1215
import { GoExtensionContext } from './context';
16+
import { toolExecutionEnvironment } from './goEnv';
1317
import { isModSupported } from './goModules';
1418
import { escapeSubTestName } from './subTestUtils';
1519
import {
@@ -25,6 +29,7 @@ import {
2529
SuiteToTestMap,
2630
getTestFunctions
2731
} from './testUtils';
32+
import { getBinPath } from './util';
2833

2934
// lastTestConfig holds a reference to the last executed TestConfig which allows
3035
// the last test to be easily re-executed.
@@ -346,6 +351,115 @@ export async function debugTestAtCursor(
346351
return await vscode.debug.startDebugging(workspaceFolder, debugConfig);
347352
}
348353

354+
/**
355+
* Records and replays the test at cursor using Mozilla rr via `dlv replay`.
356+
* Only supported on Linux with rr installed.
357+
*/
358+
export const rrAtCursor: CommandFactory = (_, goCtx) => async (args: any) => {
359+
const editor = vscode.window.activeTextEditor;
360+
if (!editor) {
361+
vscode.window.showInformationMessage('No editor is active.');
362+
return;
363+
}
364+
if (!editor.document.fileName.endsWith('_test.go')) {
365+
vscode.window.showInformationMessage('No tests found. Current file is not a test file.');
366+
return;
367+
}
368+
if (os.platform() !== 'linux') {
369+
vscode.window.showErrorMessage("'rr test' is only supported on Linux.");
370+
return;
371+
}
372+
373+
const rrPath = getBinPath('rr');
374+
if (!rrPath || rrPath === 'rr') {
375+
// getBinPath returns the input unchanged when not found
376+
try {
377+
cp.execFileSync('rr', ['--version'], { stdio: 'ignore' });
378+
} catch {
379+
vscode.window.showErrorMessage("'rr' not found on PATH. Install Mozilla rr to use this feature.");
380+
return;
381+
}
382+
}
383+
384+
const { testFunctions } = await getTestFunctionsAndTestSuite(false, goCtx, editor.document);
385+
const testFunctionName =
386+
args && args.functionName
387+
? args.functionName
388+
: testFunctions?.filter((func) => func.range.contains(editor.selection.start)).map((el) => el.name)[0];
389+
if (!testFunctionName) {
390+
vscode.window.showInformationMessage('No test function found at cursor.');
391+
return;
392+
}
393+
394+
await editor.document.save();
395+
396+
const goConfig = getGoConfig(editor.document.uri);
397+
const pkgDir = path.dirname(editor.document.fileName);
398+
const traceDir = path.join(os.tmpdir(), `vscode-go-rr-${Date.now()}`);
399+
const testBin = path.join(os.tmpdir(), `vscode-go-rr-bin-${Date.now()}`);
400+
401+
const tags = getTestTags(goConfig);
402+
const buildFlags = tags ? ['-tags', tags] : [];
403+
const flagsFromConfig = getTestFlags(goConfig);
404+
flagsFromConfig.forEach((x) => {
405+
if (x !== '-args') buildFlags.push(x);
406+
});
407+
408+
const goRuntime = getBinPath('go');
409+
const execFile = util.promisify(cp.execFile);
410+
const env = toolExecutionEnvironment();
411+
412+
try {
413+
await execFile(goRuntime, ['test', '-c', '-o', testBin, ...buildFlags, pkgDir], { env, cwd: pkgDir });
414+
} catch (e: any) {
415+
vscode.window.showErrorMessage(`Failed to build test binary: ${e.message ?? e}`);
416+
return;
417+
}
418+
419+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
420+
421+
const exitCode = await new Promise<number>((resolve) => {
422+
const writeEmitter = new vscode.EventEmitter<string>();
423+
const pty: vscode.Pseudoterminal = {
424+
onDidWrite: writeEmitter.event,
425+
open() {
426+
const proc = cp.spawn(
427+
'rr',
428+
['record', `--output-trace-dir=${traceDir}`, testBin, '-test.run', `^${testFunctionName}$`],
429+
{ cwd: pkgDir, env: { ...process.env, ...env } }
430+
);
431+
const onData = (chunk: Buffer | string) => {
432+
writeEmitter.fire(chunk.toString().replace(/\n/g, '\r\n'));
433+
};
434+
proc.stdout?.on('data', onData);
435+
proc.stderr?.on('data', onData);
436+
proc.on('close', (code) => {
437+
writeEmitter.fire(`\r\nrr exited with code ${code}.\r\n`);
438+
resolve(code ?? 1);
439+
});
440+
},
441+
close() {}
442+
};
443+
vscode.window.createTerminal({ name: `rr: ${testFunctionName}`, pty }).show();
444+
});
445+
446+
if (exitCode !== 0) {
447+
vscode.window.showErrorMessage(`rr record failed (exit code ${exitCode}). Check the terminal for details.`);
448+
return;
449+
}
450+
451+
const debugConfig: vscode.DebugConfiguration = {
452+
name: `rr replay: ${testFunctionName}`,
453+
type: 'go',
454+
request: 'launch',
455+
mode: 'replay',
456+
traceDirPath: traceDir,
457+
env: goConfig.get('testEnvVars', {}),
458+
envFile: goConfig.get('testEnvFile')
459+
};
460+
await vscode.debug.startDebugging(workspaceFolder, debugConfig);
461+
};
462+
349463
/**
350464
* Runs all tests in the package of the source of the active editor.
351465
*

extension/src/language/goLanguageServer.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,27 +1012,38 @@ async function passLinkifyShowMessageToGopls(cfg: LanguageServerConfig, goplsCon
10121012
return goplsConfig;
10131013
}
10141014

1015-
// createTestCodeLens adds the go.test.cursor and go.debug.cursor code lens
1015+
// createTestCodeLens adds the go.test.cursor, go.debug.cursor, and optionally go.rr.cursor code lenses
10161016
function createTestCodeLens(lens: vscode.CodeLens): vscode.CodeLens[] {
10171017
// CodeLens argument signature in gopls is [fileName: string, testFunctions: string[], benchFunctions: string[]],
10181018
// so this needs to be deconstructured here
10191019
// Note that there will always only be one test function name in this context
10201020
if ((lens.command?.arguments?.length ?? 0) < 2 || (lens.command?.arguments?.[1].length ?? 0) < 1) {
10211021
return [lens];
10221022
}
1023-
return [
1023+
const functionName = lens.command?.arguments?.[1][0];
1024+
const lenses = [
10241025
new vscode.CodeLens(lens.range, {
10251026
title: '',
10261027
...lens.command,
10271028
command: 'go.test.cursor',
1028-
arguments: [{ functionName: lens.command?.arguments?.[1][0] }]
1029+
arguments: [{ functionName }]
10291030
}),
10301031
new vscode.CodeLens(lens.range, {
10311032
title: 'debug test',
10321033
command: 'go.debug.cursor',
1033-
arguments: [{ functionName: lens.command?.arguments?.[1][0] }]
1034+
arguments: [{ functionName }]
10341035
})
10351036
];
1037+
if (getGoConfig().get<{ [key: string]: boolean }>('enableCodeLens')?.['rrtest']) {
1038+
lenses.push(
1039+
new vscode.CodeLens(lens.range, {
1040+
title: 'rr test',
1041+
command: 'go.rr.cursor',
1042+
arguments: [{ functionName }]
1043+
})
1044+
);
1045+
}
1046+
return lenses;
10361047
}
10371048

10381049
function createBenchmarkCodeLens(lens: vscode.CodeLens): vscode.CodeLens[] {

0 commit comments

Comments
 (0)