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<root>. Folders under a test root get Run Tests in '<dir>': RSpec on the folder, rake test with TEST=<globs> (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 <[email protected]>
Цей коміт міститься в:
Jacob Kjeldahlтаkjeldahl authored and GitHub committed 2026-11-05 21:37:08 +08:00
1 джерело 4959b430b1
коміт f6a1823121
4 files changed
+144 -1

No files matched your search

+14
Переглянути файл
@@ -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<RunConfig>) {
}
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());
}
}
+19
Переглянути файл
@@ -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")]);
+75
Переглянути файл
@@ -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<RunConfig> & { 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();
});
});
+36 -1
Переглянути файл
@@ -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<dir>` 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=<glob>`, 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"]);