Файли
openidea/src/features/run/testing/parsers.test.ts
T
l0ng-ai 82b6980a59 feat: Second pass aligning interactions with IntelliJ IDEA (#23)
* feat: Second pass aligning interactions with IntelliJ IDEA

Result tool windows
- Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO,
  Inspection and Hierarchy, with an "N of M" status hint
- Find: reuse the unpinned tab, Pin Tab, source preview, group by file or
  directory, exclude moves to the next result, Rerun
- Usages: tabs with pinning, exclude and preview
- Problems, TODO, Inspection: no autoscroll by default, severity-first sorting,
  context menu with quick fixes and copy description
- Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling

Find in Files, bookmarks, navigation
- Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select
- Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓),
  Alt mnemonics, search context filter (comments / string literals)
- Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions,
  ⌘-click gutter toggles a bookmark
- Select In popup (⌥F1); Recent Locations double-click and Delete;
  Search Everywhere remembers the query per tab

Code insight and refactoring
- Extract/Introduce continue with in-place rename and name suggestions;
  expression and occurrence choosers with live highlighting
- Safe Delete confirms first; numbered Refactor This
- Quick Documentation history, in-popup links and a Documentation tool window;
  Quick Definition switches between targets and shows the whole declaration
- Goto chooser lists symbol names and can pin to the Find window
- Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary,
  inlay hint ⌘-click and context menu
- Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup,
  language surrounders with mnemonics
- File Structure popup as a filterable tree; Show Usages filter and preview;
  call hierarchy jumps to call sites

Editor
- Paste re-indents each line by default; Move Statement moves whole statements
  in and out of blocks and re-indents
- Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions;
  ⌥⌫ deletes the preceding word
- ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning
- Visual-column block selection, nested Collapse All, recursive folding,
  fold selection toggle, Go to Matching Brace, gutter drag selects lines,
  sticky line and error stripe clicks, Insert toggles overwrite mode

Run, VCS, workbench
- Gutter icons show the last test result; Modify Run Configuration; test tree
  hides passed tests, context menu and assertion diff
- Stack frames resolve by fully qualified class name; context run chooser
- Inline Smart Step Into
- Git Log multi-select filters, "me" and date range; favourite branches,
  Fetch and tags in the branch popup; stash Keep index and Unstash options
- Terminal tab titles follow the shell; Distraction Free persists; gear menu;
  sticky balloons move to the notifications tool window after a while

* fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out

Conflicts from restoring stashed changes show the user's uncommitted
changes on the left like other operations, and the checkout notification
names the new branch instead of the previous one.

* fix: Close the merge editor on Apply; confirm before terminating a running Run tab

Like IDEA, applying a merge result closes its tab, and closing a Run tab
whose process still runs asks before terminating it (with a Settings
toggle and "Do not ask again").

* fix(terminal): Don't start a new session in the background after closing the last tab

The hidden Terminal window stays mounted, so it reopened a session as soon
as the last tab closed. It now opens the Local tab only once the window is
shown again, as IDEA does.

* fix(vcs): Start the merge result from a three-way merge; close the editor on Apply

The result pane now begins with non-conflicting changes merged and each
conflict showing the base text, resolved from the side panes, instead of
raw conflict markers. Apply now reports success, so the tab closes.

fix(search): Match File Structure filter on word starts

* fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles

Adapters that list every remaining call in the function (debugpy) made Smart
Step Into fall back to a chooser that offered calls from later lines. Targets
are now narrowed to the calls on the paused line, so they are picked in place.

Anonymous functions and callbacks no longer get a 'no usages' hint, and
intention titles are no longer cut short when there is room for them.

* fix: Undo / Redo from the Edit menu and Find Action use the editor's history

They ran the webview's native undo, which misses programmatic edits such as
templates, completion and refactorings, so nothing happened in the editor.
2026-10-25 09:44:46 +08:00

