feat(run): Ruby test tree, jump to source and gutter actions (#1546)

Ruby test runs showed only console text: there was no Ruby output parser and test_*.rb was not a test file. A Minitest parser now builds the test tree (class, test, status, time, failure text) from verbose output (-v / TESTOPTS=-v), test_*.rb counts as a test file, jump to source finds the class and then its def, and the gutter gets a run action per test method (coloured by the last result) and per test class and module that holds test classes; one test, class or module runs with --name=....

The streaming Find in Files helper the jump to source uses moved from the search feature to src/platform, so the run feature doesn't import another feature.

Co-authored-by: kjeldahl <19719+kjeldahl@users.noreply.github.com>
This commit is contained in:
Jacob Kjeldahl
2026-11-05 21:38:12 +08:00
committed by GitHub
co-authored by kjeldahl
parent f6a1823121
commit 087cb4cbaa
17 changed files with 683 additions and 18 deletions
+3 -2
View File
@@ -1059,8 +1059,9 @@ fn ruby_config(
c
}
/// Loads the test files of the directories (or files) in `ARGV`, default `test`: the way `rake test` does.
const MINITEST_RUNNER: &str = r#"dirs = ARGV.empty? ? ["test"] : ARGV.dup; ARGV.clear; dirs.flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#;
/// Loads the test files of the directories (or files) named in `ARGV`, default `test`, the way `rake test`
/// does; options such as `-v` stay in `ARGV` for Minitest.
const MINITEST_RUNNER: &str = r#"paths, opts = ARGV.partition { |a| !a.start_with?("-") }; ARGV.replace(opts); (paths.empty? ? ["test"] : paths).flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#;
fn uses_rspec(root: &Path) -> bool {
root.join("spec").is_dir()
+6 -3
View File
@@ -21,7 +21,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows(
(django && name === "tests.py") ||
/_test\.go$/.test(name) ||
/Test\.php$/.test(name) ||
/_(spec|test)\.rb$/.test(name) ||
/_(spec|test)\.rb$|^test_.*\.rb$/.test(name) ||
/(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) ||
(/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs")
)
@@ -43,8 +43,8 @@ function testKinds(relPath: string): string[] {
if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"];
if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"];
if (/\.php$/.test(relPath)) return ["phpunit"];
if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake"];
if (/_test\.rb$/.test(relPath)) return ["rake", "rspec"];
if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake", "ruby"];
if (/(^|\/)test_[^/]*\.rb$|_test\.rb$/.test(relPath)) return ["rake", "rspec", "ruby"];
if (/\.go$/.test(relPath)) return ["go"];
if (/\.rs$/.test(relPath)) return ["cargo"];
if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"];
@@ -180,6 +180,9 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
const key = /(^|:)spec$/.test(task) ? "SPEC" : /(^|:)test$/.test(task) ? "TEST" : null;
return key ? derived(base, relPath, { args: [...base.args, `${key}=${file}`] }) : base;
}
case "ruby":
// The detected Minitest runner (`ruby … -e <runner> test`): its last argument is what to load.
return derived(base, relPath, { args: [...base.args.slice(0, -1), file] });
case "django-test": {
const tests = djangoTestsOf(base, [djangoLabel(file)]);
return tests ? derived(base, relPath, tests) : null;
+4 -2
View File
@@ -12,9 +12,11 @@ export interface FileTarget {
/**
* `path`: resolve against the run directory; `name`: a file name from a JVM
* stack frame — look it up in the project (`path` then is the file under its
* package directory, `com/acme/OrderController.kt`, when the frame names the class).
* package directory, `com/acme/OrderController.kt`, when the frame names the class);
* `rubyTest`: a Minitest test with no file in its output — `className` is its class, `path` its
* method name — found by searching the project (`rubyTests.ts`).
*/
resolve: "path" | "name";
resolve: "path" | "name" | "rubyTest";
/** JVM frames: the class's binary name (`com.acme.OrderController$Inner`). */
className?: string;
}
+39
View File
@@ -2,7 +2,9 @@
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
import { streamSearch } from "@/platform/textSearch";
import { joinPath, type FileTarget } from "./links";
import { findRubyTest } from "./rubyTests";
import { tx } from "@/platform/i18n";
async function exists(path: string): Promise<boolean> {
@@ -90,8 +92,45 @@ export async function resolveTarget(target: FileTarget, cwd: string): Promise<st
return (await findByName(target)) ?? (target.className ? findLibraryClass(target.className) : null);
}
/** Regular-expression search over the project's Ruby files (or the given ones). */
async function searchRuby(pattern: string, files?: string[]) {
const out: import("@/platform/ipc").FileMatches[] = [];
await streamSearch(
nextRequestId(),
{
pattern,
isRegex: true,
caseSensitive: true,
wholeWord: false,
include: files ? [] : ["*.rb"],
exclude: [],
scope: files ? { kind: "files", paths: files } : { kind: "project" },
maxMatches: 500,
},
(f) => out.push(f),
);
return out.sort((a, b) => a.relPath.localeCompare(b.relPath));
}
export async function openTarget(target: FileTarget, cwd: string, focus = true): Promise<void> {
if (!isTauri()) return;
if (target.resolve === "rubyTest") {
const site = target.className
? await findRubyTest(searchRuby, target.className, target.path).catch(() => null)
: null;
if (!site) {
notifications().notify({
group: "run",
level: "warning",
title: "File not found",
body: `${target.className ?? ""}#${target.path}`,
key: "run-link",
});
return;
}
await editorService().openFile(site.path, { selection: { line: site.line - 1, col: 0 }, focus });
return;
}
const path = await resolveTarget(target, cwd);
if (!path && target.className) {
// A library or JDK frame without sources: nothing to open, which is no error.
+44 -1
View File
@@ -10,7 +10,7 @@ vi.mock("@/platform/projectRoots", () => ({
}));
import type { RunConfig } from "@/platform/ipc";
import { produceContext, produceDirectory, type ToolEnv } from "./producers";
import { produceContext, produceDirectory, produceFile, type ToolEnv } from "./producers";
const env: ToolEnv = {
tool: (n) => (n === "ruby" ? { path: "/bin/ruby", version: null } : null),
@@ -73,3 +73,46 @@ describe("Ruby directory run", () => {
expect(dir("test/unit", [])).toBeNull();
});
});
describe("Ruby test files", () => {
const rake = {
id: "rake:test",
name: "rake test",
kind: "rake",
command: "bundle",
args: ["exec", "rake", "test"],
cwd: null,
env: {},
detected: true,
isTest: true,
debugAdapter: null,
} as RunConfig;
const minitest = {
...rake,
id: "minitest",
name: "minitest",
kind: "ruby",
command: "ruby",
args: ["-Ilib", "-Itest", "-e", "RUNNER", "test"],
} as RunConfig;
const file = (path: string, configs: RunConfig[]) =>
produceFile({ path: `/work/app/${path}`, root: "/work/app", configs, content: "" }, env);
it("test_*.rb is a test file: rake test with TEST=<file>, reported per test", () => {
const p = file("test/unit/test_decide.rb", [rake]);
expect(p?.config).toMatchObject({ isTest: true, args: ["exec", "rake", "test", "TEST=test/unit/test_decide.rb"] });
});
it("without a rake test task the detected Minitest runner loads the file", () => {
const p = file("test/unit/test_decide.rb", [minitest]);
expect(p?.config).toMatchObject({
isTest: true,
kind: "ruby",
args: ["-Ilib", "-Itest", "-e", "RUNNER", "test/unit/test_decide.rb"],
});
});
it("other scripts stay plain runs", () => {
expect(file("lib/tool.rb", [rake])?.config?.isTest).toBe(false);
});
});
+2 -2
View File
@@ -972,7 +972,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
export function produceContext(input: ContextInput, env: ToolEnv): Produced | null {
if (input.content !== undefined && input.line !== undefined) {
const targets = fileTargets(input, input.content.slice(0, 400_000));
const inner = innermostTarget(input.content, targets, input.line, /\.py$/.test(input.path));
const inner = innermostTarget(input.content, targets, input.line, /\.(py|rb)$/.test(input.path));
if (inner) {
const p = elementProduction(input, env, inner);
if (p) return p;
@@ -997,7 +997,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc
let element: RunTarget | null = null;
if (produced.scope === "element") {
const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000));
element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path));
element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.(py|rb)$/.test(input.path));
if (!element || element.kind === "main" || element.kind === "generate") return [produced];
} else if (!produced.config.isTest) {
return [produced];
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { detectRunTargets, targetConfig, targetLanguage } from "./targets";
const SRC = `require_relative "../test_helper"
module Outer
class InnerTest < Minitest::Test
def test_one = assert true
def test_two?
assert true
end
def helper = 1
end
class Helper
def test_not_a_test_class = 1
end
end
class TopTest < ActiveSupport::TestCase
test "does a thing" do
assert true
end
end
module Empty
class Plain
end
end
`;
const cfg = (over: Partial<RunConfig> & { id: string; kind: string }): RunConfig => ({
name: over.id,
command: "bundle",
args: [],
cwd: null,
env: {},
detected: true,
isTest: true,
debugAdapter: null,
...over,
});
describe("Ruby run targets", () => {
const targets = detectRunTargets(SRC, "test/unit/test_outer.rb");
const find = (name: string) => targets.find((t) => t.name === name)!;
it("is a target language", () => expect(targetLanguage("a.rb")).toBe("ruby"));
it("finds test classes, the modules holding them, and their test methods", () => {
expect(targets.map((t) => [t.kind, t.name, t.line])).toEqual([
["suite", "Outer", 2],
["suite", "Outer::InnerTest", 3],
["test", "Outer::InnerTest#test_one", 4],
["test", "Outer::InnerTest#test_two?", 6],
["suite", "TopTest", 18],
["test", "TopTest#test_does_a_thing", 19],
]);
expect(find("Outer").rubyModule).toBe(true);
expect(find("Outer::InnerTest").path).toEqual(["Outer::InnerTest"]);
expect(find("Outer::InnerTest#test_one").path).toEqual(["Outer::InnerTest", "test_one"]);
});
it("leaves modules without test classes, non-test classes and non-test files alone", () => {
expect(targets.some((t) => t.name === "Empty" || t.name.includes("Helper"))).toBe(false);
expect(detectRunTargets(SRC, "lib/outer.rb")).toEqual([]);
expect(detectRunTargets(SRC, "spec/outer_spec.rb")).toEqual([]);
});
describe("run configurations", () => {
const rake = cfg({ id: "rake:test", kind: "rake", args: ["exec", "rake", "test"] });
const minitest = cfg({ id: "minitest", kind: "ruby", command: "ruby", args: ["-Ilib", "-e", "RUNNER", "test"] });
const run = (t: string, base: RunConfig) => targetConfig(find(t), "test/unit/test_outer.rb", [base], SRC)?.args;
it("one test by its exact name, through rake and through the Minitest runner", () => {
expect(run("Outer::InnerTest#test_one", rake)).toEqual([
"exec",
"rake",
"test",
"TEST=test/unit/test_outer.rb",
"TESTOPTS=--name=Outer::InnerTest#test_one",
]);
expect(run("Outer::InnerTest#test_one", minitest)).toEqual([
"-Ilib",
"-e",
"RUNNER",
"test/unit/test_outer.rb",
"--name=Outer::InnerTest#test_one",
]);
});
it("a class runs its own tests, a module the tests of the classes inside it", () => {
expect(run("Outer::InnerTest", minitest)!.at(-1)).toBe("--name=/^Outer::InnerTest#/");
expect(run("Outer", minitest)!.at(-1)).toBe("--name=/^Outer::/");
expect(run("Outer", rake)!.at(-1)).toBe("TESTOPTS=--name=/^Outer::/");
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { FileMatches } from "@/platform/ipc";
import { findRubyTest, type RubySearch } from "./rubyTests";
const file = (path: string, ...lines: number[]): FileMatches => ({
path,
relPath: path.slice(1),
matches: lines.map((line) => ({ line, text: "", ranges: [], textOffset: 0 })),
});
/** A search over fixed hits: the first matching pattern fragment wins. */
function fake(hits: Record<string, FileMatches[]>): RubySearch & { calls: [string, string[] | undefined][] } {
const calls: [string, string[] | undefined][] = [];
const fn = (async (pattern: string, files?: string[]) => {
calls.push([pattern, files]);
const key = Object.keys(hits).find((k) => pattern.includes(k));
const found = key ? hits[key]! : [];
return files ? found.filter((f) => files.includes(f.path)) : found;
}) as RubySearch & { calls: [string, string[] | undefined][] };
fn.calls = calls;
return fn;
}
describe("finding a Minitest test's source", () => {
it("the def inside the file that declares the class", async () => {
const search = fake({
"(class|module)": [file("/p/test/a_test.rb", 0), file("/p/test/b_test.rb", 0)],
"def\\s+test_init": [file("/p/test/a_test.rb", 11), file("/p/test/b_test.rb", 4)],
});
// Only b declares TestB: its def wins even though a's file sorts first.
const owners = fake({
"(class|module)": [file("/p/test/b_test.rb", 0)],
"def\\s+test_init": [file("/p/test/a_test.rb", 11), file("/p/test/b_test.rb", 4)],
});
expect(await findRubyTest(owners, "TestB", "test_initialize")).toEqual({ path: "/p/test/b_test.rb", line: 5 });
expect(await findRubyTest(search, "TestA", "test_initialize")).toEqual({ path: "/p/test/a_test.rb", line: 12 });
});
it("looks for the last segment of a namespaced class", async () => {
const search = fake({
InnerTest: [file("/p/test/n_test.rb", 3)],
"def\\s+test_nested": [file("/p/test/n_test.rb", 4)],
});
expect(await findRubyTest(search, "Outer::InnerTest", "test_nested")).toEqual({
path: "/p/test/n_test.rb",
line: 5,
});
expect(search.calls[0]![0]).not.toContain("Outer");
});
it("spec-style tests: the it block of the describe, underscores for spaces", async () => {
const search = fake({
describe: [file("/p/spec/x_test.rb", 0)],
"does[ _]a[ _]thing": [file("/p/spec/x_test.rb", 6)],
});
expect(await findRubyTest(search, "Spec style", "test_0001_does_a_thing")).toEqual({
path: "/p/spec/x_test.rb",
line: 7,
});
});
it("falls back to the class when the method is not found, and to nothing when nothing is", async () => {
const onlyClass = fake({ "(class|module)": [file("/p/test/a_test.rb", 2)] });
expect(await findRubyTest(onlyClass, "TestA", "test_dynamic")).toEqual({ path: "/p/test/a_test.rb", line: 3 });
expect(await findRubyTest(fake({}), "TestA", "test_x")).toBeNull();
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Where a Minitest test is defined. Minitest's output names `Class#test_name` and no file for tests that
* pass, so the tree finds the source by searching the project: the class, then the `def` in its file.
* Spec-style tests (`describe "Thing" do it "does x"`) are reported as `Thing#test_0001_does_x`.
*/
import type { FileMatches } from "@/platform/ipc";
/** Runs a regular-expression search over the project's Ruby files, or over `files` when given. */
export type RubySearch = (pattern: string, files?: string[]) => Promise<FileMatches[]>;
export interface RubyTestSite {
path: string;
/** 1-based. */
line: number;
}
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const SPEC_TEST = /^test_\d+_(.+)$/;
const first = (hits: FileMatches[], file?: string) => {
const hit = file ? hits.find((h) => h.path === file) : hits[0];
return hit?.matches[0] ? { path: hit.path, line: hit.matches[0].line + 1 } : null;
};
export async function findRubyTest(search: RubySearch, className: string, test: string): Promise<RubyTestSite | null> {
const spec = SPEC_TEST.exec(test);
// `Outer::InnerTest` is declared as `class InnerTest` inside `module Outer`, or as written.
const last = className.split("::").pop()!;
const declared = spec
? `^\\s*(describe|context)\\s*\\(?\\s*(["'])${escape(className)}\\2`
: `^\\s*(class|module)\\s+([A-Z]\\w*::)*${escape(last)}\\b`;
const owners = await search(declared);
const files = owners.map((o) => o.path);
const inOwners = (pattern: string) => (files.length ? search(pattern, files) : search(pattern));
const body = spec
? // Spec descriptions lose their spaces to underscores in the method name.
`^\\s*it\\s*\\(?\\s*(["'])${escape(spec[1]!).replace(/_/g, "[ _]")}\\1`
: `^\\s*def\\s+${escape(test)}\\b`;
const hits = await inOwners(body);
// The same method name is common (`test_initialize`): prefer the file that declares the class.
const site =
first(
hits,
files.find((f) => hits.some((h) => h.path === f)),
) ?? first(hits);
return site ?? first(owners);
}
+66 -2
View File
@@ -2,7 +2,7 @@
* Run targets inside a file, for the gutter ▶ icons and "Run test" code lenses:
* `fn main`, `#[test]` functions and `mod tests`, JS/TS `test`/`it`/`describe`,
* pytest `def test_*` / `class Test*`, unittest `TestCase` subclasses, Go `func TestX(t *testing.T)`,
* JUnit `@Test`.
* JUnit `@Test`, Minitest / Test::Unit classes, modules holding them and `def test_*`.
* Line-based regex detection (cheap, no parser), plus the run configuration for
* one target derived from the project's detected test configurations.
*/
@@ -34,12 +34,14 @@ export interface RunTarget {
spring?: boolean;
/** Python: a unittest `TestCase` subclass or one of its methods (what Django's runner can run). */
unittest?: boolean;
/** Ruby: a `module` suite, which runs the tests of every class declared inside it. */
rubyModule?: boolean;
}
/** Above this size we skip detection (generated / minified files). */
export const MAX_SCAN = 400_000;
type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | null;
type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | "ruby" | null;
export function targetLanguage(relPath: string): Lang {
if (/\.rs$/.test(relPath)) return "rust";
@@ -48,6 +50,7 @@ export function targetLanguage(relPath: string): Lang {
if (/\.php$/.test(relPath)) return "php";
if (/\.go$/.test(relPath)) return "go";
if (/\.(java|kt|kts|scala|groovy)$/.test(relPath)) return "jvm";
if (/\.rb$/.test(relPath)) return "ruby";
return null;
}
@@ -211,6 +214,53 @@ function pythonTargets(lines: string[]): RunTarget[] {
return out;
}
/** A class that holds Minitest / Test::Unit tests: by its superclass or its name. */
const isRubyTestClass = (name: string, superclass: string | undefined) =>
/(Test|TestCase|Spec)$/.test(superclass ?? "") || /Tests?(Case)?$/.test(name.split("::").pop()!);
/**
* Minitest / Test::Unit: test classes, the modules that hold them (run together) and their
* `def test_*` methods (ActiveSupport's `test "does x" do` is `test_does_x`). Frames are tracked
* by indentation, as Python's are. Results name a class by its full name (`Outer::InnerTest`).
*/
function rubyTargets(lines: string[]): RunTarget[] {
const frames: Array<{ indent: number; name: string; test: boolean; at: number; hasTests: boolean }> = [];
const out: Array<RunTarget & { frame?: (typeof frames)[number] }> = [];
const fullName = () => frames.map((f) => f.name).join("::");
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!line.trim() || /^\s*#/.test(line)) continue;
const indent = indentOf(line);
while (frames.length && frames[frames.length - 1]!.indent >= indent) frames.pop();
const decl = /^\s*(class|module)\s+([A-Z]\w*(?:::[A-Z]\w*)*)(?:\s*<\s*([\w:]+))?/.exec(line);
if (decl) {
const test = decl[1] === "class" && isRubyTestClass(decl[2]!, decl[3]);
const frame = { indent, name: decl[2]!, test, at: i, hasTests: false };
frames.push(frame);
out.push({
line: i,
kind: "suite",
name: fullName(),
path: [fullName()],
...(decl[1] === "module" ? { rubyModule: true } : {}),
frame,
});
continue;
}
const owner = frames[frames.length - 1];
if (!owner?.test) continue;
const method =
/^\s*def\s+(test_\w*[?!]?)/.exec(line)?.[1] ??
(/^\s*test\s*\(?\s*(["'])(.+?)\1/.exec(line)?.[2] ?? "").replace(/\s+/g, "_").replace(/^(?=.)/, "test_");
if (method && method !== "test_") {
for (const f of frames) f.hasTests = true;
out.push({ line: i, kind: "test", name: `${fullName()}#${method}`, path: [fullName(), method] });
}
}
// A class or module that holds no tests is noise.
return out.filter((t) => t.kind !== "suite" || t.frame?.hasTests).map(({ frame: _frame, ...t }) => t);
}
/** `go test` reports subtest names with spaces as `_` (`t.Run("zero case")` → `TestX/zero_case`). */
export const goSubtestName = (name: string) => name.replace(/\s/g, "_");
@@ -367,6 +417,9 @@ export function detectRunTargets(text: string, relPath: string, django = false):
return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath));
case "jvm":
return /\.(java|kts?)$/.test(relPath) ? javaKotlinTargets(text, relPath) : jvmTargets(lines, relPath);
case "ruby":
// RSpec files (`spec/`) are not Minitest.
return /(^|\/)test_[^/]*\.rb$|_test\.rb$|(^|\/)tests?\//.test(relPath) ? rubyTargets(lines) : [];
default:
return [];
}
@@ -456,6 +509,17 @@ export function targetConfig(
};
return result;
}
case "rake":
case "ruby": {
// Minitest's `--name=`: `Class#test_x` names one test exactly, a regexp (no `$`, which a shell would
// eat) the rest. The `=` form is one argument: rake's test loader would `require` the value of `-n x`.
const full = target.path[0]!;
const pattern =
target.kind === "test" ? `${full}#${target.path[1]}` : target.rubyModule ? `/^${full}::/` : `/^${full}#/`;
return file.kind === "rake"
? named([...file.args, `TESTOPTS=--name=${pattern}`])
: named([...file.args, `--name=${pattern}`]);
}
case "cargo": {
const path = target.path.join("::");
return named(target.kind === "suite" ? [...file.args, "--", `${path}::`] : [...file.args, "--", path, "--exact"]);
+26 -2
View File
@@ -6,13 +6,14 @@ import { jsParser } from "./js";
import { jvmParser } from "./jvm";
import { phpunitParser, withTeamcity } from "./phpunit";
import { pytestParser } from "./pytest";
import { rubyParser } from "./ruby";
import { unittestParser } from "./unittest";
import type { TestEvent, TestOutputParser } from "./types";
export type { TestEvent, TestOutputParser, TestStatus } from "./types";
export { parseDuration } from "./types";
export { TestResults, nodeStatus, type TestNode } from "./results";
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, unittestParser };
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, rubyParser, unittestParser };
/** Runs several parsers side by side (unknown tool: e.g. `make test`). */
export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
@@ -22,7 +23,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
};
}
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "auto";
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "auto";
export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args">): Framework {
const cmd = [config.command, ...config.args].join(" ");
@@ -47,6 +48,10 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
case "gradle":
case "maven":
return "jvm";
case "ruby":
case "rake":
case "rspec":
return "ruby";
}
if (/\bcargo\b/.test(cmd)) return "cargo";
if (/\b(jest|vitest|npm|pnpm|yarn|bun|npx|deno)\b/.test(cmd)) return "js";
@@ -58,6 +63,21 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
return "auto";
}
/** `TESTOPTS=-v` / a trailing `-v`: Minitest prints a line per test only when verbose. */
function rubyVerbose<T extends Pick<RunConfig, "kind" | "command" | "args">>(config: T): T {
if (config.kind === "rspec") return config;
if (config.kind === "rake") {
const at = config.args.findIndex((a) => a.startsWith("TESTOPTS="));
if (at < 0) return { ...config, args: [...config.args, "TESTOPTS=-v"] };
// Options already given (`--name=Class#test_x`): add ours unless verbosity was chosen.
return /(^|\s)(-v|--verbose)(\s|$)/.test(config.args[at]!)
? config
: { ...config, args: config.args.map((a, i) => (i === at ? `${a} -v` : a)) };
}
const chosen = config.args.some((a) => a === "-v" || a === "--verbose");
return chosen ? config : { ...config, args: [...config.args, "-v"] };
}
/**
* Launch arguments that make a test run reportable per test: pytest and
* Django print only progress dots by default, so passed tests would never
@@ -79,6 +99,7 @@ export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" |
const chosen = config.args.some((a) => /^(-v|--verbosity)(=?\d)?$/.test(a));
return chosen ? config : { ...config, args: [...config.args, "-v", "2"] };
}
if (frameworkFor(config) === "ruby") return rubyVerbose(config);
if (frameworkFor(config) !== "pytest") return config;
const cmd = [config.command, ...config.args];
const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a));
@@ -103,6 +124,8 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
return goParser();
case "jvm":
return jvmParser();
case "ruby":
return rubyParser();
default:
return combinedParser([
cargoParser(),
@@ -112,6 +135,7 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
phpunitParser(),
goParser(),
jvmParser(),
rubyParser(),
]);
}
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { parseAll, withTestReporting, frameworkFor } from "./index";
import { TestResults, nodeStatus } from "./results";
import { rubyParser } from "./ruby";
// Captured from `ruby -Ilib test/mixed_test.rb -v --seed 1` (Minitest 6.0.6).
const MINITEST = `Run options: -v --seed 1
# Running:
MixedTest#test_pass = 0.00 s = .
MixedTest#test_fail = 0.00 s = F
MixedTest#test_skip = 0.00 s = S
MixedTest#test_error = 0.00 s = E
MixedTest#test_prints = hello from test
0.00 s = .
Spec style#test_0001_works = 0.00 s = .
Outer::InnerTest#test_nested = 0.00 s = .
Finished in 0.000893s, 7838.7459 runs/s, 5599.1042 assertions/s.
1) Failure:
MixedTest#test_fail [test/mixed_test.rb:5]:
Expected: 4
Actual: 3
2) Skipped:
MixedTest#test_skip [test/mixed_test.rb:7]:
later
3) Error:
MixedTest#test_error:
RuntimeError: boom
test/mixed_test.rb:6:in 'MixedTest#test_error'
7 runs, 5 assertions, 1 failures, 1 errors, 1 skips`;
describe("Minitest parser", () => {
const events = parseAll(rubyParser(), MINITEST);
const results = new TestResults();
results.apply(events);
it("reports every test under its class, with its status", () => {
const status = (cls: string, name: string) => nodeStatus(results.get([cls, name].join("\u0000"))!);
expect(results.roots.map((r) => r.name).sort()).toEqual(["MixedTest", "Outer::InnerTest", "Spec style"]);
expect(status("MixedTest", "test_pass")).toBe("passed");
expect(status("MixedTest", "test_fail")).toBe("failed");
expect(status("MixedTest", "test_error")).toBe("failed");
expect(status("MixedTest", "test_skip")).toBe("skipped");
expect(status("MixedTest", "test_prints")).toBe("passed");
expect(status("Spec style", "test_0001_works")).toBe("passed");
expect(status("Outer::InnerTest", "test_nested")).toBe("passed");
});
it("keeps a test's own output with it, and the result printed after it", () => {
expect(events).toContainEqual({ type: "output", path: ["MixedTest", "test_prints"], line: "hello from test" });
expect(events.filter((e) => e.type === "result" && e.path[1] === "test_prints")).toHaveLength(1);
});
it("attaches the failure text and locates it", () => {
const out = events
.filter((e) => e.type === "output" && e.path[1] === "test_fail")
.map((e) => (e as { line: string }).line);
expect(out).toEqual(expect.arrayContaining(["Expected: 4", " Actual: 3"]));
expect(events).toContainEqual({
type: "location",
path: ["MixedTest", "test_fail"],
location: { path: "test/mixed_test.rb", line: 5, resolve: "path" },
});
// An error has no location in its header: the first frame gives it.
expect(events).toContainEqual({
type: "location",
path: ["MixedTest", "test_error"],
location: { path: "test/mixed_test.rb", line: 6, resolve: "path" },
});
});
it("locates every test by class and name, once, for jumping to its source", () => {
const located = events.filter((e) => e.type === "location" && e.location.resolve === "rubyTest");
expect(located).toHaveLength(7);
expect(located).toContainEqual({
type: "location",
path: ["MixedTest", "test_pass"],
location: { path: "test_pass", className: "MixedTest", resolve: "rubyTest" },
});
// The first location wins in the tree: passing tests have no other.
expect(results.get(["MixedTest", "test_pass"].join("\u0000"))!.location?.resolve).toBe("rubyTest");
});
it("summarises the run", () => {
expect(events.at(-1)).toEqual({ type: "summary", passed: 4, failed: 2, skipped: 1, durationMs: 0.893 });
});
});
describe("Ruby test reporting", () => {
const cfg = (kind: string, command: string, args: string[]) => ({ kind, command, args });
it("asks Minitest for one line per test", () => {
expect(withTestReporting(cfg("ruby", "ruby", ["-Ilib", "test/a_test.rb"])).args).toEqual([
"-Ilib",
"test/a_test.rb",
"-v",
]);
expect(withTestReporting(cfg("rake", "bundle", ["exec", "rake", "test"])).args).toEqual([
"exec",
"rake",
"test",
"TESTOPTS=-v",
]);
expect(withTestReporting(cfg("ruby", "ruby", ["a_test.rb", "-v"])).args).toEqual(["a_test.rb", "-v"]);
expect(withTestReporting(cfg("rake", "rake", ["test", "TESTOPTS=--seed 1"])).args).toEqual([
"test",
"TESTOPTS=--seed 1 -v",
]);
expect(withTestReporting(cfg("rake", "rake", ["test", "TESTOPTS=--name=Foo#test_a"])).args).toEqual([
"test",
"TESTOPTS=--name=Foo#test_a -v",
]);
// RSpec has no parser yet: untouched.
expect(withTestReporting(cfg("rspec", "rspec", ["spec"])).args).toEqual(["spec"]);
});
it("is the framework of Ruby configurations", () => {
expect(frameworkFor(cfg("ruby", "ruby", []))).toBe("ruby");
expect(frameworkFor(cfg("rake", "bundle", ["exec", "rake", "test"]))).toBe("ruby");
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Minitest's verbose output (`-v`; Test::Unit's minitest shim prints the same): one
* `Class#test_name = 0.00 s = .` line per test (`.` passed, `F` failed, `E` error, `S` skipped),
* then the failure sections and the summary. Tests are reported as `[Class, test_name]`.
*
* ```text
* MixedTest#test_prints = hello from test <- the test's own output comes first,
* 0.00 s = . the result follows on a line of its own
*
* 1) Failure:
* MixedTest#test_fail [test/mixed_test.rb:5]:
* Expected: 4
* Actual: 3
*
* 7 runs, 5 assertions, 1 failures, 1 errors, 1 skips
* ```
*/
import { parseDuration, type TestEvent, type TestOutputParser, type TestStatus } from "./types";
// A class may be a spec description with spaces (`Spec style#test_0001_works`).
const RESULT_RE = /^(.+?)#(\S+) = ([\d.]+ s) = ([.FES])$/;
const STARTED_RE = /^(.+?)#(\S+) = (.*)$/;
/** The result of a test that printed output first. */
const LATE_RESULT_RE = /^([\d.]+ s) = ([.FES])$/;
const SECTION_RE = /^\s*\d+\) (Failure|Error|Skipped):$/;
const SECTION_TEST_RE = /^(.+?)#(\S+?)(?: \[(.+?):(\d+)\])?:$/;
const FRAME_RE = /^\s+(\S+?):(\d+):in /;
const SUMMARY_RE = /^(\d+) runs, \d+ assertions, (\d+) failures, (\d+) errors, (\d+) skips/;
const FINISHED_RE = /^Finished in ([\d.]+s)/;
/** Gems and the standard library never locate a failure. */
const LIBRARY_RE = /[/\\](?:gems|rubygems|lib[/\\]ruby)[/\\]/;
const STATUS: Record<string, Exclude<TestStatus, "running">> = {
".": "passed",
F: "failed",
E: "failed",
S: "skipped",
};
/** Minitest names no file for a passing test: its location is searched for when the user jumps to it. */
const located = (path: string[]): TestEvent => ({
type: "location",
path,
location: { path: path[1]!, className: path[0], resolve: "rubyTest" },
});
export function rubyParser(): TestOutputParser {
const seen = new Set<string>();
/** The test's own location, once. */
const locate = (path: string[]): TestEvent[] => {
const id = path.join("\0");
if (seen.has(id)) return [];
seen.add(id);
return [located(path)];
};
/** A test whose result line has not been printed yet: its own output came first. */
let pending: string[] | null = null;
let section: { path: string[] | null; located: boolean; expectTest: boolean } | null = null;
let finished: number | undefined;
return {
feed(line) {
const events: TestEvent[] = [];
let m: RegExpExecArray | null;
if ((m = SUMMARY_RE.exec(line))) {
section = null;
const [runs, failures, errors, skips] = [m[1], m[2], m[3], m[4]].map(Number) as [
number,
number,
number,
number,
];
events.push({
type: "summary",
passed: Math.max(0, runs - failures - errors - skips),
failed: failures + errors,
skipped: skips,
durationMs: finished,
});
return events;
}
if ((m = FINISHED_RE.exec(line))) {
finished = parseDuration(m[1]);
pending = null;
return events;
}
if (SECTION_RE.test(line)) {
section = { path: null, located: false, expectTest: true };
return events;
}
if (section) {
if (section.expectTest) {
const t = SECTION_TEST_RE.exec(line);
section.expectTest = false;
if (t) {
section.path = [t[1]!, t[2]!];
events.push({ type: "output", path: section.path, line });
if (t[3]) {
section.located = true;
events.push({
type: "location",
path: section.path,
location: { path: t[3], line: Number(t[4]), resolve: "path" },
});
}
return events;
}
}
if (section.path) {
events.push({ type: "output", path: section.path, line });
const frame = FRAME_RE.exec(line);
// An error has no `[file:line]` in its header: its first frame outside the libraries is the place.
if (frame && !section.located && !LIBRARY_RE.test(frame[1]!)) {
section.located = true;
events.push({
type: "location",
path: section.path,
location: { path: frame[1]!, line: Number(frame[2]), resolve: "path" },
});
}
}
return events;
}
if ((m = RESULT_RE.exec(line))) {
const path = [m[1]!, m[2]!];
events.push({ type: "result", path, status: STATUS[m[4]!]!, durationMs: parseDuration(m[3]) }, ...locate(path));
pending = null;
return events;
}
if (pending && (m = LATE_RESULT_RE.exec(line))) {
events.push({ type: "result", path: pending, status: STATUS[m[2]!]!, durationMs: parseDuration(m[1]) });
pending = null;
return events;
}
if ((m = STARTED_RE.exec(line)) && /^(test|\w*_\d+)/.test(m[2]!)) {
pending = [m[1]!, m[2]!];
events.push({ type: "start", path: pending }, ...locate(pending));
if (m[3]) events.push({ type: "output", path: pending, line: m[3] });
return events;
}
if (pending) events.push({ type: "output", path: pending, line });
return events;
},
};
}
+2 -2
View File
@@ -10,8 +10,8 @@ export interface TestLocation {
/** 1-based, as printed. */
line?: number;
col?: number;
/** `name`: a file name to look up in the project (JVM / Go frames). */
resolve: "path" | "name";
/** `name`: a file name to look up in the project (JVM / Go frames); `rubyTest`: a test to search for (`rubyTests.ts`). */
resolve: "path" | "name" | "rubyTest";
/** JVM frames: the class's binary name; `path` is then the file under its package directory. */
className?: string;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc
import { inFileSet } from "@/platform/projectRoots";
import { searchEndpoints, searchableSettings } from "@/workbench/contributions";
import { projectRoot } from "../common/open";
import { cancelRequest, streamSearch } from "../common/textSearch";
import { cancelRequest, streamSearch } from "@/platform/textSearch";
import type { SeDeps } from "./controller";
import { recentActions } from "./store";
+1 -1
View File
@@ -8,7 +8,7 @@ import type { FindResultsSource } from "@/workbench/contributions";
import type { MenuItem } from "@/ui";
import { loadJson, saveJson } from "../common/open";
import { maybeToolWindowService } from "../common/services";
import { cancelRequest, streamSearch } from "../common/textSearch";
import { cancelRequest, streamSearch } from "@/platform/textSearch";
import { FindSession, IDLE, countFileMatches, type FindDeps, type FindState } from "./session";
export interface FindTab {