From f6a18231211d74a39bae5d7c8afe7e8dde96b008 Mon Sep 17 00:00:00 2001 From: Jacob Kjeldahl Date: Thu, 5 Nov 2026 14:37:08 +0100 Subject: [PATCH] feat(run): run Ruby tests from the editor and Project view (-I roots, test-root folders) (#1545) A Ruby file run from the context menu had no project load path, so require "foo" for lib/foo.rb failed: Sources roots are now passed as -I. Folders under a test root get Run Tests in '': RSpec on the folder, rake test with TEST= (two comma-separated globs, since Rake splits TEST at commas), or a detected minitest configuration that loads every *_test.rb / test_*.rb under the folder when the project has neither. Co-authored-by: kjeldahl <19719+kjeldahl@users.noreply.github.com> --- crates/ide-run/src/detect.rs | 14 +++++ crates/ide-run/tests/detect.rs | 19 +++++++ src/features/run/producers.ruby.test.ts | 75 +++++++++++++++++++++++++ src/features/run/producers.ts | 37 +++++++++++- 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/features/run/producers.ruby.test.ts diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index b7bc88027..7e7d49b0d 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -1059,6 +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) }"#; + fn uses_rspec(root: &Path) -> bool { root.join("spec").is_dir() || root.join(".rspec").is_file() @@ -1101,6 +1104,17 @@ fn detect_ruby(root: &Path, out: &mut Vec) { } if uses_rspec(root) { out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test()); + } else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") { + // Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`. + let mut args = Vec::new(); + for dir in ["lib", "test"] { + if root.join(dir).is_dir() { + args.push(format!("-I{dir}")); + } + } + args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]); + let extra: Vec<&str> = args.iter().map(String::as_str).collect(); + out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test()); } } diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index aebe520c2..5cbb58187 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -820,6 +820,25 @@ task default: :build assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake")); } +#[test] +fn ruby_minitest_projects_get_a_test_runner() { + let dir = project(&[("Gemfile", "source 'https://rubygems.org'\n"), ("lib/a.rb", ""), ("test/a_test.rb", "")]); + let c = detect(dir.path()); + let m = find(&c, "minitest"); + assert!(m.is_test); + assert_eq!(m.kind, "ruby"); + assert_eq!( + (m.command.as_str(), &m.args[..4]), + ("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..]) + ); + assert_eq!(m.args.last().map(String::as_str), Some("test")); + // A `test` Rake task or RSpec already runs the tests. + let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]); + assert!(!ids(&detect(dir.path())).contains(&"minitest")); + let dir = project(&[("Gemfile", "gem 'rspec'\n"), ("test/a_test.rb", "")]); + assert!(!ids(&detect(dir.path())).contains(&"minitest")); +} + #[test] fn ruby_without_bundler_runs_bare_tools() { let dir = project(&[("Rakefile", "task :test\n")]); diff --git a/src/features/run/producers.ruby.test.ts b/src/features/run/producers.ruby.test.ts new file mode 100644 index 000000000..bcf37acb1 --- /dev/null +++ b/src/features/run/producers.ruby.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/projectRoots", () => ({ + currentRoots: () => [ + { path: "lib", kind: "source", detectedFrom: null }, + { path: "test", kind: "test", detectedFrom: null }, + { path: "spec", kind: "test", detectedFrom: null }, + { path: "", kind: "source", detectedFrom: null }, + ], +})); + +import type { RunConfig } from "@/platform/ipc"; +import { produceContext, produceDirectory, type ToolEnv } from "./producers"; + +const env: ToolEnv = { + tool: (n) => (n === "ruby" ? { path: "/bin/ruby", version: null } : null), + compileDir: "/tmp/openidea-run", +}; +const input = (path: string) => ({ path, root: "/work/app", configs: [], content: undefined, line: undefined }); + +describe("Ruby file run", () => { + it("puts Sources roots on the load path", () => { + const p = produceContext(input("/work/app/test/integration/a_test.rb"), env); + expect(p?.config?.args).toEqual(["-Ilib", "test/integration/a_test.rb"]); + }); + + it("adds none for files outside the project", () => { + const p = produceContext(input("/elsewhere/a.rb"), env); + expect(p?.config?.args).toEqual(["a.rb"]); + }); +}); + +describe("Ruby directory run", () => { + 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, + }); + const dir = (path: string, configs: RunConfig[]) => + produceDirectory({ path: `/work/app/${path}`, root: "/work/app", configs }); + + it("RSpec on a nested folder", () => { + const p = dir("spec/models/user", [cfg({ id: "rspec", kind: "rspec", args: ["exec", "rspec"] })]); + expect(p?.config?.args).toEqual(["exec", "rspec", "spec/models/user"]); + expect(p?.name).toBe("Tests in 'user'"); + }); + + it("rake test with a TEST glob", () => { + const p = dir("test/unit", [cfg({ id: "rake:test", kind: "rake", args: ["exec", "rake", "test"] })]); + expect(p?.config?.args).toEqual(["exec", "rake", "test", "TEST=test/unit/**/*_test.rb,test/unit/**/test_*.rb"]); + }); + + it("the Minitest runner with Sources roots and the folder", () => { + const base = cfg({ id: "minitest", kind: "ruby", args: ["exec", "ruby", "-Itest", "-e", "SCRIPT", "test"] }); + const p = dir("test/unit", [base]); + expect(p?.config?.args).toEqual(["exec", "ruby", "-Itest", "-Ilib", "-e", "SCRIPT", "test/unit"]); + }); + + it("the test root itself", () => { + const p = dir("test", [cfg({ id: "rspec", kind: "rspec", args: ["exec", "rspec"] })]); + expect(p?.config?.args).toEqual(["exec", "rspec", "test"]); + }); + + it("nothing for non-test folders or projects without a Ruby runner", () => { + expect(dir("lib/unit", [cfg({ id: "rspec", kind: "rspec" })])).toBeNull(); + expect(dir("other/unit", [cfg({ id: "rspec", kind: "rspec" })])).toBeNull(); + expect(dir("test/unit", [])).toBeNull(); + }); +}); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index cfca6c5bb..ce9347c72 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -596,6 +596,10 @@ function simple( return { name: s.file, scope: "file", config: fileConfig(s, kind, t.command, args(t.command, t.name), debugAdapter) }; } +/** `-I` for each Sources root of the project (the project root itself is already the cwd). */ +const rubyLoadPath = (roots: readonly { path: string; kind: string }[]): string[] => + roots.filter((r) => r.kind === "source" && r.path !== "").map((r) => `-I${r.path}`); + const hasGoMain = (content?: string) => content === undefined || /^\s*package\s+main\b/m.test(content); const hasRustMain = (content?: string) => content === undefined || /^(?:pub\s+)?(?:async\s+)?fn\s+main\s*\(/m.test(content); @@ -798,6 +802,8 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null }; } case "ruby": + // Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative. + return simple(lang, s, env, lang, () => [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]); case "php": case "perl": case "lua": @@ -1021,6 +1027,33 @@ function cmakeTargets(input: ContextInput): RunConfig[] { const TEST_DIR = /(^|\/)(tests?|__tests__|specs?|test_?\w*)$/; +/** + * The tests of a Ruby folder: RSpec on the folder, `rake test` with `TEST=`, or the + * detected Minitest runner given the folder (Sources roots on its load path). + */ +function rubyDirectory( + closest: (kinds: string[]) => RunConfig | undefined, + relToCwd: (c: RunConfig) => string, + make: (base: RunConfig, label: string, args: string[]) => Produced, + label: string, +): Produced | null { + const rspec = closest(["rspec"]); + if (rspec) return make(rspec, label, [...rspec.args, relToCwd(rspec)]); + const rake = closest(["rake"]); + if (rake && rake.args.at(-1) === "test") { + // Rake splits `TEST` at commas into patterns, so no braces: two globs. + const dir = relToCwd(rake); + return make(rake, label, [...rake.args, `TEST=${dir}/**/*_test.rb,${dir}/**/test_*.rb`]); + } + const minitest = closest(["ruby"]); + if (!minitest) return null; + const at = minitest.args.indexOf("-e"); + const flags = rubyLoadPath(currentRoots()).filter((f) => !minitest.args.includes(f)); + const args = [...minitest.args.slice(0, -1)]; + args.splice(at < 0 ? args.length : at, 0, ...flags); + return make(minitest, label, [...args, relToCwd(minitest)]); +} + export function produceDirectory(input: ContextInput): Produced | null { const { root } = input; if (!root || !isProjectPath(input) || input.path === root) return null; @@ -1029,8 +1062,8 @@ export function produceDirectory(input: ContextInput): Produced | null { const jvmRoot = /^(?:(.*)\/)?src\/test(?:\/(java|kotlin|scala|groovy))?$/.exec(rel); const jvmPackage = /^(?:(.*)\/)?src\/test\/(?:java|kotlin|scala|groovy)\/(.+)$/.exec(rel); const marked = isTestPath(currentRoots(), rel); - if (!TEST_DIR.test(rel) && !jvmRoot && !jvmPackage && !marked && !/(^|\/)src\/test(\/|$)/.test(rel)) return null; const tests = input.configs.filter((c) => c.isTest); + if (!TEST_DIR.test(rel) && !jvmRoot && !jvmPackage && !marked && !/(^|\/)src\/test(\/|$)/.test(rel)) return null; const closest = (kinds: string[]) => { const cands = tests.filter((c) => kinds.includes(c.kind)); const score = (c: RunConfig) => { @@ -1071,6 +1104,8 @@ export function produceDirectory(input: ContextInput): Produced | null { } } const js = closest(["npm", "bun", "deno"]); + const ruby = marked && !js && rubyDirectory(closest, relToCwd, make, testsIn); + if (ruby) return ruby; const py = closest(["pytest"]); const django = closest(["django-test"]); const go = closest(["go"]);