mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
kjeldahl
parent
f6a1823121
commit
087cb4cbaa
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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::/");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user