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

+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"]);