433 рядки
15 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import { describe, expect, it } from "vitest";
import {
cargoParser,
createTestParser,
frameworkFor,
goParser,
jsParser,
jvmParser,
nodeStatus,
parseAll,
parseDuration,
pytestParser,
TestResults,
type TestOutputParser,
} from "./index";
function results(parser: TestOutputParser, text: string) {
const r = new TestResults();
r.apply(parseAll(parser, text));
return r;
}
function leaf(r: TestResults, ...path: string[]) {
return r.get(path.join("\u0000"));
}
describe("parseDuration", () => {
it("parses common forms", () => {
expect(parseDuration("(3 ms)")).toBe(3);
expect(parseDuration("1.2 s")).toBe(1200);
expect(parseDuration("0.00s")).toBe(0);
expect(parseDuration("12ms")).toBe(12);
expect(parseDuration("1m 3s")).toBe(63_000);
expect(parseDuration("0.05 sec")).toBe(50);
expect(parseDuration(undefined)).toBeUndefined();
expect(parseDuration("soon")).toBeUndefined();
});
});
describe("cargo test", () => {
const fixture = ` Compiling app v0.1.0 (/p/app)
Finished \`test\` profile [unoptimized + debuginfo] target(s) in 0.52s
Running unittests src/lib.rs (target/debug/deps/app-1a2b3c)
running 3 tests
test tests::adds ... ok
test tests::slow ... ignored, needs network
test tests::subtracts ... FAILED
failures:
---- tests::subtracts stdout ----
thread 'tests::subtracts' panicked at src/lib.rs:20:9:
assertion \`left == right\` failed
left: 1
right: 2
note: run with \`RUST_BACKTRACE=1\` environment variable to display a backtrace
failures:
tests::subtracts
test result: FAILED. 1 passed; 1 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running tests/api.rs (target/debug/deps/api-9f8e7d)
running 1 test
test health ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
`;
it("builds a tree per test binary and module", () => {
const r = results(cargoParser(), fixture);
expect(r.roots.map((n) => n.name)).toEqual(["src/lib.rs", "tests/api.rs"]);
expect(leaf(r, "src/lib.rs", "tests", "adds")?.own).toBe("passed");
expect(leaf(r, "src/lib.rs", "tests", "slow")?.own).toBe("skipped");
expect(leaf(r, "tests/api.rs", "health")?.own).toBe("passed");
expect(nodeStatus(r.roots[0]!)).toBe("failed");
expect(nodeStatus(r.roots[1]!)).toBe("passed");
});
it("captures failure output and panic location", () => {
const r = results(cargoParser(), fixture);
const failed = leaf(r, "src/lib.rs", "tests", "subtracts")!;
expect(failed.own).toBe("failed");
expect(failed.location).toEqual({ path: "src/lib.rs", line: 20, col: 9, resolve: "path" });
expect(failed.output.join("\n")).toContain("left: 1");
expect(r.firstFailure()?.id).toBe(failed.id);
});
it("accumulates summaries across binaries", () => {
const r = results(cargoParser(), fixture);
expect(r.summary).toMatchObject({ passed: 2, failed: 1, skipped: 1, durationMs: 10 });
expect(r.counts()).toMatchObject({ passed: 2, failed: 1, skipped: 1, total: 4 });
});
it("supports the pre-1.73 panic format", () => {
const r = results(
cargoParser(),
`test it_fails ... FAILED\n---- it_fails stdout ----\nthread 'it_fails' panicked at 'boom', src/main.rs:3:5\n`,
);
expect(leaf(r, "it_fails")?.location).toMatchObject({ path: "src/main.rs", line: 3, col: 5 });
});
it("parses nextest", () => {
const r = results(
cargoParser(),
` PASS [ 0.004s] app tests::adds
FAIL [ 0.120s] app tests::subtracts
Summary [ 0.130s] 2 tests run: 1 passed, 1 failed, 0 skipped`,
);
expect(leaf(r, "app", "tests", "adds")).toMatchObject({ own: "passed", durationMs: 4 });
expect(leaf(r, "app", "tests", "subtracts")?.own).toBe("failed");
expect(r.summary).toMatchObject({ passed: 1, failed: 1, durationMs: 130 });
});
});
describe("jest", () => {
const verbose = `PASS src/math.test.ts
math
✓ adds (2 ms)
nested
✓ deep (1 ms)
✓ top level
FAIL src/order.test.ts (5.2 s)
orders
✓ lists (3 ms)
✕ creates (12 ms)
○ skipped later
● orders › creates
expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
at Object.<anonymous> (node_modules/expect/build/index.js:1:1)
at Object.<anonymous> (src/order.test.ts:14:21)
Test Suites: 1 failed, 1 passed, 2 total
Tests: 1 failed, 1 skipped, 4 passed, 6 total
Snapshots: 0 total
Time: 6.1 s
`;
it("nests describe blocks by indentation", () => {
const r = results(jsParser(), verbose);
expect(leaf(r, "src/math.test.ts", "math", "adds")).toMatchObject({ own: "passed", durationMs: 2 });
expect(leaf(r, "src/math.test.ts", "math", "nested", "deep")?.own).toBe("passed");
expect(leaf(r, "src/math.test.ts", "top level")?.own).toBe("passed");
expect(leaf(r, "src/order.test.ts", "orders", "skipped later")?.own).toBe("skipped");
expect(nodeStatus(r.get("src/order.test.ts")!)).toBe("failed");
});
it("links failures to the first project stack frame", () => {
const r = results(jsParser(), verbose);
const failed = leaf(r, "src/order.test.ts", "orders", "creates")!;
expect(failed.own).toBe("failed");
expect(failed.location).toEqual({ path: "src/order.test.ts", line: 14, col: 21, resolve: "path" });
expect(failed.output.some((l) => l.includes("Expected: 2"))).toBe(true);
});
it("reads the summary", () => {
const r = results(jsParser(), verbose);
expect(r.summary).toMatchObject({ passed: 4, failed: 1, skipped: 1, durationMs: 6100 });
});
it("creates failed tests from non-verbose output", () => {
const r = results(
jsParser(),
`PASS src/a.test.js
FAIL src/b.test.js
● suite › broken
boom
at Object.<anonymous> (src/b.test.js:3:9)
`,
);
expect(r.get("src/a.test.js")?.own).toBe("passed");
expect(leaf(r, "src/b.test.js", "suite", "broken")?.location?.line).toBe(3);
});
});
describe("vitest", () => {
const fixture = ` RUN v3.2.4 /p/web
✓ src/a.test.ts (2 tests) 3ms
❯ src/b.test.ts (2 tests | 1 failed) 7ms
✓ cart > adds item 1ms
× cart > removes item 4ms
→ expected 1 to be 0
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/b.test.ts > cart > removes item
AssertionError: expected 1 to be 0 // Object.is equality
❯ src/b.test.ts:12:17
10| it("removes item", () => {
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 3 passed (4)
Start at 10:00:00
Duration 412ms
`;
it("builds file and test nodes", () => {
const r = results(jsParser(), fixture);
expect(r.get("src/a.test.ts")).toMatchObject({ own: "passed", durationMs: 3 });
expect(leaf(r, "src/b.test.ts", "cart", "adds item")?.own).toBe("passed");
expect(leaf(r, "src/b.test.ts", "cart", "removes item")).toMatchObject({ own: "failed", durationMs: 4 });
expect(nodeStatus(r.get("src/b.test.ts")!)).toBe("failed");
});
it("links the failure location", () => {
const r = results(jsParser(), fixture);
expect(leaf(r, "src/b.test.ts", "cart", "removes item")?.location).toEqual({
path: "src/b.test.ts",
line: 12,
col: 17,
resolve: "path",
});
});
it("reads counts and duration", () => {
const r = results(jsParser(), fixture);
expect(r.summary).toMatchObject({ passed: 3, failed: 1, durationMs: 412 });
expect(r.counts()).toMatchObject({ passed: 3, failed: 1 });
});
it("handles flat verbose lines", () => {
const r = results(jsParser(), ` ✓ src/x.test.ts > group > works 2ms\n ↓ src/x.test.ts > group > later`);
expect(leaf(r, "src/x.test.ts", "group", "works")?.own).toBe("passed");
expect(leaf(r, "src/x.test.ts", "group", "later")?.own).toBe("skipped");
});
});
describe("pytest", () => {
const verbose = `============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.3.2
collected 4 items
tests/test_math.py::test_add PASSED [ 25%]
tests/test_math.py::test_sub FAILED [ 50%]
tests/test_math.py::TestDiv::test_zero[1] SKIPPED (no zero) [ 75%]
tests/test_math.py::TestDiv::test_ok PASSED [100%]
=================================== FAILURES ===================================
___________________________________ test_sub ___________________________________
def test_sub():
> assert 1 - 1 == 1
E assert 0 == 1
tests/test_math.py:7: AssertionError
=========================== short test summary info ============================
FAILED tests/test_math.py::test_sub - assert 0 == 1
=============== 1 failed, 2 passed, 1 skipped in 0.12s ===============
`;
it("parses verbose node ids into a tree", () => {
const r = results(pytestParser(), verbose);
expect(leaf(r, "tests/test_math.py", "test_add")?.own).toBe("passed");
expect(leaf(r, "tests/test_math.py", "TestDiv", "test_zero[1]")?.own).toBe("skipped");
expect(leaf(r, "tests/test_math.py", "TestDiv", "test_ok")?.own).toBe("passed");
expect(nodeStatus(r.get("tests/test_math.py")!)).toBe("failed");
});
it("attaches failure sections and locations", () => {
const r = results(pytestParser(), verbose);
const failed = leaf(r, "tests/test_math.py", "test_sub")!;
expect(failed.own).toBe("failed");
expect(failed.location).toEqual({ path: "tests/test_math.py", line: 7, resolve: "path" });
expect(failed.output.some((l) => l.includes("assert 0 == 1"))).toBe(true);
});
it("reads the final summary", () => {
const r = results(pytestParser(), verbose);
expect(r.summary).toMatchObject({ passed: 2, failed: 1, skipped: 1, durationMs: 120 });
});
it("resolves non-verbose failures from the short summary", () => {
const plain = `tests/test_math.py .F. [100%]
=================================== FAILURES ===================================
_______________________________ TestDiv.test_ok ________________________________
tests/test_math.py:21: ZeroDivisionError
=========================== short test summary info ============================
FAILED tests/test_math.py::TestDiv::test_ok - ZeroDivisionError
========================= 1 failed, 2 passed in 0.05s =========================
`;
const r = results(pytestParser(), plain);
const failed = leaf(r, "tests/test_math.py", "TestDiv", "test_ok")!;
expect(failed.own).toBe("failed");
expect(failed.location?.line).toBe(21);
expect(r.roots.map((n) => n.name)).toEqual(["tests/test_math.py"]);
expect(r.counts()).toMatchObject({ passed: 2, failed: 1 });
});
});
describe("go test", () => {
const fixture = `=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestSub
math_test.go:12: expected 0, got 1
--- FAIL: TestSub (0.01s)
=== RUN TestTable
=== RUN TestTable/zero
=== RUN TestTable/one
--- PASS: TestTable (0.00s)
--- PASS: TestTable/zero (0.00s)
--- SKIP: TestTable/one (0.00s)
FAIL
FAIL example.com/calc 0.004s
ok example.com/util (cached)
? example.com/cmd [no test files]
`;
it("groups tests and subtests under their package", () => {
const r = results(goParser(), fixture);
expect(r.roots.map((n) => n.name)).toEqual(["example.com/calc", "example.com/util"]);
expect(leaf(r, "example.com/calc", "TestAdd")?.own).toBe("passed");
expect(leaf(r, "example.com/calc", "TestSub")).toMatchObject({ own: "failed", durationMs: 10 });
expect(leaf(r, "example.com/calc", "TestTable", "zero")?.own).toBe("passed");
expect(leaf(r, "example.com/calc", "TestTable", "one")?.own).toBe("skipped");
expect(r.get("example.com/util")?.own).toBe("passed");
});
it("captures the failure location", () => {
const r = results(goParser(), fixture);
expect(leaf(r, "example.com/calc", "TestSub")?.location).toEqual({
path: "math_test.go",
line: 12,
resolve: "name",
});
});
it("handles non-verbose output (failure lines after the result)", () => {
const r = results(
goParser(),
`--- FAIL: TestSub (0.00s)\n math_test.go:9: nope\nFAIL\nFAIL\texample.com/calc\t0.002s\n`,
);
expect(leaf(r, "example.com/calc", "TestSub")?.location?.line).toBe(9);
});
});
describe("gradle / maven", () => {
it("parses gradle test logging", () => {
const r = results(
jvmParser(),
`> Task :test
OrderControllerTest > shouldListOrders() PASSED
OrderControllerTest > shouldCreateOrder() FAILED
org.opentest4j.AssertionFailedError at OrderControllerTest.kt:22
OrderControllerTest > Paging > firstPage() SKIPPED
3 tests completed, 1 failed, 1 skipped
`,
);
expect(leaf(r, "OrderControllerTest", "shouldListOrders()")?.own).toBe("passed");
expect(leaf(r, "OrderControllerTest", "shouldCreateOrder()")?.location).toEqual({
path: "OrderControllerTest.kt",
line: 22,
resolve: "name",
});
expect(leaf(r, "OrderControllerTest", "Paging", "firstPage()")?.own).toBe("skipped");
expect(r.summary).toMatchObject({ passed: 1, failed: 1, skipped: 1 });
});
it("parses surefire reports", () => {
const r = results(
jvmParser(),
`[INFO] Running com.acme.OrderTest
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! -- in com.acme.OrderTest
[ERROR] com.acme.OrderTest.shouldTotal -- Time elapsed: 0.01 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:151)
at com.acme.OrderTest.shouldTotal(OrderTest.java:31)
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.acme.UserTest
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
`,
);
expect(nodeStatus(r.get("com.acme.OrderTest")!)).toBe("failed");
// Resolved by the frame's class (package directory + file), not by a bare file name.
expect(leaf(r, "com.acme.OrderTest", "shouldTotal")?.location).toEqual({
path: "com/acme/OrderTest.java",
line: 31,
resolve: "name",
className: "com.acme.OrderTest",
});
expect(r.get("com.acme.UserTest")?.own).toBe("passed");
expect(r.summary).toMatchObject({ passed: 2, failed: 1 });
});
});
describe("parser selection", () => {
const cfg = (kind: string, command: string, args: string[] = []) => ({ kind, command, args });
it("maps config kinds and commands to frameworks", () => {
expect(frameworkFor(cfg("cargo", "cargo", ["test"]))).toBe("cargo");
expect(frameworkFor(cfg("npm", "pnpm", ["run", "test"]))).toBe("js");
expect(frameworkFor(cfg("pytest", "uv", ["run", "pytest"]))).toBe("pytest");
expect(frameworkFor(cfg("go", "go", ["test", "./..."]))).toBe("go");
expect(frameworkFor(cfg("gradle", "./gradlew", ["test"]))).toBe("jvm");
expect(frameworkFor(cfg("shell", "npx vitest run"))).toBe("js");
expect(frameworkFor(cfg("make", "make", ["test"]))).toBe("auto");
});
it("the combined parser understands any supported format", () => {
const r = results(createTestParser(cfg("make", "make", ["test"])), "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s");
expect(leaf(r, "a")?.own).toBe("passed");
expect(leaf(r, "pkg", "TestB")?.own).toBe("passed");
});
});
describe("TestResults", () => {
it("marks unfinished tests when the process ends", () => {
const r = new TestResults();
r.apply([{ type: "start", path: ["s", "a"] }]);
expect(nodeStatus(r.get("s")!)).toBe("running");
r.finish(1);
expect(r.get("s\u0000a")?.own).toBe("skipped");
});
});