diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 7e7d49b0..9b1e98bc 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -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() diff --git a/src/features/run/context.ts b/src/features/run/context.ts index fe8c40b9..26ba771f 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -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 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; diff --git a/src/features/run/links.ts b/src/features/run/links.ts index 563118bb..cd195ecb 100644 --- a/src/features/run/links.ts +++ b/src/features/run/links.ts @@ -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; } diff --git a/src/features/run/navigate.ts b/src/features/run/navigate.ts index a81dcfae..3d4eee6a 100644 --- a/src/features/run/navigate.ts +++ b/src/features/run/navigate.ts @@ -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 { @@ -90,8 +92,45 @@ export async function resolveTarget(target: FileTarget, cwd: string): Promise out.push(f), + ); + return out.sort((a, b) => a.relPath.localeCompare(b.relPath)); +} + export async function openTarget(target: FileTarget, cwd: string, focus = true): Promise { 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. diff --git a/src/features/run/producers.ruby.test.ts b/src/features/run/producers.ruby.test.ts index bcf37acb..d37ff00c 100644 --- a/src/features/run/producers.ruby.test.ts +++ b/src/features/run/producers.ruby.test.ts @@ -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=, 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); + }); +}); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index ce9347c7..aef87734 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -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]; diff --git a/src/features/run/rubyTargets.test.ts b/src/features/run/rubyTargets.test.ts new file mode 100644 index 00000000..d65f06a4 --- /dev/null +++ b/src/features/run/rubyTargets.test.ts @@ -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 & { 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::/"); + }); + }); +}); diff --git a/src/features/run/rubyTests.test.ts b/src/features/run/rubyTests.test.ts new file mode 100644 index 00000000..99b7f9b3 --- /dev/null +++ b/src/features/run/rubyTests.test.ts @@ -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): 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(); + }); +}); diff --git a/src/features/run/rubyTests.ts b/src/features/run/rubyTests.ts new file mode 100644 index 00000000..093941bd --- /dev/null +++ b/src/features/run/rubyTests.ts @@ -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; + +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 { + 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); +} diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 11bd6d4d..ef757312 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -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 = []; + 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"]); diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 889e4909..33704129 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -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): Framework { const cmd = [config.command, ...config.args].join(" "); @@ -47,6 +48,10 @@ export function frameworkFor(config: Pick>(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 /^(-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 { + 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"); + }); +}); diff --git a/src/features/run/testing/ruby.ts b/src/features/run/testing/ruby.ts new file mode 100644 index 00000000..a73fe6cb --- /dev/null +++ b/src/features/run/testing/ruby.ts @@ -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> = { + ".": "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(); + /** 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; + }, + }; +} diff --git a/src/features/run/testing/types.ts b/src/features/run/testing/types.ts index a54f2500..0985ff4b 100644 --- a/src/features/run/testing/types.ts +++ b/src/features/run/testing/types.ts @@ -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; } diff --git a/src/features/search/everywhere/deps.ts b/src/features/search/everywhere/deps.ts index f3c5c9e3..237cb78f 100644 --- a/src/features/search/everywhere/deps.ts +++ b/src/features/search/everywhere/deps.ts @@ -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"; diff --git a/src/features/search/find/windowStore.ts b/src/features/search/find/windowStore.ts index 1a8e03aa..a1b12cd5 100644 --- a/src/features/search/find/windowStore.ts +++ b/src/features/search/find/windowStore.ts @@ -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 { diff --git a/src/features/search/common/textSearch.ts b/src/platform/textSearch.ts similarity index 100% rename from src/features/search/common/textSearch.ts rename to src/platform/textSearch.ts