Порівняти коміти

...
1 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai ee0952e32e test(evals): measure Java code intelligence as the user sees it
`pnpm eval` drives the production workbench in Chromium against a release
`openidea-server` and jdtls, and judges each scenario on what the user sees: time to
feedback, to the first result and to the final one, and the results against
`evals/java/golden/`. Scenarios cover navigation, usages, implementations, gutter
markers, code lenses, diagnostics, completion, quick documentation and rename, in
`fixtures/java-app` and a pinned spring-petclinic, warm and cold. The report
(`target/eval/report.json` / `.md`) names each failure's kind, a repro command and the
server commands in the slowest run's window; `--save-baseline` compares later runs.

- The frontend gets `data-*` hooks the scenarios read: usage / chooser rows carry their
  location, the usages popup its loading state and scope, a hint whether it is progress
  and what it answers.
- `smoke/tauriShim.ts`: the in-page Tauri runtime shared by the jsdom smoke tests and
  the evals; `perf/lib/bundle.ts` builds the perf bundle for both.
- `.github/tool-versions.env` pins the language-server versions for Language smoke and
  the evals.
- `.agents/skills/eval`: the measure → fix → prove loop for agents.
2026-11-11 22:39:59 +08:00
82 змінених файлів з 3581 додано та 149 видалено

No files matched your search

+83
Переглянути файл
@@ -0,0 +1,83 @@
---
name: eval
description: Run the code-intelligence evals (`pnpm eval`), pick the worst failing scenario, diagnose it from the attached timeline, fix it, prove the fix with before / after numbers measured on this machine without regressing other scenarios, and open one PR. Use for "跑 eval", "优化 Java 代码智能", "eval 失败了", an issue labelled `eval`, or any change to Java navigation / usages / gutter markers that needs numbers.
---
# Eval loop: measure → fix → prove
`pnpm eval` drives the production workbench in Chromium against a release `openidea-server`
with real language servers (Java / jdtls for now) and judges every scenario on what the user
sees: time to feedback, to the first result, to the final one, and the results themselves vs
`evals/<language>/golden/`. One machine, before and after: numbers from different machines or
from the nightly host don't judge a fix.
## 0. Preconditions
- `jdtls` on PATH and `JAVA_HOME` pointing at a JDK of the pinned `JAVA` version (projects
compile against it, and the JDK scenarios' results are in its sources; jdtls itself runs on
a newer installed JDK when it needs one). Pinned versions: `.github/tool-versions.env`. A
mismatch prints a warning and the report records both: say so in the PR when it applies.
- First run builds `cargo build --release -p ide-host --bin openidea-server` and the perf
bundle; later iterations of a frontend-only change can use `--no-build` after a
`pnpm exec vite build --mode perf --outDir target/eval/dist`, a backend-only one after the
cargo build alone.
- Run evals one at a time on this machine, with nothing heavy (builds, other evals) beside them.
## 1. Baseline on the base commit
```sh
git switch --detach origin/main # or the fix branch's merge base
pnpm eval --save-baseline # target/eval/baseline.json, this machine only
```
Read `target/eval/report.json`. Each verdict has `status`, `failures` (`kind`: `error`,
`timeout`, `degraded`, `correctness`, `budget`, `golden`), `regressions`, `repro` (the command
that reruns it) and, for failures, `diagnostics`: every command sent to the server in the
slowest run's window (`cmd`, `start`, `ms`, `ok`, `bytes`, `items`), background tasks, server
status changes and the server's log tail.
## 2. Pick one problem
Worst first: wrong or missing results and silent degradation, then timeouts, then the
feedback budget (100 ms), then the slowest `completeMs`. One problem per PR. Say which
scenario and metric, with its value vs budget.
## 3. Diagnose from the timeline
- Commands in the window ordered by `start`: what ran, what waited on what, what's serial
that could overlap, which request returned many `items`/`bytes`, which failed.
- `feedbackMs` far above 16 ms with a fast `firstResultMs`: the UI shows nothing while it
waits (a hint or popup that only appears after a delay).
- A task still running across the window: background work competing with the request.
- For the frontend side rerun with `--trace` and open `target/eval/traces/<id>@<cond>.json`
in Chrome's Performance panel.
- Name the cause at class level (as in `issue-fix`): it usually affects sibling scenarios.
## 4. Fix, then rerun that scenario until it passes
```sh
pnpm eval --only <scenario> --condition <warm|cold> # the verdict's `repro`
```
Correctness is enforced, so a faster answer that drops, truncates or serves stale results
fails. Don't trade a budget miss for a degraded answer.
## 5. Full suite, then PR
```sh
pnpm eval # vs the baseline from step 1: no new failures or regressions
```
PR per `issue-fix` §6, plus in **How it was verified** the before / after table from
`target/eval/report.md` (both on this machine, commits named) and the tool versions line.
## Guard rails
- Goldens (`evals/*/golden/`) and budgets (`evals/budgets.json`) are human-owned. Don't edit
them, and don't run `--update-golden`, to make a scenario pass. When a golden looks wrong
(a tool bump, a deliberate behaviour change), stop and say so: a PR that touches them gets
`needs-decision`.
- Don't weaken a scenario's checks or timeouts in `evals/<language>/scenarios.ts` to pass.
Adding a scenario (for a case the sweep or a user found) is fine; record its golden with
`--update-golden --only <id>` and ask a person to review the file.
- A flaky timing (passes on rerun) is not a regression: rerun once before acting on one.
+1
Переглянути файл
@@ -0,0 +1 @@
../../.agents/skills/eval
+14
Переглянути файл
@@ -0,0 +1,14 @@
# Language server and tool versions for Language smoke (.github/workflows/language-smoke.yml)
# and the code-intelligence evals (pnpm eval). Pinned so a release can't fail an unrelated
# PR or move a golden result; bump them deliberately (and re-record goldens: pnpm eval --update-golden).
VTSLS=0.3.0
TYPESCRIPT=6.0.3
BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
JAVA=21
+4 -11
Переглянути файл
@@ -24,6 +24,7 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
- ".github/tool-versions.env"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
@@ -38,16 +39,6 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
VTSLS: 0.3.0
TYPESCRIPT: 6.0.3
BASEDPYRIGHT: 1.40.2
RUFF: 0.17.3
GOPLS: v0.24.0
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -61,6 +52,8 @@ jobs:
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
# Tool versions, shared with the evals (pnpm eval).
- run: grep -E '^[A-Z0-9_]+=' .github/tool-versions.env >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with: { components: "rust-analyzer, rustfmt" }
# One build cache for every language; main saves it.
@@ -96,7 +89,7 @@ jobs:
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "21" }
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
+1
Переглянути файл
@@ -40,6 +40,7 @@ CLAUDE.local.md
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
!.claude/skills/eval
# Local reference screenshots
pycharm-screenshots/
+1
Переглянути файл
@@ -9,6 +9,7 @@
"dist",
"target",
"src/bindings.ts",
"evals/*/golden",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
+11 -3
Переглянути файл
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
`fixtures/` — sample projects per language for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
@@ -59,9 +59,12 @@ Unclear product decisions → `needs-decision` and stop.
## Skills
Do not use Impeccable or its related skills in this repository, including globally installed copies.
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
`issue-fix` takes an issue from reproduction to one PR.
`issue-fix` takes an issue from reproduction to one PR;
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
## Commands
@@ -81,6 +84,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
usages, gutter markers) in the production workbench against a release `openidea-server` and
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"$comment": "Human-owned: agents don't change this file or evals/*/golden/ (a PR that does is labelled needs-decision). `*Ms` are upper bounds on medians (the user's view: input → first painted frame), `recall` / `precision` lower bounds vs the golden. Keys: `<scenario>` or `<scenario>@<condition>`. Regressions vs target/eval/baseline.json need both the relative tolerance and the absolute floor.",
"regressionTolerance": 0.2,
"regressionFloorMs": 200,
"defaults": {
"feedbackMs": 100,
"recall": 1,
"precision": 1
},
"scenarios": {}
}
+70
Переглянути файл
@@ -0,0 +1,70 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"add",
"addAll",
"addFirst",
"addLast",
"cast",
"clear",
"contains",
"containsAll",
"else",
"equals",
"for",
"forEach",
"fori",
"get",
"getClass",
"getFirst",
"getLast",
"hashCode",
"if",
"indexOf",
"isEmpty",
"iterator",
"lastIndexOf",
"listIterator",
"nn",
"not",
"notify",
"notifyAll",
"null",
"orders.nnull",
"orders.opt",
"orders.syserr",
"orders.sysouf",
"orders.sysout",
"orders.sysoutv",
"par",
"parallelStream",
"remove",
"removeAll",
"removeFirst",
"removeIf",
"removeLast",
"replaceAll",
"retainAll",
"return",
"reversed",
"set",
"size",
"sort",
"sout",
"spliterator",
"stream",
"subList",
"throw",
"toArray",
"toString",
"try",
"var",
"wait",
"while"
]
}
}
+43
Переглянути файл
@@ -0,0 +1,43 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"book.nnull",
"book.opt",
"book.syserr",
"book.sysouf",
"book.sysout",
"book.sysoutv",
"byCustomer",
"cast",
"else",
"equals",
"for",
"fori",
"getClass",
"hashCode",
"if",
"largest",
"nn",
"not",
"notify",
"notifyAll",
"null",
"par",
"place",
"return",
"revenueCents",
"size",
"sout",
"throw",
"toString",
"try",
"var",
"wait",
"while"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/junit-jupiter-api-5.11.3.jar/org.junit.jupiter.api/Assertions.java#assertEquals"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/AbstractList.java#AbstractList"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/ArrayList.java#ArrayList"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/Comparator.java#comparingLong"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/com/example/orders/OrderBook.java:28"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": [
"L33:error:Type mismatch: cannot convert from String to int"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": [
"L34:warning:The value of the local variable used is not used"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"documentation": [
"java.util.ArrayList.ArrayList<Order>() Constructs an empty list with an initial capacity of ten. Source: Java 21.0.13.0.2 (module: java.base)"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"documentation": [
"Order com.example.orders.OrderBook.place(String customer, String product, int quantity, long unitPriceCents) Places an order and returns it. Parameters: customer product quantity unitPriceCents Source: openidea-fixture-orders"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/com/example/orders/OrderBook.java:32"
]
}
}
+14
Переглянути файл
@@ -0,0 +1,14 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/ListPrice.java:7",
"src/main/java/com/example/orders/OrderBook.java:11",
"src/main/java/com/example/orders/OrderBook.java:28",
"src/main/java/com/example/orders/OrderBook.java:33"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/App.java:12",
"src/main/java/com/example/orders/OrderBook.java:11",
"src/test/java/com/example/orders/OrderBookTest.java:15"
]
}
}
+12
Переглянути файл
@@ -0,0 +1,12 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L4:implemented",
"L5:implemented"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L14:overriding"
]
}
}
+12
Переглянути файл
@@ -0,0 +1,12 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"src/main/java/com/example/orders/BulkDiscount.java:14",
"src/main/java/com/example/orders/ListPrice.java:6"
]
}
}
+81
Переглянути файл
@@ -0,0 +1,81 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"jdt://contents/java.base/java.lang.invoke/AbstractConstantGroup.java:175",
"jdt://contents/java.base/java.util.concurrent/CopyOnWriteArrayList.java:1269",
"jdt://contents/java.base/java.util.concurrent/CopyOnWriteArrayList.java:1742",
"jdt://contents/java.base/java.util.concurrent/CopyOnWriteArrayList.java:99",
"jdt://contents/java.base/java.util/AbstractList.java:75",
"jdt://contents/java.base/java.util/AbstractList.java:750",
"jdt://contents/java.base/java.util/AbstractList.java:919",
"jdt://contents/java.base/java.util/AbstractSequentialList.java:71",
"jdt://contents/java.base/java.util/ArrayList.java:109",
"jdt://contents/java.base/java.util/ArrayList.java:1194",
"jdt://contents/java.base/java.util/Arrays.java:4229",
"jdt://contents/java.base/java.util/Collections.java:1486",
"jdt://contents/java.base/java.util/Collections.java:1585",
"jdt://contents/java.base/java.util/Collections.java:2691",
"jdt://contents/java.base/java.util/Collections.java:2789",
"jdt://contents/java.base/java.util/Collections.java:3748",
"jdt://contents/java.base/java.util/Collections.java:3836",
"jdt://contents/java.base/java.util/Collections.java:4779",
"jdt://contents/java.base/java.util/Collections.java:5158",
"jdt://contents/java.base/java.util/Collections.java:5368",
"jdt://contents/java.base/java.util/ImmutableCollections.java:254",
"jdt://contents/java.base/java.util/ImmutableCollections.java:434",
"jdt://contents/java.base/java.util/ImmutableCollections.java:553",
"jdt://contents/java.base/java.util/ImmutableCollections.java:660",
"jdt://contents/java.base/java.util/LinkedList.java:1292",
"jdt://contents/java.base/java.util/LinkedList.java:90",
"jdt://contents/java.base/java.util/ReverseOrderListView.java:40",
"jdt://contents/java.base/java.util/ReverseOrderListView.java:55",
"jdt://contents/java.base/java.util/Stack.java:50",
"jdt://contents/java.base/java.util/Vector.java:92",
"jdt://contents/java.base/jdk.internal.classfile.components/ClassPrinter.java:151",
"jdt://contents/java.base/jdk.internal.classfile.impl/ClassPrinterImpl.java:78",
"jdt://contents/java.base/jdk.internal.classfile.impl/Util.java:161",
"jdt://contents/java.base/jdk.internal.org.objectweb.asm.tree/MethodNode.java:281",
"jdt://contents/java.base/sun.net.www.http/KeepAliveStreamCleaner.java:49",
"jdt://contents/java.base/sun.security.jca/ProviderList.java:153",
"jdt://contents/java.base/sun.security.jca/ProviderList.java:431",
"jdt://contents/java.desktop/javax.swing/JEditorPane.java:2010",
"jdt://contents/java.desktop/sun.awt.util/IdentityArrayList.java:95",
"jdt://contents/java.desktop/sun.swing/BakedArrayList.java:47",
"jdt://contents/java.management/com.sun.jmx.remote.internal/ArrayQueue.java:31",
"jdt://contents/java.management/javax.management.relation/RoleList.java:56",
"jdt://contents/java.management/javax.management.relation/RoleUnresolvedList.java:55",
"jdt://contents/java.management/javax.management/AttributeList.java:65",
"jdt://contents/java.xml/com.sun.org.apache.xalan.internal.xsltc.compiler.util/StringStack.java:30",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.util/ByteListImpl.java:36",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/Base64BinaryDV.java:60",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/HexBinaryDV.java:57",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/ListDV.java:58",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/XSSimpleTypeDecl.java:2263",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/XSSimpleTypeDecl.java:2298",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/XSSimpleTypeDecl.java:3447",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/LSInputListImpl.java:38",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/ObjectListImpl.java:35",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/ShortListImpl.java:36",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/StringListImpl.java:38",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/XSObjectListImpl.java:41",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs/PSVIErrorList.java:35",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs/XSModelImpl.java:63",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs.datatypes/ByteList.java:34",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs.datatypes/ObjectList.java:33",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/LSInputList.java:33",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/ShortList.java:31",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/StringList.java:31",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/XSNamespaceItemList.java:32",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/XSObjectList.java:32",
"jdt://contents/jdk.compiler/com.sun.tools.javac.model/FilteredMemberList.java:46",
"jdt://contents/jdk.compiler/com.sun.tools.javac.util/List.java:53",
"jdt://contents/jdk.compiler/com.sun.tools.javac.util/List.java:79",
"jdt://contents/jdk.jdi/com.sun.tools.jdi/EventSetImpl.java:86",
"jdt://contents/jdk.jshell/jdk.jshell/DiagList.java:38"
]
}
}
+12
Переглянути файл
@@ -0,0 +1,12 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"src/main/java/com/example/orders/BulkDiscount.java:4",
"src/main/java/com/example/orders/ListPrice.java:4"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L33:1804 usages",
"L73:226 usages",
"L92:3 usages"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L10:3 usages",
"L3:12 usages",
"L6:2 usages"
]
}
}
+14
Переглянути файл
@@ -0,0 +1,14 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"changes": [
"src/main/java/com/example/orders/ListPrice.java:7",
"src/main/java/com/example/orders/Order.java:11",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"changes": [
"src/main/java/com/example/orders/OrderBook.java:14",
"src/main/java/com/example/orders/OrderBook.java:15",
"src/main/java/com/example/orders/OrderBook.java:16"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/OrderBook.java:20",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29"
]
}
}
+16
Переглянути файл
@@ -0,0 +1,16 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"jdt://contents/java.base/java.util/Comparator.java:302",
"jdt://contents/java.base/java.util/Comparator.java:307",
"jdt://contents/jdk.jfr/jdk.jfr.internal.dcmd/AbstractDCmd.java:174",
"jdt://contents/jdk.jfr/jdk.jfr.internal.periodic/BatchManager.java:84",
"jdt://contents/jdk.jfr/jdk.jfr.internal/TypeLibrary.java:109",
"src/main/java/com/example/orders/OrderBook.java:24"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/ListPrice.java:7",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29"
]
}
}
+21
Переглянути файл
@@ -0,0 +1,21 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/BulkDiscount.java:14",
"src/main/java/com/example/orders/ListPrice.java:6",
"src/main/java/com/example/orders/OrderBook.java:10",
"src/main/java/com/example/orders/OrderBook.java:13",
"src/main/java/com/example/orders/OrderBook.java:14",
"src/main/java/com/example/orders/OrderBook.java:19",
"src/main/java/com/example/orders/OrderBook.java:23",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29",
"src/main/java/com/example/orders/PricingRule.java:5",
"src/test/java/com/example/orders/OrderBookTest.java:21"
]
}
}
+18
Переглянути файл
@@ -0,0 +1,18 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"Pageable",
"PageableArgumentResolver",
"PageableDefault",
"PageableExecutionUtils",
"PageableHandlerMethodArgumentResolver",
"PageableHandlerMethodArgumentResolverCustomizer",
"PageableHandlerMethodArgumentResolverSupport",
"PageableUtils"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/spring-data-commons-4.1.0.jar/org.springframework.data.repository/CrudRepository.java#save"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/spring-data-commons-4.1.0.jar/org.springframework.data.domain/PageRequest.java#PageRequest"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:45"
]
}
}
+9
Переглянути файл
@@ -0,0 +1,9 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": []
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"documentation": [
"org.springframework.data.domain.PageRequest Basic Java Bean implementation of Pageable. Author: Oliver Gierke Thomas Darimont Anastasiia Smirnova Mark Paluch Thach Le Source: spring-data-commons-4.1.0.jar"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L33:overridden"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L29:overridden"
]
}
}
+20
Переглянути файл
@@ -0,0 +1,20 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"src/main/java/org/springframework/samples/petclinic/model/NamedEntity.java:31",
"src/main/java/org/springframework/samples/petclinic/model/Person.java:29",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:50",
"src/main/java/org/springframework/samples/petclinic/owner/Pet.java:46",
"src/main/java/org/springframework/samples/petclinic/owner/PetType.java:28",
"src/main/java/org/springframework/samples/petclinic/owner/Visit.java:37",
"src/main/java/org/springframework/samples/petclinic/vet/Specialty.java:30",
"src/main/java/org/springframework/samples/petclinic/vet/Vet.java:45",
"src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java:84",
"src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java:89"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L38:82 usages",
"L76:3 usages",
"L84:3 usages"
]
}
}
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"changes": [
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:176",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:108",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:229"
]
}
}
+83
Переглянути файл
@@ -0,0 +1,83 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:107",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:132",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:133",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:141",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:154",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:181",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:182",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:65",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:66",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:78",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:95",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:36",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:39",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:42",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:45",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:48",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:50",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:51",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:55",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:60",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:101",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:108",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:145",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:186",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:68",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:69",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:70",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:83",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:84",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:66",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:67",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:98",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:114",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:22",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:43",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:45",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:176",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:192",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:201",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:215",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:227",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:302",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:73",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:74",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:95",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerTests.java:27",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerTests.java:40",
"src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java:167",
"src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java:258",
"src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java:77",
"src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java:61",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:100",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:102",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:112",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:115",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:131",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:133",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:160",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:162",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:188",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:190",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:220",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:222",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:239",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:241",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:256",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:258",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:285",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:287",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:296",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:298",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:34",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:91"
]
}
}
+24
Переглянути файл
@@ -0,0 +1,24 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:108",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:132",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:161",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:125",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:161",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:86",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:153",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:187",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:122",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:182",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:229",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:234",
"src/test/java/org/springframework/samples/petclinic/service/EntityUtils.java:46",
"src/test/java/org/springframework/samples/petclinic/vet/VetTests.java:38"
]
}
}
+838
Переглянути файл
@@ -0,0 +1,838 @@
/**
* Java code intelligence with jdtls: the steps users take most, timed as they see them and
* checked against `golden/<id>.json`. On `fixtures/java-app` (small, every case visible) and
* on Spring PetClinic (`evals/projects.json`: a real Spring Boot / Maven project).
*/
import { join } from "node:path";
import { track } from "../lib/measure.ts";
import { normalizePath, type EvalScenario, type Measured } from "../lib/scenario.ts";
import type { Session } from "../lib/session.ts";
const SRC = "src/main/java/com/example/orders";
const TEST = "src/test/java/com/example/orders";
const PET = "src/main/java/org/springframework/samples/petclinic";
const TIMEOUT = 30_000;
/** Results a whole file's worth of server requests make up. */
const LONG_TIMEOUT = 60_000;
interface ProbeWindow {
__openideaPerf: {
editorState(): { path: string; head: number; line: number; word: string } | null;
editorText(): string | null;
setCursor(offset: number): void;
insertAfter(after: string, text: string): number;
completions(): string[];
completionPending(): boolean;
diagnostics(): Array<{ line: number; severity: string; message: string }>;
lineAtY(y: number): number;
};
}
/** The visible editor hint, unless it only says a request is pending. */
async function finalHint(s: Session): Promise<string | null> {
return s.page.evaluate(() => {
const el = document.querySelector(".cm-lsp-hint:not([data-pending])") as HTMLElement | null;
return el && el.getClientRects().length ? el.textContent : null;
});
}
/** Where the caret is: `path:line` in the project, `uri#word` in a library class (its line depends on the JDK build). */
async function caretLocation(s: Session): Promise<string | null> {
const st = await s.page.evaluate(() => (window as unknown as ProbeWindow).__openideaPerf.editorState());
if (!st) return null;
const path = normalizePath(s.root, st.path);
return path.includes("://") ? `${path}#${st.word}` : `${path}:${st.line + 1}`;
}
/**
* Where a step starts: inside `word` on the first line containing `line` of `file`, or of the
* library class reached from `file` by going to the declaration of each `via` in turn. `edit`
* changes `file` first; `typed` changes the file reached right before the step, with no time
* for the server to catch up.
*/
interface Place {
file: string;
edit?: Insertion;
via?: Array<[line: string, word: string]>;
typed?: Insertion;
line: string;
word: string;
}
interface Insertion {
after: string;
text: string;
}
async function reach(s: Session, at: Place) {
await s.openFile(at.file);
if (at.edit) await s.edit(at.edit.after, at.edit.text);
for (const [line, word] of at.via ?? []) await s.declarationOf(line, word);
if (!at.typed) await s.caretAt(at.line, at.word);
}
/** What a step does first when its place is `typed`. */
async function type(s: Session, at: Place) {
if (!at.typed) return;
await s.edit(at.typed.after, at.typed.text);
await s.caretAt(at.line, at.word);
}
/** Text that marks an answer from syntax instead of the language server. */
const SYNTAX = /based on syntax|language server not ready/i;
function declaration(
project: string,
id: string,
title: string,
at: Place,
conditions: EvalScenario["conditions"] = ["warm"],
): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions,
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
await type(s, at);
// Moved to the declaration, or a hint saying why not (not one saying it is still looking).
const answered = (_: null, before: { path: string; head: number } | null) => {
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(".cm-lsp-hint:not([data-pending])");
};
const timing = await track(s.page, {
action: "navigate.declaration",
arg: null,
first: answered,
done: answered,
timeoutMs: TIMEOUT,
});
// A note that the answer came from syntax follows the navigation.
await s.page.waitForTimeout(300);
const hint = await finalHint(s);
const landed = await caretLocation(s);
return {
timing,
results: { declaration: landed ? [landed] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
/** Every usage row of the popup, scrolling its virtual list through. */
async function usageRows(s: Session): Promise<string[]> {
const rows = await s.page.evaluate(async () => {
const popup = document.querySelector('[data-testid="usages-popup"]');
if (!popup) return [];
const found = new Set<string>();
const collect = () => {
for (const r of popup.querySelectorAll<HTMLElement>('[data-testid="usage-row"]'))
found.add(`${r.dataset.path}\u0000${Number(r.dataset.line) + 1}`);
};
const scroller = [...popup.querySelectorAll<HTMLElement>("*")].find((el) => el.scrollHeight > el.clientHeight + 1);
collect();
if (scroller) {
for (let top = 0; top < scroller.scrollHeight; top += scroller.clientHeight / 2) {
scroller.scrollTop = top;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
collect();
}
}
return [...found];
});
return rows.map((r) => {
const [path, line] = r.split("\u0000") as [string, string];
return `${normalizePath(s.root, path)}:${line}`;
});
}
/**
* Show Usages at `at`. `widen`: in Project and Libraries, as invoking it again while the popup
* or its hint offers to widen a Project Files search does (the step is that second invocation).
*/
function showUsages(project: string, id: string, title: string, at: Place, widen = false): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
if (!widen) return;
await s.runAction("find.showUsages");
await s.page.waitForFunction(
() =>
document.querySelector('[data-testid="usages-popup"][data-loading="false"][data-scope="project"]') ||
document.querySelector(".cm-lsp-hint[data-usages]"),
undefined,
{ timeout: TIMEOUT },
);
},
async step(s): Promise<Measured> {
await type(s, at);
const timing = await track(s.page, {
action: "find.showUsages",
arg: widen,
// A single usage is jumped to; none says so in a hint.
first: (widen, before) => {
const popup = document.querySelector<HTMLElement>('[data-testid="usages-popup"]');
if (popup)
return (
(!widen || popup.dataset.scope === "libraries") && !!popup.querySelector('[data-testid="usage-row"]')
);
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(widen ? '.cm-lsp-hint[data-scope="libraries"]' : ".cm-lsp-hint[data-usages]");
},
done: (widen, before) => {
const popup = document.querySelector<HTMLElement>('[data-testid="usages-popup"]');
if (popup) return (!widen || popup.dataset.scope === "libraries") && popup.dataset.loading === "false";
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(widen ? '.cm-lsp-hint[data-scope="libraries"]' : ".cm-lsp-hint[data-usages]");
},
timeoutMs: TIMEOUT,
});
const popup = await s.page.$('[data-testid="usages-popup"]');
const rows = popup ? await usageRows(s) : [];
// Without a popup the hint gives the count: the only usage was jumped to (or the caret is on it).
const count = await s.page.evaluate(
() => (document.querySelector(".cm-lsp-hint[data-usages]") as HTMLElement | null)?.dataset.usages ?? null,
);
const single = !popup && count === "1" ? await caretLocation(s) : null;
const note = await s.page.evaluate(
() => document.querySelector('[data-testid="usages-note"]')?.textContent ?? null,
);
const hint = await finalHint(s);
return {
timing,
results: { usages: single ? [single] : rows },
degraded: note ?? (hint && SYNTAX.test(hint) ? hint : null),
};
},
};
}
/** Every row of the targets chooser (`path:line`), scrolling its virtual list through. */
async function chooserRows(s: Session): Promise<string[]> {
const rows = await s.page.evaluate(async () => {
const popup = document.querySelector('[data-testid="list-popup"]');
if (!popup) return [];
const found = new Set<string>();
const collect = () => {
for (const r of popup.querySelectorAll<HTMLElement>('[data-testid="list-popup-item"]'))
if (r.dataset.path) found.add(`${r.dataset.path}\u0000${Number(r.dataset.line) + 1}`);
};
const scroller = [...popup.querySelectorAll<HTMLElement>("*")].find((el) => el.scrollHeight > el.clientHeight + 1);
collect();
if (scroller) {
for (let top = 0; top < scroller.scrollHeight; top += scroller.clientHeight / 2) {
scroller.scrollTop = top;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
collect();
}
}
return [...found];
});
return rows.map((r) => {
const [path, line] = r.split("\u0000") as [string, string];
return `${normalizePath(s.root, path)}:${line}`;
});
}
/** Go to Implementation: the chooser's rows once it stops searching, or the only target. */
function implementation(project: string, id: string, title: string, at: Place): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
const before = await caretLocation(s);
// The chooser (busy while the server looks on), the only target, or a hint.
const answered = (searching: boolean, before: { path: string; head: number } | null) => {
const popup = document.querySelector<HTMLElement>('[data-testid="list-popup"]');
if (popup) return searching || popup.getAttribute("aria-busy") !== "true";
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(".cm-lsp-hint:not([data-pending])");
};
// Checks run in the page from their source, so the shared one goes in as the argument's source.
const timing = await track(s.page, {
action: "navigate.implementation",
arg: answered.toString(),
first: (src, before) => (new Function(`return ${src}`)() as typeof answered)(true, before),
done: (src, before) => (new Function(`return ${src}`)() as typeof answered)(false, before),
timeoutMs: TIMEOUT,
});
const popup = await s.page.$('[data-testid="list-popup"]');
const rows = popup ? await chooserRows(s) : [];
const at = await caretLocation(s);
const hint = await finalHint(s);
return {
timing,
results: { implementations: popup ? rows : at && at !== before ? [at] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
/**
* What opening `file`, or the library class reached from it through `via` (see `Place`), brings
* up beside its text: every `selector` with its line, as `L<line>:<its kind or text>`.
*/
function onOpen(
project: string,
id: string,
title: string,
file: string,
via: NonNullable<Place["via"]>,
group: string,
selector: string,
): EvalScenario {
const last = via[via.length - 1];
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
if (!last) return;
await s.openFile(file);
for (const [line, word] of via.slice(0, -1)) await s.declarationOf(line, word);
await s.caretAt(last[0], last[1]);
},
async step(s): Promise<Measured> {
const items = (selector: string) => {
const probe = (window as unknown as ProbeWindow).__openideaPerf;
const out: string[] = [];
for (const m of document.querySelectorAll<HTMLElement>(selector)) {
// A lens is a row of its own above the line it is about.
const r = (m.closest(".cm-openidea-lens")?.nextElementSibling ?? m).getBoundingClientRect();
out.push(`L${probe.lineAtY(r.top + r.height / 2) + 1}:${m.dataset.kind ?? m.textContent}`);
}
return out.sort();
};
const timing = await track(s.page, {
// A library class has no path to open: it is reached the way users do, which also shows its declaration.
...(last ? { action: "navigate.declaration" } : { openFile: join(s.root, file) }),
arg: selector,
first: (selector, before) =>
(window as unknown as ProbeWindow).__openideaPerf.editorState()?.path !== before?.path &&
!!document.querySelector(selector),
done: () => true,
signature: items,
settleMs: 2000,
timeoutMs: LONG_TIMEOUT,
});
return { timing, results: { [group]: await s.page.evaluate(items, selector) } };
},
};
}
const GUTTER = '.cm-editor [class*="cm-openidea-lineMarker-"]';
function gutterOnOpen(project: string, id: string, title: string, file: string, via: Place["via"] = []): EvalScenario {
return onOpen(project, id, title, file, via, "markers", GUTTER);
}
function lensOnOpen(project: string, id: string, title: string, file: string, via: Place["via"] = []): EvalScenario {
return onOpen(project, id, title, file, via, "lenses", ".cm-editor .cm-openidea-lensAction");
}
/** Errors and warnings in the active editor: hints and notes come and go with the server's settings. */
const problems = () =>
(window as unknown as ProbeWindow).__openideaPerf
.diagnostics()
.filter((d) => d.severity === "error" || d.severity === "warning")
.map((d) => `L${d.line + 1}:${d.severity}:${d.message}`)
.sort();
/** Waits for what the previous run left in the active editor to go. */
async function noErrors(s: Session) {
await s.page.waitForFunction(() => !document.querySelector(".cm-lintRange-error"), undefined, { timeout: TIMEOUT });
}
/** Opening a correct `file`: nothing is reported in it, once the server has had its say. */
function diagnosticsOnOpen(project: string, id: string, title: string, file: string): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup() {},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
openFile: join(s.root, file),
arg: null,
first: () => !!document.querySelector(".cm-content .cm-line"),
done: () => true,
signature: problems,
settleMs: 3000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Typing `edit` into a correct file: the error shows under it. */
function diagnosticsAfterEdit(project: string, id: string, title: string, at: Place, edit: Insertion): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await noErrors(s);
await s.keepText();
},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
run: (edit) => (window as unknown as ProbeWindow).__openideaPerf.insertAfter(edit.after, edit.text),
arg: edit,
// The typed text answers the keystroke; the error is the result.
feedback: () => true,
first: () => !!document.querySelector(".cm-lintRange-error"),
done: () => true,
signature: problems,
settleMs: 2000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Typing `fix` elsewhere repairs the mistake `edit` made: only the server can clear its error. */
function diagnosticsAfterFix(
project: string,
id: string,
title: string,
at: Place,
edit: Insertion,
fix: Insertion,
): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await noErrors(s);
await s.edit(edit.after, edit.text);
await s.page.waitForSelector(".cm-lintRange-error", { timeout: TIMEOUT });
},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
run: (fix) => (window as unknown as ProbeWindow).__openideaPerf.insertAfter(fix.after, fix.text),
arg: fix,
feedback: () => true,
first: () => !document.querySelector(".cm-lintRange-error"),
done: () => true,
signature: problems,
settleMs: 2000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Code Completion right after typing `edit`: the lookup's labels. */
function completion(project: string, id: string, title: string, file: string, edit: Insertion): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => s.openFile(file),
async step(s): Promise<Measured> {
const end = await s.edit(edit.after, edit.text);
await s.page.evaluate((o) => (window as unknown as ProbeWindow).__openideaPerf.setCursor(o), end);
const timing = await track(s.page, {
action: "code.completion",
arg: null,
first: () => (window as unknown as ProbeWindow).__openideaPerf.completions().length > 0,
// Quicker sources show first: every source has answered once it stops being pending.
done: () => !(window as unknown as ProbeWindow).__openideaPerf.completionPending(),
timeoutMs: TIMEOUT,
});
const labels = await s.page.evaluate(() => (window as unknown as ProbeWindow).__openideaPerf.completions());
return { timing, results: { completions: labels } };
},
};
}
/** Quick Documentation: the start of what the popup says. */
function quickDoc(project: string, id: string, title: string, at: Place): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
const shown = () => !!document.querySelector('[data-testid="doc-popup"], .cm-lsp-hint:not([data-pending])');
const text = () =>
document.querySelector('[data-testid="doc-popup"]')?.textContent?.replace(/\s+/g, " ").trim().slice(0, 300) ??
"";
const timing = await track(s.page, {
action: "code.quickDocumentation",
arg: null,
feedback: () => !!document.querySelector('[data-testid="doc-popup"]'),
first: shown,
done: shown,
signature: text,
settleMs: 500,
timeoutMs: TIMEOUT,
});
const doc = await s.page.evaluate(text);
const hint = await finalHint(s);
return {
timing,
results: { documentation: doc ? [doc] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
interface RenameAnswer {
edit: { files: Array<{ path?: string; edits?: Array<{ range: { start: { line: number } }; new_text: string }> }> };
}
/** Rename in place to `newName`, then ↵: every edit it makes (a preview is left unapplied). */
function rename(project: string, id: string, title: string, at: Place, newName: string): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await s.keepText();
await s.runAction("refactor.rename");
await s.page.waitForSelector(".cm-lsp-inplace-primary", { timeout: TIMEOUT });
await s.page.keyboard.type(newName);
},
async step(s): Promise<Measured> {
const answered = (newName: string) => {
if (document.querySelector('[data-testid="refactor-preview"]')) return true;
if (document.querySelector(".cm-lsp-hint:not([data-pending]):not(.cm-lsp-inplace-hint)")) return true;
return (
!document.querySelector(".cm-lsp-inplace") &&
!!(window as unknown as ProbeWindow).__openideaPerf.editorText()?.includes(newName)
);
};
const timing = await track(s.page, {
run: () =>
document.querySelector(".cm-content")?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
bubbles: true,
cancelable: true,
}),
),
arg: newName,
first: answered,
done: answered,
timeoutMs: TIMEOUT,
});
// The preview only counts per file: where the name lands is in the answer it shows. A server
// may merge nearby occurrences into one edit, so every line of its new text counts.
const answer = s.lastAnswer.get("lsp_rename") as RenameAnswer | undefined;
const changes = (answer?.edit.files ?? []).flatMap((f) =>
(f.edits ?? []).flatMap((e) =>
e.new_text
.split("\n")
.flatMap((l, i) =>
l.includes(newName) ? [`${normalizePath(s.root, f.path!)}:${e.range.start.line + i + 1}`] : [],
),
),
);
return { timing, results: { changes } };
},
};
}
const APP = "java-app";
const PETCLINIC = "spring-petclinic";
/** From `OrderBook` into the JDK's `ArrayList` / `Collection` sources. */
const BOOK = `${SRC}/OrderBook.java`;
const TO_ARRAYLIST: [string, string] = ["private final List<Order> orders = new ArrayList<>();", "ArrayList"];
/** A new method above the rest of `OrderBook`, calling `revenueCents`. */
const TWICE = "\n public long twice() {\n return revenueCents();\n }\n";
const TYPE_ERROR: Insertion = { after: "public int size() {", text: '\n int broken = "text";' };
const TO_STREAM: [string, string] = ["return orders.stream().mapToLong(Order::totalCents).sum();", "stream"];
export const JAVA_SCENARIOS: EvalScenario[] = [
declaration(
APP,
"java.app.declaration.project",
"Go to declaration: project method",
{ file: `${SRC}/App.java`, line: "long revenue = book.revenueCents();", word: "revenueCents" },
["warm", "cold"],
),
declaration(APP, "java.app.declaration.jdk", "Go to declaration: JDK class", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: TO_ARRAYLIST[1],
}),
declaration(APP, "java.app.declaration.dependency", "Go to declaration: dependency method (JUnit)", {
file: `${TEST}/OrderBookTest.java`,
line: "assertEquals(2500, book.revenueCents());",
word: "assertEquals",
}),
showUsages(APP, "java.app.usages.record", "Show Usages: record used across files", {
file: `${SRC}/Order.java`,
line: "public record Order(",
word: "Order",
}),
showUsages(APP, "java.app.usages.method", "Show Usages: method used through calls and method references", {
file: `${SRC}/Order.java`,
line: "public long totalCents() {",
word: "totalCents",
}),
gutterOnOpen(APP, "java.app.gutter.interface", "Gutter markers: open an interface", `${SRC}/PricingRule.java`),
gutterOnOpen(
APP,
"java.app.gutter.subclass",
"Gutter markers: open an overriding subclass",
`${SRC}/BulkDiscount.java`,
),
// The JDK's sources: their lines depend on its build (`java` in .github/tool-versions.env).
declaration(APP, "java.app.declaration.jdkMethod", "Go to declaration: JDK static method", {
file: BOOK,
line: "return orders.stream().max(Comparator.comparingLong(Order::totalCents));",
word: "comparingLong",
}),
declaration(APP, "java.app.declaration.inJdkSource", "Go to declaration from a JDK class: its superclass", {
file: BOOK,
via: [TO_ARRAYLIST],
line: "extends AbstractList<E>",
word: "AbstractList",
}),
showUsages(
APP,
"java.app.usages.jdkMethodLibraries",
"Show Usages in Project and Libraries: JDK method called once in the project",
{
file: BOOK,
line: "return orders.stream().max(Comparator.comparingLong(Order::totalCents));",
word: "comparingLong",
},
true,
),
showUsages(APP, "java.app.usages.inJdkSource", "Show Usages from a JDK class: a default method the project calls", {
file: BOOK,
via: [TO_STREAM],
line: "default Stream<E> stream() {",
word: "stream",
}),
implementation(APP, "java.app.implementation.jdkInterface", "Go to Implementation: JDK interface", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: "List",
}),
gutterOnOpen(APP, "java.app.gutter.jdkClass", "Gutter markers: open a JDK class", BOOK, [TO_ARRAYLIST]),
lensOnOpen(APP, "java.app.lens.project", "Usage lenses: open a project class", `${SRC}/Order.java`),
lensOnOpen(APP, "java.app.lens.jdkClass", "Usage lenses: open a JDK class", BOOK, [
["public Optional<Order> largest() {", "Optional"],
]),
implementation(APP, "java.app.implementation.projectInterface", "Go to Implementation: project interface", {
file: `${SRC}/PricingRule.java`,
line: "public interface PricingRule {",
word: "PricingRule",
}),
implementation(APP, "java.app.implementation.interfaceMethod", "Go to Implementation: interface method", {
file: `${SRC}/PricingRule.java`,
line: "long price(Order order);",
word: "price",
}),
// Right after an edit: results follow the new text, unsaved and with its lines moved.
declaration(APP, "java.app.edit.declaration", "Go to declaration right after typing above the target", {
file: BOOK,
typed: { after: "public class OrderBook {", text: TWICE },
line: "return revenueCents();",
word: "revenueCents",
}),
showUsages(APP, "java.app.edit.usagesSameFile", "Show Usages right after typing a call in the same file", {
file: BOOK,
typed: { after: "public class OrderBook {", text: TWICE },
line: "public long revenueCents() {",
word: "revenueCents",
}),
showUsages(APP, "java.app.edit.usagesOtherFile", "Show Usages after typing a call in another file", {
file: BOOK,
edit: {
after: "public class OrderBook {",
text: "\n public long firstTotal() {\n return orders.get(0).totalCents();\n }\n",
},
via: [["return orders.get(0).totalCents();", "totalCents"]],
line: "public long totalCents() {",
word: "totalCents",
}),
diagnosticsAfterEdit(
APP,
"java.app.diagnostics.afterError",
"Diagnostics: type a type error",
{ file: BOOK, line: "public class OrderBook {", word: "OrderBook" },
TYPE_ERROR,
),
diagnosticsAfterFix(
APP,
"java.app.diagnostics.afterFix",
"Diagnostics: declare the missing variable",
{ file: BOOK, line: "public class OrderBook {", word: "OrderBook" },
{ after: "public int size() {", text: "\n int used = missing;" },
{ after: "public class OrderBook {", text: "\n private final int missing = 0;" },
),
completion(APP, "java.app.completion.jdkMember", "Code Completion: members of a JDK type", BOOK, {
after: "public int size() {",
text: "\n orders.",
}),
completion(
APP,
"java.app.completion.projectMember",
"Code Completion: members of a project class",
`${SRC}/App.java`,
{
after: "long revenue = book.revenueCents();",
text: "\n book.",
},
),
quickDoc(APP, "java.app.doc.project", "Quick Documentation: project method", {
file: BOOK,
line: "public Order place(String customer, String product, int quantity, long unitPriceCents) {",
word: "place",
}),
quickDoc(APP, "java.app.doc.jdk", "Quick Documentation: JDK class", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: TO_ARRAYLIST[1],
}),
rename(
APP,
"java.app.rename.acrossFiles",
"Rename: method used across files",
{ file: `${SRC}/Order.java`, line: "public long totalCents() {", word: "totalCents" },
"orderTotalCents",
),
rename(
APP,
"java.app.rename.local",
"Rename: local variable",
{ file: BOOK, line: "Order order = new Order(customer, product, quantity, unitPriceCents);", word: "order" },
"placedOrder",
),
declaration(
PETCLINIC,
"java.petclinic.declaration.project",
"Go to declaration: Spring Data query method in the project",
{
file: `${PET}/owner/OwnerController.java`,
line: "return owners.findByLastNameStartingWith(lastname, pageable);",
word: "findByLastNameStartingWith",
},
["warm", "cold"],
),
declaration(
PETCLINIC,
"java.petclinic.declaration.inheritedLibrary",
"Go to declaration: method inherited from a Spring Data interface",
{ file: `${PET}/owner/OwnerController.java`, line: "this.owners.save(owner);", word: "save" },
),
declaration(PETCLINIC, "java.petclinic.declaration.libraryClass", "Go to declaration: Spring Data class", {
file: `${PET}/owner/OwnerController.java`,
line: "Pageable pageable = PageRequest.of(validatedPage - 1, pageSize);",
word: "PageRequest",
}),
showUsages(
PETCLINIC,
"java.petclinic.usages.entity",
"Show Usages: entity class used across controllers, repositories and tests",
{ file: `${PET}/owner/Owner.java`, line: "public class Owner extends Person {", word: "Owner" },
),
showUsages(
PETCLINIC,
"java.petclinic.usages.inheritedMethod",
"Show Usages: base-class getter called through subclasses",
{
file: `${PET}/model/BaseEntity.java`,
line: "public Integer getId() {",
word: "getId",
},
),
gutterOnOpen(
PETCLINIC,
"java.petclinic.gutter.baseClass",
"Gutter markers: open a base entity",
`${PET}/model/BaseEntity.java`,
),
gutterOnOpen(
PETCLINIC,
"java.petclinic.gutter.midClass",
"Gutter markers: open a mid-hierarchy entity",
`${PET}/model/Person.java`,
),
lensOnOpen(PETCLINIC, "java.petclinic.lens.entity", "Usage lenses: open an entity", `${PET}/owner/Owner.java`),
implementation(PETCLINIC, "java.petclinic.implementation.baseClass", "Go to Implementation: base entity", {
file: `${PET}/model/BaseEntity.java`,
line: "public class BaseEntity implements Serializable {",
word: "BaseEntity",
}),
diagnosticsOnOpen(
PETCLINIC,
"java.petclinic.diagnostics.cleanFile",
"Diagnostics: open a controller that compiles",
`${PET}/owner/OwnerController.java`,
),
completion(
PETCLINIC,
"java.petclinic.completion.libraryClass",
"Code Completion: class from a dependency, not imported",
`${PET}/owner/PetValidator.java`,
{ after: "public class PetValidator implements Validator {", text: "\n private Pageab" },
),
quickDoc(PETCLINIC, "java.petclinic.doc.library", "Quick Documentation: Spring Data class", {
file: `${PET}/owner/OwnerController.java`,
line: "Pageable pageable = PageRequest.of(validatedPage - 1, pageSize);",
word: "PageRequest",
}),
rename(
PETCLINIC,
"java.petclinic.rename.acrossFiles",
"Rename: entity method used across controllers and tests",
{ file: `${PET}/owner/Owner.java`, line: "public void addVisit(Integer petId, Visit visit) {", word: "addVisit" },
"recordVisit",
),
];
+40
Переглянути файл
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { compare, toolDrift } from "./golden.ts";
describe("compare", () => {
test("a perfect answer", () => {
expect(compare({ usages: ["a:1", "b:2"] }, { usages: ["b:2", "a:1"] })).toEqual({
recall: 1,
precision: 1,
missing: [],
extra: [],
});
});
test("dropped and extra results lower recall and precision", () => {
const c = compare({ usages: ["a:1", "c:3"] }, { usages: ["a:1", "b:2"] });
expect(c.recall).toBe(0.5);
expect(c.precision).toBe(0.5);
expect(c.missing).toEqual(["usages: b:2"]);
expect(c.extra).toEqual(["usages: c:3"]);
});
test("groups are part of the identity", () => {
expect(compare({ read: ["a:1"] }, { write: ["a:1"] }).recall).toBe(0);
});
test("nothing expected", () => {
expect(compare({}, {})).toMatchObject({ recall: 1, precision: 1 });
expect(compare({ usages: ["a:1"] }, {})).toMatchObject({ recall: 1, precision: 0 });
});
test("nothing found", () => {
expect(compare({}, { usages: ["a:1"] })).toMatchObject({ recall: 0, precision: 0 });
});
});
test("toolDrift lists tools that changed since the recording", () => {
expect(toolDrift({ tools: { jdtls: "1.63.0", java: "21" }, groups: {} }, { jdtls: "1.64.0", java: "21" })).toEqual([
"jdtls: 1.63.0 → 1.64.0",
]);
});
+62
Переглянути файл
@@ -0,0 +1,62 @@
/**
* Golden results: what a scenario found when a person last confirmed it, keyed to the
* language server and JDK that found it. Compared as sets (recall / precision), not by order.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
/** Result group → items (e.g. `src/…/OrderBook.java:14`); repeated items count once. */
export type ResultSet = Record<string, string[]>;
export interface Golden {
/** Tool versions the results were recorded with (`jdtls`, `java`). */
tools: Record<string, string>;
groups: ResultSet;
}
export interface Comparison {
recall: number;
precision: number;
/** In the golden, not found (`group: item`). */
missing: string[];
/** Found, not in the golden. */
extra: string[];
}
const keys = (set: ResultSet) => new Set(Object.entries(set).flatMap(([g, items]) => items.map((i) => `${g}: ${i}`)));
export function compare(found: ResultSet, golden: ResultSet): Comparison {
const got = keys(found);
const want = keys(golden);
const missing = [...want].filter((k) => !got.has(k));
const extra = [...got].filter((k) => !want.has(k));
const hit = want.size - missing.length;
return {
// Nothing expected and nothing found is a perfect answer.
recall: want.size ? hit / want.size : 1,
precision: got.size ? hit / got.size : want.size ? 0 : 1,
missing: missing.sort(),
extra: extra.sort(),
};
}
export function readGolden(file: string): Golden | null {
return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as Golden) : null;
}
export function writeGolden(file: string, golden: Golden) {
mkdirSync(dirname(file), { recursive: true });
const sorted = Object.fromEntries(
Object.entries(golden.groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([g, items]) => [g, [...new Set(items)].sort()]),
);
writeFileSync(file, JSON.stringify({ tools: golden.tools, groups: sorted }, null, 2) + "\n");
}
/** Tools that differ between the golden's recording and this run (`name: was → now`). */
export function toolDrift(golden: Golden, tools: Record<string, string>): string[] {
return Object.entries(golden.tools)
.filter(([k, v]) => tools[k] !== undefined && tools[k] !== v)
.map(([k, v]) => `${k}: ${v} → ${tools[k]}`);
}
+86
Переглянути файл
@@ -0,0 +1,86 @@
/** The machine and tools a report was measured with: a baseline only holds for the same ones. */
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { arch, cpus, platform, release, totalmem } from "node:os";
import { dirname, join } from "node:path";
export interface Fingerprint {
cpu: string;
cores: number;
memoryGb: number;
os: string;
}
export function fingerprint(): Fingerprint {
return {
cpu: cpus()[0]?.model.trim() ?? "unknown",
cores: cpus().length,
memoryGb: Math.round(totalmem() / 2 ** 30),
os: `${platform()} ${release()} ${arch()}`,
};
}
export function sameMachine(a: Fingerprint | undefined, b: Fingerprint): boolean {
return !!a && a.cpu === b.cpu && a.cores === b.cores && a.memoryGb === b.memoryGb && a.os === b.os;
}
/** `KEY=value` lines (`#` comments), as GitHub Actions' `$GITHUB_ENV` takes them; keys lower-cased. */
export function pinnedTools(file: string): Record<string, string> {
if (!existsSync(file)) return {};
const out: Record<string, string> = {};
for (const line of readFileSync(file, "utf8").split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(\S+)/.exec(line);
if (m) out[m[1]!.toLowerCase()] = m[2]!;
}
return out;
}
function which(cmd: string): string | null {
try {
const found = execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { encoding: "utf8" })
.split("\n")[0]!
.trim();
return found ? realpathSync(found) : null;
} catch {
return null;
}
}
/** jdtls's version from its core bundle (`org.eclipse.jdt.ls.core_1.63.0.202610191322.jar` → `1.63.0-202610191322`). */
function jdtlsVersion(): string | null {
const bin = which("jdtls");
if (!bin) return null;
for (let dir = dirname(bin), i = 0; i < 4; dir = dirname(dir), i++) {
for (const plugins of [join(dir, "plugins"), join(dir, "libexec", "plugins")]) {
if (!existsSync(plugins)) continue;
const core = readdirSync(plugins).find((f) => /^org\.eclipse\.jdt\.ls\.core_.*\.jar$/.test(f));
const m = core && /_(\d+\.\d+\.\d+)\.(\d+)\.jar$/.exec(core);
if (m) return `${m[1]}-${m[2]}`;
}
}
return null;
}
/**
* The JDK projects compile against, whose sources library navigation opens: `JAVA_HOME`'s (it
* wins for its Java version; jdtls itself runs on a newer one when it needs to), else the `java`
* on PATH. Runtime version and vendor (`-XshowSettings` prints to stderr).
*/
function javaVersion(): string | null {
const home = process.env.JAVA_HOME;
const java = home ? join(home, "bin", process.platform === "win32" ? "java.exe" : "java") : "java";
const r = spawnSync(java, ["-XshowSettings:properties", "-version"], { encoding: "utf8" });
const props = `${r.stdout ?? ""}${r.stderr ?? ""}`;
const version = /java\.runtime\.version = (\S+)/.exec(props)?.[1];
const vendor = /java\.vendor = (.+)/.exec(props)?.[1]?.trim();
return version ? `${version}${vendor ? ` (${vendor})` : ""}` : null;
}
export function toolVersions(): Record<string, string> {
const tools: Record<string, string> = {};
const jdtls = jdtlsVersion();
if (jdtls) tools.jdtls = jdtls;
const java = javaVersion();
if (java) tools.java = java;
return tools;
}
+154
Переглянути файл
@@ -0,0 +1,154 @@
/**
* Timing one user action in the page: the action is run, then every animation frame checks
* what the user would see. A condition true at the start of a frame is painted by it.
*/
import type { Page } from "@playwright/test";
/**
* A check of the page, run in it every frame: self-contained (it is re-created from its
* source), given the arguments passed to `track` and the editor state before the action.
*/
export type Check<A> = (arg: A, before: EditorSnapshot | null) => boolean;
export interface EditorSnapshot {
path: string;
head: number;
line: number;
word: string;
}
export interface Tracked {
/** Something visible changed in answer to the action (popup, hint, spinner, caret / editor moved). */
feedbackMs: number;
/** The first result is shown. */
firstResultMs: number;
/** The final result is shown and nothing is loading. */
completeMs: number;
}
export interface TrackSpec<A> {
/** The action run (`actionArg` its argument), the file opened (`openFile`), or a check-like step in the page (`run`). */
action?: string;
actionArg?: unknown;
openFile?: string;
run?: (arg: A) => unknown;
arg: A;
first: Check<A>;
done: Check<A>;
/** Extra feedback beyond the default (popup, hint, status spinner, editor or caret changed). */
feedback?: Check<A>;
/**
* For results with no loading indicator (gutter markers): done once `done` holds and
* `signature` has not changed for `settleMs`; complete is when it last changed.
*/
signature?: (arg: A) => unknown;
settleMs?: number;
timeoutMs: number;
}
export class TimeoutError extends Error {
readonly partial: Partial<Tracked>;
constructor(message: string, partial: Partial<Tracked>) {
super(message);
this.partial = partial;
}
}
export async function track<A>(page: Page, spec: TrackSpec<A>): Promise<Tracked> {
const r = await page.evaluate(
({ action, actionArg, openFile, run, arg, first, done, feedback, signature, settleMs, timeoutMs }) => {
const w = window as unknown as {
__openideaPerf: {
runAction(id: string, arg?: unknown): Promise<boolean>;
openFile(path: string): Promise<void>;
editorState(): EditorSnapshot | null;
};
};
const make = (src: string | null) =>
src
? (new Function("arg", "before", `return (${src})(arg, before)`) as (a: unknown, b: unknown) => boolean)
: null;
const checks = { first: make(first)!, done: make(done)!, feedback: make(feedback) };
const sign = signature
? (new Function("arg", `return JSON.stringify((${signature})(arg))`) as (a: unknown) => string)
: null;
let lastSig: string | null = null;
let lastChange = 0;
const before = w.__openideaPerf.editorState();
const visible = (sel: string) =>
[...document.querySelectorAll(sel)].some((el) => (el as HTMLElement).getClientRects().length > 0);
const anyFeedback = () => {
if (visible('.cm-lsp-hint, [data-testid="usages-popup"], [role="dialog"], [role="listbox"]')) return true;
const now = w.__openideaPerf.editorState();
if ((now?.path ?? null) !== (before?.path ?? null) || (now && before && now.head !== before.head)) return true;
return checks.feedback?.(arg, before) ?? false;
};
const times: { feedbackMs?: number; firstResultMs?: number; completeMs?: number } = {};
const t0 = performance.now();
let failed: unknown = null;
const started = run
? new Promise((resolve) => resolve(make(run)!(arg, before)))
: openFile
? w.__openideaPerf.openFile(openFile)
: w.__openideaPerf.runAction(action!, actionArg);
void started.catch((e: unknown) => (failed = e));
return new Promise<{ times: typeof times; timedOut: boolean; error: string | null }>((resolve) => {
const tick = () => {
const t = performance.now() - t0;
try {
if (times.feedbackMs === undefined && anyFeedback()) times.feedbackMs = t;
if (times.firstResultMs === undefined && checks.first(arg, before)) times.firstResultMs = t;
if (sign) {
const sig = sign(arg);
if (sig !== lastSig) {
lastSig = sig;
lastChange = t;
}
}
const settled = !sign || t - lastChange >= (settleMs ?? 0);
if (times.firstResultMs !== undefined && settled && checks.done(arg, before)) {
// Session.reset waits out the time in which invoking it again widens it.
if (action === "find.showUsages")
(window as unknown as { __openideaEvalShowUsagesAt?: number }).__openideaEvalShowUsagesAt =
performance.now();
times.completeMs = sign ? Math.max(lastChange, times.firstResultMs) : t;
times.feedbackMs ??= t;
return resolve({ times, timedOut: false, error: null });
}
} catch (e) {
return resolve({ times, timedOut: false, error: String(e) });
}
if (failed) return resolve({ times, timedOut: false, error: String((failed as Error).message ?? failed) });
if (t > timeoutMs) return resolve({ times, timedOut: true, error: null });
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
},
{
action: spec.action,
actionArg: spec.actionArg,
openFile: spec.openFile,
run: spec.run?.toString() ?? null,
arg: spec.arg,
first: spec.first.toString(),
done: spec.done.toString(),
feedback: spec.feedback?.toString() ?? null,
signature: spec.signature?.toString() ?? null,
settleMs: spec.settleMs,
timeoutMs: spec.timeoutMs,
},
);
const what = spec.action ?? (spec.openFile ? `open ${spec.openFile}` : "step");
if (r.error) throw new Error(`${what}: ${r.error}`);
if (r.timedOut) {
const seen =
r.times.firstResultMs !== undefined
? "a first result but no final one"
: r.times.feedbackMs !== undefined
? "feedback but no result"
: "nothing";
throw new TimeoutError(`${what} timed out after ${spec.timeoutMs} ms (${seen})`, r.times);
}
return r.times as Tracked;
}
+70
Переглянути файл
@@ -0,0 +1,70 @@
/** Where a scenario's project comes from: `fixtures/<name>`, or an open-source project pinned in `evals/projects.json`. */
import { execFileSync, spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
interface Pinned {
repo: string;
commit: string;
remove?: string[];
prepare?: string[];
}
function pinned(repoRoot: string, name: string): Pinned | null {
const all = JSON.parse(readFileSync(join(repoRoot, "evals", "projects.json"), "utf8")) as Record<string, Pinned>;
return all[name] ?? null;
}
const quiet = { stdio: "ignore" as const };
/** The pinned checkout, cloned and prepared once; returns its directory. */
function checkout(repoRoot: string, name: string, p: Pinned): string {
const dir = join(repoRoot, "target", "eval", "sources", name);
if (!existsSync(join(dir, ".git"))) {
console.log(`Cloning ${p.repo}…`);
mkdirSync(join(dir, ".."), { recursive: true });
execFileSync("git", ["clone", "-q", "--filter=blob:none", p.repo, dir], quiet);
}
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim();
if (head !== p.commit) {
const has = spawnSync("git", ["cat-file", "-e", `${p.commit}^{commit}`], { cwd: dir }).status === 0;
if (!has) execFileSync("git", ["fetch", "-q", "origin", p.commit], { cwd: dir, ...quiet });
execFileSync("git", ["checkout", "-q", "--detach", p.commit], { cwd: dir, ...quiet });
}
const marker = join(repoRoot, "target", "eval", "sources", `.${name}.prepared`);
if (p.prepare && (!existsSync(marker) || readFileSync(marker, "utf8") !== p.commit)) {
console.log(`Preparing ${name}: ${p.prepare.join(" ")}…`);
const r = spawnSync(p.prepare[0]!, p.prepare.slice(1), { cwd: dir, stdio: ["ignore", "ignore", "inherit"] });
if (r.status !== 0) throw new Error(`preparing ${name} failed: ${p.prepare.join(" ")}`);
writeFileSync(marker, p.commit);
}
return dir;
}
/**
* A fresh copy of `name` at `dest`, as its own git repository (edits and IDE state of an
* earlier session don't carry over; it isn't part of the checkout `target/` sits in).
*/
export function copyProject(repoRoot: string, name: string, dest: string) {
const fixture = join(repoRoot, "fixtures", name);
const p = existsSync(fixture) ? null : pinned(repoRoot, name);
if (!p && !existsSync(fixture)) throw new Error(`no fixtures/${name} and no ${name} in evals/projects.json`);
const src = p ? checkout(repoRoot, name, p) : fixture;
rmSync(dest, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
mkdirSync(join(dest, ".."), { recursive: true });
cpSync(src, dest, { recursive: true, filter: (f) => !/[\\/](\.git|target|build|\.gradle|\.openidea)$/.test(f) });
for (const r of p?.remove ?? []) rmSync(join(dest, r), { recursive: true, force: true });
const env = {
...process.env,
GIT_AUTHOR_NAME: "eval",
GIT_AUTHOR_EMAIL: "eval@localhost",
GIT_COMMITTER_NAME: "eval",
GIT_COMMITTER_EMAIL: "eval@localhost",
};
for (const args of [
["init", "-q"],
["add", "-A"],
["commit", "-q", "--no-verify", "-m", "eval project"],
])
execFileSync("git", args, { cwd: dest, env, ...quiet });
}
+35
Переглянути файл
@@ -0,0 +1,35 @@
/** What an eval scenario is: a project, a step the user takes, and what they should see. */
import type { ResultSet } from "./golden.ts";
import type { Tracked } from "./measure.ts";
import type { Session } from "./session.ts";
import type { Condition } from "./verdict.ts";
export interface Measured {
timing: Tracked;
results?: ResultSet;
/** Why the answer is weaker than a semantic one, when the UI says so. */
degraded?: string | null;
}
export interface EvalScenario {
/** Stable id: `<language>.<group>.<case>`, used by `--only`, goldens and budgets. */
id: string;
title: string;
/** `fixtures/<fixture>`. */
fixture: string;
/** The language server whose readiness the warm runs wait for. */
server: string;
conditions: Condition[];
/** Brings the idle workbench to where the step starts (open a file, place the caret). */
setup(s: Session): Promise<void>;
/** The measured step. */
step(s: Session): Promise<Measured>;
}
/** Project-relative path, or a library class's URI without its machine-specific query. */
export function normalizePath(root: string, path: string): string {
if (path.startsWith(root + "/")) return path.slice(root.length + 1);
const uri = /^[a-z]+:\/\//.exec(path);
if (uri) return path.replace(/[?#].*$/, "");
return path;
}
+403
Переглянути файл
@@ -0,0 +1,403 @@
/**
* One eval session: a release `openidea-server --stdio` with its own home, and the production
* workbench in Chromium connected to it as remote development connects a window (the Tauri
* runtime from `smoke/tauriShim.ts`, commands and notifications relayed by Playwright).
*/
import { mkdirSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, sep } from "node:path";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { installInPage } from "../../perf/lib/inpage.ts";
import { Server } from "../../smoke/server.ts";
import { copyProject } from "./projects.ts";
import { installTauriShim, type ShimWindow } from "../../smoke/tauriShim.ts";
/** How long after its answer Show Usages invoked again widens it (`HINT_MS` in code-insight/usages.ts), plus a margin. */
const SHOW_USAGES_HINT_MS = 4200;
export interface IpcCall {
cmd: string;
/** Milliseconds since the session started. */
start: number;
ms: number;
ok: boolean;
/** JSON length of the result (or error). */
bytes: number;
/** Array length of the result, when it is one. */
items?: number;
}
export interface TaskSpan {
id: number;
title: string;
start: number;
end: number | null;
}
/** What the server did, for diagnostics: every command, background task and server status. */
export class Timeline {
readonly t0 = Date.now();
readonly calls: IpcCall[] = [];
readonly tasks = new Map<number, TaskSpan>();
readonly servers: Array<{ at: number; name: string; status: string; message: string | null }> = [];
/** Last time anything happened (a call ended, a task or status changed). */
lastActivity = 0;
inFlight = 0;
now() {
return Date.now() - this.t0;
}
onEvent(event: { type: string } & Record<string, unknown>) {
if (event.type === "task") {
const id = event.id as number;
const span = this.tasks.get(id) ?? { id, title: String(event.title), start: this.now(), end: null };
if (event.done) span.end = this.now();
this.tasks.set(id, span);
this.lastActivity = this.now();
} else if (event.type === "languageServer") {
this.servers.push({
at: this.now(),
name: String(event.name),
status: String(event.status),
message: (event.message as string | null) ?? null,
});
this.lastActivity = this.now();
}
}
runningTasks(): TaskSpan[] {
return [...this.tasks.values()].filter((t) => t.end === null);
}
serverStatus(name: string): string | null {
for (let i = this.servers.length - 1; i >= 0; i--)
if (this.servers[i]!.name === name) return this.servers[i]!.status;
return null;
}
/** The calls and tasks overlapping `[from, to]` (session milliseconds). */
window(from: number, to: number) {
return {
calls: this.calls.filter((c) => c.start <= to && c.start + c.ms >= from),
tasks: [...this.tasks.values()].filter((t) => t.start <= to && (t.end ?? Infinity) >= from),
};
}
}
export interface SessionOptions {
/** `fixtures/<name>` or a project pinned in `evals/projects.json`. */
fixture: string;
/** Where the project copy and the server's home go. */
workDir: string;
/** Keep the server's home (installed tools, language server workspaces) from an earlier session. */
keepHome: boolean;
/** `OPENIDEA_LOG` for the server. */
log?: string;
}
export class Session {
readonly timeline = new Timeline();
readonly root: string;
readonly home: string;
page!: Page;
private context!: BrowserContext;
private server!: Server;
private relayQueue: Promise<unknown> = Promise.resolve();
private pendingNotes: Array<[kind: "channel" | "event", a: unknown, b: unknown]> = [];
/** The last answer to each command that succeeded, for what the UI only summarises. */
readonly lastAnswer = new Map<string, unknown>();
/** Editors changed by a scenario: path → the text to put back. */
private readonly edited = new Map<string, string>();
readonly pageErrors: string[] = [];
private constructor(opts: SessionOptions) {
this.root = join(opts.workDir, "projects", opts.fixture);
this.home = join(opts.workDir, "home");
}
static async start(browser: Browser, url: string, repoRoot: string, opts: SessionOptions): Promise<Session> {
const s = new Session(opts);
copyProject(repoRoot, opts.fixture, s.root);
if (!opts.keepHome) rmSync(s.home, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
mkdirSync(s.home, { recursive: true });
s.server = await Server.start(s.home, opts.log ? { OPENIDEA_LOG: opts.log } : {});
s.server.onEvent = (target, event) => {
if (target !== null && target !== "main") return;
s.timeline.onEvent(event as { type: string });
s.notify("event", target, event);
};
s.server.onChannel = (id, data) => s.notify("channel", id, data);
await s.server.invoke("project_trust_set", { path: s.root, trusted: true, folder: false });
s.context = await browser.newContext({
baseURL: url,
viewport: { width: 1400, height: 900 },
deviceScaleFactor: 1,
});
await s.context.exposeFunction("__openideaRelay", async (cmd: string, args: unknown) => {
const start = s.timeline.now();
s.timeline.inFlight++;
try {
const value = await s.server.invoke(cmd, args);
s.record(cmd, start, true, value);
return { ok: value ?? null };
} catch (err) {
s.record(cmd, start, false, err);
return { err };
} finally {
s.timeline.inFlight--;
s.timeline.lastActivity = s.timeline.now();
}
});
await s.context.addInitScript(installTauriShim, {
directories: { 12: tmpdir(), 21: homedir() },
sep: sep as "/" | "\\",
});
await s.context.addInitScript(() => {
const w = window as unknown as ShimWindow & {
__openideaRelay(cmd: string, args: unknown): Promise<{ ok: unknown } | { err: unknown }>;
};
w.__openideaForward = async (cmd, args) => {
const r = await w.__openideaRelay(cmd, args);
if ("err" in r) throw r.err;
return r.ok;
};
});
await s.context.addInitScript(installInPage);
s.page = await s.context.newPage();
s.page.on("pageerror", (e) => s.pageErrors.push(e.message));
if (process.env.OPENIDEA_EVAL_DEBUG) {
s.page.on("console", (m) => console.log("[page]", m.type(), m.text().slice(0, 300)));
s.page.on("pageerror", (e) => console.log("[pageerror]", e.message));
}
await s.page.goto("/");
await s.page.waitForFunction(() => "__openideaPerf" in window && "__openideaShim" in window);
await s.page.evaluate(
(root) =>
(window as unknown as { __openideaPerf: { openProject(p: string): Promise<void> } }).__openideaPerf.openProject(
root,
),
s.root,
);
await s.page.waitForSelector('[role="tree"][aria-label="Project"] [role="treeitem"]', { timeout: 60_000 });
return s;
}
private record(cmd: string, start: number, ok: boolean, value: unknown) {
let bytes = 0;
try {
bytes = JSON.stringify(value ?? null).length;
} catch {
// not serialisable: size unknown
}
const items = Array.isArray(value)
? value.length
: value && typeof value === "object" && Array.isArray((value as { locations?: unknown }).locations)
? (value as { locations: unknown[] }).locations.length
: undefined;
this.timeline.calls.push({ cmd, start, ms: this.timeline.now() - start, ok, bytes, items });
if (ok) this.lastAnswer.set(cmd, value);
}
/** Delivers server notifications in order, batched per turn of the event loop. */
private notify(kind: "channel" | "event", a: unknown, b: unknown) {
this.pendingNotes.push([kind, a, b]);
if (this.pendingNotes.length > 1) return;
this.relayQueue = this.relayQueue
.then(() => new Promise((r) => setImmediate(r)))
.then(() => {
const batch = this.pendingNotes;
this.pendingNotes = [];
return this.page
?.evaluate((notes) => {
const shim = (window as unknown as ShimWindow).__openideaShim;
for (const [kind, a, b] of notes) {
if (kind === "channel") shim.channel(a as number, b);
else shim.event(a as string | null, b);
}
}, batch)
.catch(() => {});
});
}
runAction(id: string, arg?: unknown): Promise<boolean> {
return this.page.evaluate(
async ({ id, arg }) => {
const w = window as unknown as {
__openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> };
__openideaEvalShowUsagesAt?: number;
};
const ran = await w.__openideaPerf.runAction(id, arg);
if (id === "find.showUsages") w.__openideaEvalShowUsagesAt = performance.now();
return ran;
},
{ id, arg },
);
}
/** Opens `rel` (project-relative, or a URI) in an editor and waits until its text is shown. */
async openFile(rel: string) {
const path = /^[a-z]+:\/\//.test(rel) ? rel : join(this.root, rel);
const bare = rel.replace(/[?#].*$/, "");
const name = bare.slice(bare.lastIndexOf("/") + 1);
await this.page.evaluate(
async ({ path, name }) => {
const w = window as unknown as {
__perf: { waitShown(s: string, t: string | null, ms: number): Promise<number> };
__openideaPerf: { openFile(p: string): Promise<void> };
};
await w.__openideaPerf.openFile(path);
await w.__perf.waitShown('[role="tab"][aria-selected="true"]', name, 30_000);
await w.__perf.waitShown(".cm-content .cm-line", null, 30_000);
},
{ path, name },
);
}
/**
* Goes to the declaration of `word` on `line` of the active editor and waits until another
* file shows (a library class: its `jdt://` URI, which carries what only the server knows).
*/
async declarationOf(line: string, word: string): Promise<string> {
await this.caretAt(line, word);
type Probe = { __openideaPerf: { editorState(): { path: string } | null } };
const from = await this.page.evaluate(() => (window as unknown as Probe).__openideaPerf.editorState()?.path);
await this.runAction("navigate.declaration");
const handle = await this.page.waitForFunction(
(from) => {
const path = (window as unknown as Probe).__openideaPerf.editorState()?.path;
return path && path !== from && document.querySelector(".cm-content .cm-line") ? path : null;
},
from,
{ timeout: 30_000 },
);
return (await handle.jsonValue()) as string;
}
/** Puts the caret inside `word` on the active editor's first line containing `line` (both literal). */
async caretAt(line: string, word: string) {
const text = await this.editorText();
const at = text.indexOf(line);
if (at < 0) throw new Error(`no line containing ${JSON.stringify(line)} in the active editor`);
const inLine = line.indexOf(word);
if (inLine < 0) throw new Error(`${JSON.stringify(word)} is not in ${JSON.stringify(line)}`);
// Inside the word, as a click on it would put it.
const target = at + inLine + Math.floor(word.length / 2);
await this.page.evaluate(
(o) => (window as unknown as { __openideaPerf: { setCursor(o: number): void } }).__openideaPerf.setCursor(o),
target,
);
}
/** The active editor's whole text. */
editorText(): Promise<string> {
return this.page.evaluate(() => {
const text = (
window as unknown as { __openideaPerf: { editorText(): string | null } }
).__openideaPerf.editorText();
if (text === null) throw new Error("no active editor");
return text;
});
}
/** Keeps the active editor's text, for `reset` to put back whatever the scenario changes. */
async keepText() {
const { path, text } = await this.page.evaluate(() => {
const probe = (
window as unknown as {
__openideaPerf: { editorState(): { path: string } | null; editorText(): string | null };
}
).__openideaPerf;
return { path: probe.editorState()?.path, text: probe.editorText() };
});
if (path === undefined || text === null) throw new Error("no active editor");
if (!this.edited.has(path)) this.edited.set(path, text);
}
/** Inserts `text` after the first `after` in the active editor; the offset after it. */
async edit(after: string, text: string): Promise<number> {
await this.keepText();
return this.page.evaluate(
({ after, text }) =>
(
window as unknown as { __openideaPerf: { insertAfter(a: string, t: string): number } }
).__openideaPerf.insertAfter(after, text),
{ after, text },
);
}
private async restoreEdits() {
for (const [path, text] of this.edited) {
await this.page.evaluate(
async ({ path, text }) => {
const probe = (
window as unknown as {
__openideaPerf: {
openFile(p: string): Promise<void>;
editorState(): { path: string } | null;
editorText(): string | null;
setText(t: string): void;
};
}
).__openideaPerf;
await probe.openFile(path);
while (probe.editorState()?.path !== path) await new Promise(requestAnimationFrame);
if (probe.editorText() !== text) probe.setText(text);
},
{ path, text },
);
await this.runAction("file.save");
}
this.edited.clear();
}
/** Waits until `server` is ready and nothing has happened for `quietMs`: no task, no command in flight. */
async settle(server: string, quietMs = 2000, timeoutMs = 5 * 60_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const t = this.timeline;
const quiet =
t.serverStatus(server) === "ready" &&
t.runningTasks().length === 0 &&
t.inFlight === 0 &&
t.now() - t.lastActivity >= quietMs;
if (quiet) return;
if (Date.now() > deadline) {
throw new Error(
`the IDE did not settle within ${timeoutMs / 1000}s: ${server} ${t.serverStatus(server) ?? "not started"}, ` +
`tasks ${JSON.stringify(t.runningTasks().map((x) => x.title))}, ${t.inFlight} command(s) in flight`,
);
}
await new Promise((r) => setTimeout(r, 200));
}
}
/** Back to an idle workbench: popups dismissed, edits undone, every editor closed. */
async reset() {
// Show Usages invoked again within 4 s of its hint widens the previous search: wait it out.
const since = await this.page.evaluate(() => {
const at = (window as unknown as { __openideaEvalShowUsagesAt?: number }).__openideaEvalShowUsagesAt;
return at === undefined ? Infinity : performance.now() - at;
});
if (since < SHOW_USAGES_HINT_MS) await this.page.waitForTimeout(SHOW_USAGES_HINT_MS - since);
await this.page.keyboard.press("Escape");
await this.page.keyboard.press("Escape");
await this.restoreEdits();
await this.runAction("editor.closeAllTabs");
await this.page.waitForFunction(() => !document.querySelector('[role="tab"][aria-selected="true"]'), undefined, {
timeout: 10_000,
});
}
serverLog(): string {
return this.server.log();
}
async close() {
await this.context?.close().catch(() => {});
await this.server?.close();
}
}
+86
Переглянути файл
@@ -0,0 +1,86 @@
import { describe, expect, test } from "vitest";
import { judge, table, type Budgets, type Outcome } from "./verdict.ts";
const budgets: Budgets = {
regressionTolerance: 0.2,
regressionFloorMs: 200,
defaults: { feedbackMs: 100, recall: 1, precision: 1 },
scenarios: { usages: { completeMs: 2000 }, "usages@cold": { completeMs: 10_000 } },
};
const outcome = (o: Partial<Outcome>): Outcome => ({
scenario: "usages",
title: "Show Usages",
condition: "warm",
metrics: { feedbackMs: 40, firstResultMs: 300, completeMs: 500, recall: 1, precision: 1 },
samples: {},
results: { usages: ["a:1"] },
comparison: { recall: 1, precision: 1, missing: [], extra: [] },
...o,
});
const repro = () => "pnpm eval --only usages";
describe("judge", () => {
test("within budgets and matching the golden passes", () => {
const [v] = judge([outcome({})], budgets, null, repro);
expect(v).toMatchObject({ id: "usages@warm", status: "pass", failures: [], repro: "pnpm eval --only usages" });
});
test("slow feedback, dropped results and silent degradation each fail", () => {
const [v] = judge(
[
outcome({
metrics: { feedbackMs: 250, firstResultMs: 300, completeMs: 2500, recall: 0.5, precision: 1 },
comparison: { recall: 0.5, precision: 1, missing: ["usages: b:2"], extra: [] },
degraded: "Based on syntax",
}),
],
budgets,
null,
repro,
);
expect(v!.status).toBe("fail");
expect(v!.failures.map((f) => [f.kind, f.metric])).toEqual([
["degraded", undefined],
["budget", "feedbackMs"],
["correctness", "recall"],
["budget", "completeMs"],
]);
expect(v!.failures.find((f) => f.metric === "recall")!.detail).toContain("usages: b:2");
});
test("condition budgets override scenario budgets", () => {
const [v] = judge(
[outcome({ condition: "cold", metrics: { feedbackMs: 40, completeMs: 5000, recall: 1, precision: 1 } })],
budgets,
null,
repro,
);
expect(v!.status).toBe("pass");
});
test("timeouts and missing goldens fail", () => {
const [timedOut, noGolden] = judge(
[outcome({ error: "find.showUsages timed out after 30000 ms" }), outcome({ comparison: undefined })],
budgets,
null,
repro,
);
expect(timedOut!.failures[0]!.kind).toBe("timeout");
expect(noGolden!.failures[0]!.kind).toBe("golden");
});
test("regressions need both the relative tolerance and the absolute floor", () => {
const baseline = [outcome({ metrics: { feedbackMs: 40, firstResultMs: 300, completeMs: 500 } })];
const [v] = judge(
[outcome({ metrics: { feedbackMs: 90, firstResultMs: 450, completeMs: 1200, recall: 1, precision: 1 } })],
budgets,
baseline,
repro,
);
expect(v!.status).toBe("pass");
expect(v!.regressions.map((r) => r.metric)).toEqual(["completeMs"]);
expect(table([v!], false)).toContain("regressed: completeMs");
});
});
+196
Переглянути файл
@@ -0,0 +1,196 @@
/**
* Verdicts: each scenario run under each condition passes or fails on its own numbers
* (budgets, correctness, degradation, errors), and is compared with the local baseline.
*/
import { readFileSync } from "node:fs";
import type { Comparison, ResultSet } from "./golden.ts";
export type Condition = "warm" | "cold";
export interface Diagnostics {
/** Commands sent to the server during the slowest measured run (session ms). */
calls: Array<{ cmd: string; start: number; ms: number; ok: boolean; bytes: number; items?: number }>;
tasks: Array<{ title: string; start: number; end: number | null }>;
servers: Array<{ at: number; name: string; status: string; message: string | null }>;
/** The slowest run's window in session milliseconds. */
window: [number, number];
serverLog: string;
trace?: string;
}
export interface Outcome {
scenario: string;
title: string;
condition: Condition;
/** Medians of the measured runs (correctness: the worst run). */
metrics: Record<string, number>;
samples: Record<string, number[]>;
results?: ResultSet;
comparison?: Comparison;
/** Notes of the tools whose versions differ from the golden's. */
drift?: string[];
/** Why the answer is weaker than a semantic one (syntax fallback, incomplete search), if it is. */
degraded?: string | null;
error?: string;
diagnostics?: Diagnostics;
}
export type FailureKind = "error" | "timeout" | "degraded" | "correctness" | "budget" | "golden";
export interface Failure {
kind: FailureKind;
metric?: string;
value?: number;
limit?: number;
detail: string;
}
export interface Regression {
metric: string;
value: number;
baseline: number;
}
export interface Verdict {
id: string;
scenario: string;
condition: Condition;
title: string;
status: "pass" | "fail";
failures: Failure[];
regressions: Regression[];
improvements: Regression[];
metrics: Record<string, number>;
baseline?: Record<string, number>;
repro: string;
}
export interface Budgets {
regressionTolerance: number;
regressionFloorMs: number;
/** Applied to every scenario. `*Ms`: upper bounds; `recall` / `precision`: lower bounds. */
defaults: Record<string, number>;
/** By `<scenario>` or `<scenario>@<condition>` (the latter wins). */
scenarios: Record<string, Record<string, number>>;
}
export function loadBudgets(file: string): Budgets {
const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<Budgets>;
return {
regressionTolerance: raw.regressionTolerance ?? 0.2,
regressionFloorMs: raw.regressionFloorMs ?? 200,
defaults: raw.defaults ?? {},
scenarios: raw.scenarios ?? {},
};
}
export const idOf = (o: { scenario: string; condition: Condition }) => `${o.scenario}@${o.condition}`;
function budgetsFor(b: Budgets, o: Outcome): Record<string, number> {
return { ...b.defaults, ...b.scenarios[o.scenario], ...b.scenarios[idOf(o)] };
}
const lowerBound = (metric: string) => metric === "recall" || metric === "precision";
export function judge(
outcomes: Outcome[],
budgets: Budgets,
baseline: Outcome[] | null,
repro: (o: Outcome) => string,
): Verdict[] {
const base = new Map((baseline ?? []).map((o) => [idOf(o), o.metrics]));
return outcomes.map((o) => {
const failures: Failure[] = [];
if (o.error) failures.push({ kind: /timed out/.test(o.error) ? "timeout" : "error", detail: o.error });
if (o.degraded) failures.push({ kind: "degraded", detail: o.degraded });
if (o.results && !o.comparison && !o.error)
failures.push({ kind: "golden", detail: "no golden recorded: run with --update-golden and review it" });
for (const [metric, limit] of Object.entries(budgetsFor(budgets, o))) {
const value = o.metrics[metric];
if (value === undefined || !Number.isFinite(value)) continue;
const over = lowerBound(metric) ? value < limit : value > limit;
if (!over) continue;
if (lowerBound(metric)) {
const c = o.comparison;
const list = metric === "recall" ? c?.missing : c?.extra;
const shown = (list ?? []).slice(0, 8).join("; ");
failures.push({
kind: "correctness",
metric,
value,
limit,
detail: `${metric} ${value.toFixed(2)} < ${limit} (${metric === "recall" ? "missing" : "unexpected"}: ${shown}${(list?.length ?? 0) > 8 ? "; …" : ""})`,
});
} else {
failures.push({
kind: "budget",
metric,
value,
limit,
detail: `${metric} ${Math.round(value)} ms > ${limit} ms`,
});
}
}
const before = base.get(idOf(o));
const regressions: Regression[] = [];
const improvements: Regression[] = [];
if (before) {
for (const [metric, value] of Object.entries(o.metrics)) {
const was = before[metric];
if (was === undefined || !metric.endsWith("Ms") || !Number.isFinite(value) || !Number.isFinite(was)) continue;
const slack = Math.max(was * budgets.regressionTolerance, budgets.regressionFloorMs);
if (value > was + slack) regressions.push({ metric, value, baseline: was });
else if (value < was - slack) improvements.push({ metric, value, baseline: was });
}
}
return {
id: idOf(o),
scenario: o.scenario,
condition: o.condition,
title: o.title,
status: failures.length ? "fail" : "pass",
failures,
regressions,
improvements,
metrics: o.metrics,
baseline: before,
repro: repro(o),
};
});
}
const fmt = (metric: string, v: number | undefined) =>
v === undefined || !Number.isFinite(v) ? "–" : metric.endsWith("Ms") ? String(Math.round(v)) : v.toFixed(2);
const COLUMNS = ["feedbackMs", "firstResultMs", "completeMs", "recall", "precision"];
/** The verdicts as a table (Markdown when `md`), with the baseline in parentheses. */
export function table(verdicts: Verdict[], md: boolean): string {
const head = ["scenario", ...COLUMNS, "verdict"];
const rows = verdicts.map((v) => [
v.id,
...COLUMNS.map((m) => {
const now = fmt(m, v.metrics[m]);
const was = v.baseline?.[m];
return was === undefined || !m.endsWith("Ms") ? now : `${now} (${fmt(m, was)})`;
}),
v.status === "fail"
? `FAIL: ${v.failures.map((f) => f.kind + (f.metric ? ` ${f.metric}` : "")).join(", ")}`
: v.regressions.length
? `regressed: ${v.regressions.map((r) => r.metric).join(", ")}`
: v.improvements.length
? `improved: ${v.improvements.map((r) => r.metric).join(", ")}`
: "ok",
]);
if (md) {
return [
`| ${head.join(" | ")} |`,
`| ${head.map((_, i) => (i === 0 || i === head.length - 1 ? "---" : "---:")).join(" | ")} |`,
...rows.map((r) => `| ${r.join(" | ")} |`),
].join("\n");
}
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
const line = (cells: string[]) =>
cells.map((c, i) => (i === 0 || i === head.length - 1 ? c.padEnd(widths[i]!) : c.padStart(widths[i]!))).join(" ");
return [line(head), ...rows.map(line)].join("\n");
}
+16
Переглянути файл
@@ -0,0 +1,16 @@
{
"$comment": "Open-source projects the evals run on besides fixtures/: cloned at `commit` into target/eval/sources/, `remove`d paths dropped from the copy, `prepare` run once per checkout (dependencies downloaded before anything is timed; Maven needs to reach Central, a proxy goes in ~/.m2/settings.xml).",
"spring-petclinic": {
"repo": "https://github.com/spring-projects/spring-petclinic.git",
"commit": "500158f732419217507c7656904b8e6aa1bcc0d6",
"remove": ["build.gradle", "settings.gradle", "gradle", "gradlew", "gradlew.bat"],
"prepare": [
"mvn",
"-q",
"-B",
"dependency:go-offline",
"dependency:sources",
"-DfailOnMissingClassifierArtifact=false"
]
}
}
+363
Переглянути файл
@@ -0,0 +1,363 @@
/**
* Code-intelligence evals (`pnpm eval`): the production workbench in Chromium driving a
* release `openidea-server` with real language servers, each scenario timed as the user
* sees it (feedback, first result, complete) and checked against its golden results.
*
* pnpm eval every scenario (warm: 1 warm-up + median of 3; cold: once)
* pnpm eval --only java.usages --runs 5 scenarios whose id starts with java.usages
* pnpm eval --save-baseline store this run as target/eval/baseline.json
* pnpm eval --update-golden re-record evals/<language>/golden/ (review the diff)
* pnpm eval --only <id> --trace Chrome trace of the slowest run → target/eval/traces/
*
* Writes target/eval/report.json (verdicts, failures with diagnostics, machine fingerprint)
* and target/eval/report.md. Timings go over the remote-development route (one stdio hop
* more than Tauri IPC): compare them before / after on one machine, not as absolute numbers.
*/
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { chromium, type Browser } from "@playwright/test";
import { buildBundle, serveBundle } from "../perf/lib/bundle.ts";
import { median } from "../perf/lib/stats.ts";
import { JAVA_SCENARIOS } from "./java/scenarios.ts";
import { compare, readGolden, toolDrift, writeGolden, type ResultSet } from "./lib/golden.ts";
import { TimeoutError } from "./lib/measure.ts";
import { fingerprint, pinnedTools, sameMachine, toolVersions, type Fingerprint } from "./lib/machine.ts";
import type { EvalScenario } from "./lib/scenario.ts";
import { Session } from "./lib/session.ts";
import { idOf, judge, loadBudgets, table, type Condition, type Diagnostics, type Outcome } from "./lib/verdict.ts";
const ROOT = resolve(import.meta.dirname, "..");
const OUT = join(ROOT, "target", "eval");
const DIST = join(OUT, "dist");
const SCENARIOS: EvalScenario[] = [...JAVA_SCENARIOS];
const { values: args } = parseArgs({
options: {
only: { type: "string" },
condition: { type: "string" },
runs: { type: "string", default: "3" },
warmup: { type: "string", default: "1" },
channel: { type: "string", default: process.env.OPENIDEA_PERF_CHANNEL ?? "chromium" },
headed: { type: "boolean", default: false },
"no-build": { type: "boolean", default: false },
"save-baseline": { type: "boolean", default: false },
"update-golden": { type: "boolean", default: false },
"fail-on-regression": { type: "boolean", default: false },
trace: { type: "boolean", default: false },
list: { type: "boolean", default: false },
},
allowPositionals: false,
});
if (args.list) {
for (const s of SCENARIOS) console.log(`${s.id.padEnd(44)} ${s.conditions.join(",").padEnd(10)} ${s.title}`);
process.exit(0);
}
const runs = Math.max(1, Number(args.runs));
const warmup = Math.max(0, Number(args.warmup));
const only = args.only?.split(",").map((s) => s.trim());
const conditionFilter = args.condition as Condition | undefined;
const selected = SCENARIOS.filter((s) => !only || only.some((o) => s.id === o || s.id.startsWith(`${o}.`)));
if (only && !selected.length) throw new Error(`no scenario matches ${args.only} (see --list)`);
const goldenFile = (id: string) => join(ROOT, "evals", id.slice(0, id.indexOf(".")), "golden", `${id}.json`);
function serverBinary(): string {
const exe = process.platform === "win32" ? ".exe" : "";
const bin = process.env.OPENIDEA_SERVER ?? join(ROOT, "target", "release", `openidea-server${exe}`);
if (!args["no-build"]) {
console.log("Building openidea-server (cargo build --release)…");
const r = spawnSync("cargo", ["build", "--release", "-p", "ide-host", "--bin", "openidea-server"], {
cwd: ROOT,
stdio: ["ignore", "ignore", "inherit"],
});
if (r.status !== 0) throw new Error("cargo build failed");
}
if (!existsSync(bin)) throw new Error(`no server at ${bin}; run without --no-build`);
return bin;
}
function git(...a: string[]): string {
try {
return execFileSync("git", a, { cwd: ROOT, encoding: "utf8" }).trim();
} catch {
return "";
}
}
/** Medians of the timings; correctness from the worst run. */
function summarize(samples: Array<Record<string, number>>): Record<string, number> {
const keys = [...new Set(samples.flatMap((s) => Object.keys(s)))];
return Object.fromEntries(
keys.map((k) => {
const values = samples.map((s) => s[k]!).filter(Number.isFinite);
return [k, k === "recall" || k === "precision" ? Math.min(...values) : median(values)];
}),
);
}
interface Run {
metrics: Record<string, number>;
results?: ResultSet;
degraded?: string | null;
window: [number, number];
}
async function measureOnce(s: Session, scenario: EvalScenario, trace: string | null, browser: Browser): Promise<Run> {
await s.reset();
await scenario.setup(s);
await s.settle(scenario.server, 1000);
const from = s.timeline.now();
if (trace) await browser.startTracing(s.page, { screenshots: true });
try {
const m = await scenario.step(s);
return {
metrics: { ...m.timing },
results: m.results,
degraded: m.degraded,
window: [from, s.timeline.now()],
};
} finally {
if (trace) writeFileSync(trace, await browser.stopTracing());
}
}
function diagnose(s: Session, window: [number, number], trace?: string): Diagnostics {
const w = s.timeline.window(window[0] - 500, window[1]);
return {
calls: w.calls.map((c) => ({ ...c })),
tasks: w.tasks.map((t) => ({ title: t.title, start: t.start, end: t.end })),
servers: s.timeline.servers.filter((x) => x.at <= window[1]),
window,
serverLog: s.serverLog(),
trace,
};
}
function outcomeOf(
scenario: EvalScenario,
condition: Condition,
done: Run[],
tools: Record<string, string>,
extra: Record<string, number> = {},
): Outcome {
const golden = readGolden(goldenFile(scenario.id));
const comparisons = golden ? done.map((r) => compare(r.results ?? {}, golden.groups)) : [];
const worst = comparisons.reduce<(typeof comparisons)[number] | undefined>(
(w, c) => (!w || c.recall + c.precision < w.recall + w.precision ? c : w),
undefined,
);
const samples: Array<Record<string, number>> = done.map((r, i) =>
comparisons[i] ? { ...r.metrics, recall: comparisons[i].recall, precision: comparisons[i].precision } : r.metrics,
);
return {
scenario: scenario.id,
title: scenario.title,
condition,
metrics: { ...summarize(samples), ...extra },
samples: Object.fromEntries(Object.keys(samples[0] ?? {}).map((k) => [k, samples.map((x) => x[k]!)])),
results: done.at(-1)?.results,
comparison: worst,
drift: golden ? toolDrift(golden, tools) : undefined,
degraded: done.find((r) => r.degraded)?.degraded ?? null,
};
}
async function runScenario(
s: Session,
scenario: EvalScenario,
condition: Condition,
browser: Browser,
tools: Record<string, string>,
extra: Record<string, number> = {},
): Promise<Outcome> {
const total = condition === "cold" ? 1 : warmup + runs;
const done: Run[] = [];
process.stdout.write(`${idOf({ scenario: scenario.id, condition }).padEnd(52)} `);
try {
for (let i = 0; i < total; i++) {
const last = i === total - 1;
const trace = args.trace && last ? join(OUT, "traces", `${scenario.id}@${condition}.json`) : null;
const r = await measureOnce(s, scenario, trace, browser);
if (condition === "cold" || i >= warmup) done.push(r);
process.stdout.write(condition === "warm" && i < warmup ? "w" : ".");
}
console.log();
const o = outcomeOf(scenario, condition, done, tools, extra);
const slowest = done.reduce((a, b) => ((b.metrics.completeMs ?? 0) > (a.metrics.completeMs ?? 0) ? b : a));
o.diagnostics = diagnose(
s,
slowest.window,
args.trace ? join(OUT, "traces", `${scenario.id}@${condition}.json`) : undefined,
);
return o;
} catch (e) {
console.log(" failed");
const message = e instanceof Error ? e.message : String(e);
const partial = e instanceof TimeoutError ? (e.partial as Record<string, number>) : {};
const now = s.timeline.now();
return {
...outcomeOf(scenario, condition, done, tools, extra),
metrics: { ...partial, ...extra },
error: message,
diagnostics: diagnose(s, [Math.max(0, now - 35_000), now]),
};
}
}
async function main() {
mkdirSync(join(OUT, "traces"), { recursive: true });
process.env.OPENIDEA_SERVER = serverBinary();
if (!args["no-build"]) buildBundle(ROOT, DIST);
if (!existsSync(join(DIST, "index.html"))) throw new Error(`no build in ${DIST}; run without --no-build`);
const tools = toolVersions();
const pins = pinnedTools(join(ROOT, ".github", "tool-versions.env"));
const machine: Fingerprint = fingerprint();
for (const [k, want] of Object.entries(pins)) {
if (tools[k] !== undefined && !tools[k]!.startsWith(want))
console.warn(`warning: ${k} ${tools[k]} is not the pinned ${want} (.github/tool-versions.env)`);
}
const { server, url } = await serveBundle(DIST);
const browser = await chromium.launch({
channel: args.channel === "chromium" ? undefined : args.channel,
headless: !args.headed,
args: [
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
],
});
const outcomes: Outcome[] = [];
try {
// Warm: one session per fixture, its project imported once, scenarios one after another.
const warm = selected.filter(
(s) => s.conditions.includes("warm") && (!conditionFilter || conditionFilter === "warm"),
);
for (const fixture of [...new Set(warm.map((s) => s.fixture))]) {
const scenarios = warm.filter((s) => s.fixture === fixture);
const s = await Session.start(browser, url, ROOT, { fixture, workDir: join(OUT, "warm"), keepHome: false });
try {
await scenarios[0]!.setup(s);
await s.settle(scenarios[0]!.server);
for (const scenario of scenarios) outcomes.push(await runScenario(s, scenario, "warm", browser, tools));
} finally {
await s.close();
}
}
// Cold: a fresh server and project import per scenario, measured once after the import settles.
const cold = selected.filter(
(s) => s.conditions.includes("cold") && (!conditionFilter || conditionFilter === "cold"),
);
for (const scenario of cold) {
const started = Date.now();
const s = await Session.start(browser, url, ROOT, {
fixture: scenario.fixture,
workDir: join(OUT, "cold"),
keepHome: false,
});
try {
await scenario.setup(s);
await s.settle(scenario.server);
// Settling waits out the quiet period at its end.
const readyMs = Date.now() - started - 2000;
outcomes.push(await runScenario(s, scenario, "cold", browser, tools, { readyMs }));
} finally {
await s.close();
}
}
} finally {
await browser.close();
server.close();
}
if (args["update-golden"]) {
for (const o of outcomes) {
if (o.error || !o.results) continue;
writeGolden(goldenFile(o.scenario), { tools, groups: o.results });
const c = compare(o.results, o.results);
o.comparison = c;
o.metrics.recall = c.recall;
o.metrics.precision = c.precision;
o.drift = [];
}
console.log("Goldens re-recorded: review `git diff evals/` before committing.");
}
const baselineFile = join(OUT, "baseline.json");
let baseline: Outcome[] | null = null;
if (existsSync(baselineFile)) {
const saved = JSON.parse(readFileSync(baselineFile, "utf8")) as { machine: Fingerprint; outcomes: Outcome[] };
if (sameMachine(saved.machine, machine)) baseline = saved.outcomes;
else console.warn("warning: target/eval/baseline.json is from another machine or OS: not compared");
}
const repro = (o: Outcome) => `pnpm eval --only ${o.scenario} --condition ${o.condition}`;
const verdicts = judge(outcomes, loadBudgets(join(ROOT, "evals", "budgets.json")), baseline, repro);
const meta = {
date: new Date().toISOString(),
commit: git("rev-parse", "--short", "HEAD"),
dirty: git("status", "--porcelain", "--untracked-files=no") !== "",
runs,
warmup,
machine,
tools,
pins,
browser: browser.version(),
};
const report = {
meta,
pass: verdicts.every((v) => v.status === "pass"),
verdicts: verdicts.map((v) => {
const o = outcomes.find((x) => idOf(x) === v.id)!;
return {
...v,
samples: o.samples,
results: o.results,
comparison: o.comparison,
drift: o.drift,
degraded: o.degraded,
error: o.error,
// Diagnostics only where something needs a look.
diagnostics: v.status === "fail" || v.regressions.length ? o.diagnostics : undefined,
};
}),
};
writeFileSync(join(OUT, "report.json"), JSON.stringify(report, null, 2));
const failing = verdicts.filter((v) => v.status === "fail");
const md = [
"# OpenIDEA eval report",
"",
`${meta.date} · \`${meta.commit}\`${meta.dirty ? " (dirty)" : ""} · ${machine.cpu} · ${machine.os} · jdtls ${tools.jdtls ?? "?"} · java ${tools.java ?? "?"}`,
"",
table(verdicts, true),
"",
...failing.flatMap((v) => [`- **${v.id}**: ${v.failures.map((f) => f.detail).join("; ")} — \`${v.repro}\``]),
].join("\n");
writeFileSync(join(OUT, "report.md"), md + "\n");
console.log();
console.log(table(verdicts, false));
for (const v of failing) {
console.log(`\n${v.id} FAILED (${v.repro})`);
for (const f of v.failures) console.log(` ${f.kind}: ${f.detail}`);
const o = outcomes.find((x) => idOf(x) === v.id)!;
if (o.drift?.length) console.log(` golden recorded with other tools: ${o.drift.join(", ")}`);
const slow = [...(o.diagnostics?.calls ?? [])].sort((a, b) => b.ms - a.ms).slice(0, 5);
if (slow.length)
console.log(` slowest commands: ${slow.map((c) => `${c.cmd} ${c.ms} ms${c.ok ? "" : " (failed)"}`).join(", ")}`);
}
console.log(`\nReport: ${join(OUT, "report.json")}`);
if (args["save-baseline"]) {
writeFileSync(baselineFile, JSON.stringify({ machine, meta, outcomes }, null, 2));
console.log(`Baseline saved to ${baselineFile}`);
}
const regressed = verdicts.some((v) => v.regressions.length);
process.exit(failing.length || (args["fail-on-regression"] && regressed) ? 1 : 0);
}
await main();
+4
Переглянути файл
@@ -0,0 +1,4 @@
{
"extends": "../perf/tsconfig.json",
"include": ["./**/*.ts", "../perf/lib/**/*.ts", "../smoke/server.ts", "../smoke/tauriShim.ts"]
}
+18
Переглянути файл
@@ -0,0 +1,18 @@
package com.example.orders;
/** Takes a percentage off orders of at least {@code minQuantity} items. */
public class BulkDiscount extends ListPrice {
private final int minQuantity;
private final int percent;
public BulkDiscount(int minQuantity, int percent) {
this.minQuantity = minQuantity;
this.percent = percent;
}
@Override
public long price(Order order) {
long list = super.price(order);
return order.quantity() >= minQuantity ? list * (100 - percent) / 100 : list;
}
}
+9
Переглянути файл
@@ -0,0 +1,9 @@
package com.example.orders;
/** Charges the order's total. */
public class ListPrice implements PricingRule {
@Override
public long price(Order order) {
return order.totalCents();
}
}
+6
Переглянути файл
@@ -0,0 +1,6 @@
package com.example.orders;
/** Turns an order into the price charged for it, in cents. */
public interface PricingRule {
long price(Order order);
}
+1
Переглянути файл
@@ -11,6 +11,7 @@ const config: KnipConfig = {
"!src/editor/core/cm/astro/parser.terms.ts",
"scripts/**/*.ts",
"perf/**/*.ts",
"evals/**/*.ts",
"e2e/**/*.ts",
"smoke/**/*.ts",
],
+2 -1
Переглянути файл
@@ -17,7 +17,7 @@
"format": "oxfmt",
"format:check": "oxfmt --check",
"knip": "knip",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p evals --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
@@ -30,6 +30,7 @@
"e2e": "playwright test",
"perf": "node perf/run.ts",
"perf:rust": "node perf/rust.ts",
"eval": "node evals/run.ts",
"release:prepare": "node scripts/release-prepare.ts",
"nightly:prepare": "node scripts/nightly-prepare.ts"
},
+47
Переглянути файл
@@ -0,0 +1,47 @@
/** The workbench's `--mode perf` production bundle: building it and serving it to Chromium. */
import { spawnSync } from "node:child_process";
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { extname, join } from "node:path";
export function buildBundle(root: string, outDir: string, flags: string[] = []) {
console.log("Building the workbench (vite build --mode perf)…");
const r = spawnSync(
"pnpm",
["exec", "vite", "build", "--mode", "perf", ...flags, "--outDir", outDir, "--emptyOutDir"],
{
cwd: root,
stdio: ["ignore", "ignore", "inherit"],
shell: process.platform === "win32",
},
);
if (r.status !== 0) throw new Error("vite build failed");
}
const MIME: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
".wasm": "application/wasm",
};
/** Serves `dir` on a free local port (unknown paths: `index.html`). */
export function serveBundle(dir: string): Promise<{ server: Server; url: string }> {
const server = createServer((req, res) => {
const path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
let file = join(dir, path);
if (!file.startsWith(dir) || !existsSync(file) || statSync(file).isDirectory()) file = join(dir, "index.html");
res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
createReadStream(file).pipe(res);
});
return new Promise((ok) =>
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
ok({ server, url: `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}` });
}),
);
}
+5 -44
Переглянути файл
@@ -11,13 +11,12 @@
* pnpm perf -- --only typing --trace Chrome trace of the last run → target/perf/traces/
* pnpm perf -- --only typing --profile --unminified CPU profile + top functions
*/
import { spawnSync } from "node:child_process";
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { extname, join, resolve } from "node:path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { gzipSync } from "node:zlib";
import { chromium, type Browser } from "@playwright/test";
import { buildBundle, serveBundle } from "./lib/bundle.ts";
import { Harness, type Metrics, type Scenario } from "./lib/harness.ts";
import { topFunctions, type CpuProfile } from "./lib/profile.ts";
import { installInPage } from "./lib/inpage.ts";
@@ -65,18 +64,7 @@ if (only && scenarios.length !== only.length) {
}
function build() {
console.log("Building the workbench (vite build --mode perf)…");
const flags = args.unminified ? ["--minify", "false"] : [];
const r = spawnSync(
"pnpm",
["exec", "vite", "build", "--mode", "perf", ...flags, "--outDir", DIST, "--emptyOutDir"],
{
cwd: ROOT,
stdio: ["ignore", "ignore", "inherit"],
shell: process.platform === "win32",
},
);
if (r.status !== 0) throw new Error("vite build failed");
buildBundle(ROOT, DIST, args.unminified ? ["--minify", "false"] : []);
}
/** Initial-load JavaScript / CSS from the built index.html (gzip sizes). */
@@ -92,33 +80,6 @@ function bundleMetrics(): Metrics {
};
}
const MIME: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
".wasm": "application/wasm",
};
function serve(): Promise<{ server: Server; url: string }> {
const server = createServer((req, res) => {
const path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
let file = join(DIST, path);
if (!file.startsWith(DIST) || !existsSync(file) || statSync(file).isDirectory()) file = join(DIST, "index.html");
res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
createReadStream(file).pipe(res);
});
return new Promise((ok) =>
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
ok({ server, url: `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}` });
}),
);
}
interface Capture {
trace?: string;
profile?: string;
@@ -180,7 +141,7 @@ async function main() {
const results: ScenarioResult[] = args.unminified
? []
: [{ id: "bundle", title: "Initial bundle (gzip)", metrics: bundleMetrics() }];
const { server, url } = await serve();
const { server, url } = await serveBundle(DIST);
const browser = await chromium
.launch({
channel: args.channel === "chromium" ? undefined : args.channel,
+12 -64
Переглянути файл
@@ -1,76 +1,24 @@
/**
* The frontend's view of the desktop app, with a real backend: the window's Tauri runtime
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
* in-process (`tauriShim.ts`), and every app command sent to an `openidea-server`, as
* remote development sends them.
*/
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { setInvokeRoute } from "@/platform/tauriCore";
import { encodeArgs } from "@/platform/remote";
import { Server } from "./server";
import { sep } from "node:path";
import { installTauriShim, type ShimWindow } from "./tauriShim";
import type { Server } from "./server";
type Callback = (message: unknown) => void;
// One runtime per page, there from the start as in the app: the frontend registers its event
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
// across the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
{
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
const id = nextCallback++;
callbacks.set(id, cb);
return id;
},
unregisterCallback(id: number) {
callbacks.delete(id);
},
convertFileSrc: (path: string) => path,
// Only the window's own plugins reach this: app commands take the route below.
async invoke(cmd: string, args: Record<string, unknown> = {}) {
if (cmd === "plugin:event|listen") {
listeners.push({ event: args.event as string, handler: args.handler as number });
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
if (cmd.startsWith("plugin:")) return null;
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
},
};
Object.assign(window, {
__TAURI_INTERNALS__: internals,
__TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} },
});
}
installTauriShim({ directories: { 12: tmpdir(), 21: homedir() }, sep: sep as "/" | "\\" });
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
export function connectDesktop(server: Server): () => void {
channels.clear();
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
server.onEvent = (target, event) => {
if (target !== null && target !== "main") return;
for (const l of listeners) {
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
}
};
setInvokeRoute((cmd, args) => {
if (cmd.startsWith("plugin:")) return null;
const { json, channels: used } = encodeArgs(args);
for (const [id, channel] of used) channels.set(id, channel as unknown as { onmessage: (data: unknown) => void });
return server.invoke(cmd, JSON.parse(json));
});
const w = window as unknown as ShimWindow;
w.__openideaShim.reset();
server.onChannel = (id, data) => w.__openideaShim.channel(id, data);
server.onEvent = (target, event) => w.__openideaShim.event(target, event);
w.__openideaForward = (cmd, args) => server.invoke(cmd, args);
return () => {
setInvokeRoute(null);
delete w.__openideaForward;
server.onChannel = () => {};
server.onEvent = () => {};
};
+1
Переглянути файл
@@ -121,6 +121,7 @@ export class Server {
onEvent: (window: string | null, event: unknown) => void = () => {};
private closing: Promise<void> | undefined;
// A plain field, not a parameter property: the evals load this file with Node's type stripping.
private readonly child: ChildProcessWithoutNullStreams;
private constructor(child: ChildProcessWithoutNullStreams) {
+111
Переглянути файл
@@ -0,0 +1,111 @@
/**
* The window's Tauri runtime as the frontend sees it (callbacks, `Channel`s, the event
* plugin, path resolution), with every app command handed to `window.__openideaForward`:
* a real `openidea-server` on the other end (`smoke/desktop.ts` in jsdom, `evals/` in
* Chromium). Self-contained: Playwright serialises this function into the page, so it
* may use nothing from outside its body.
*/
export interface ShimConfig {
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
directories: Record<number, string>;
sep: "/" | "\\";
}
/** What the host side calls to deliver the server's notifications. */
export interface ShimBridge {
/** A value streamed to the `Channel` with this id. */
channel(id: number, data: unknown): void;
/** An `IdeEvent` for a window (`null`: every window). */
event(window: string | null, event: unknown): void;
/** Forgets the channels of an earlier server. */
reset(): void;
}
export interface ShimWindow {
__openideaShim: ShimBridge;
/** Runs an app command on the server; rejects with its `IdeError`. */
__openideaForward?: (cmd: string, args: unknown) => Promise<unknown>;
}
export function installTauriShim(config: ShimConfig) {
type Callback = (message: unknown) => void;
const w = window as unknown as ShimWindow & Record<string, unknown>;
if (w.__openideaShim) return;
// One runtime per page, there from the start as in the app: the frontend registers its
// event listener once (`src/platform/ipc.ts`), possibly before a server is connected,
// and keeps it across the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
const join = (paths: string[]) => {
const absolute = paths[0]?.startsWith(config.sep) ?? false;
const parts: string[] = [];
for (const p of paths.join(config.sep).split(/[\\/]+/)) {
if (p === "" || p === ".") continue;
if (p === ".." && parts.length && parts[parts.length - 1] !== "..") parts.pop();
else parts.push(p);
}
return (absolute ? config.sep : "") + parts.join(config.sep);
};
/** The arguments with each `Channel` replaced by `{ $channel: id }`, as remote development sends them. */
const encode = (args: Record<string, unknown>) => {
const plain: Record<string, unknown> = {};
for (const [k, v] of Object.entries(args)) {
const tag =
v && typeof v === "object" && typeof (v as { toJSON?: unknown }).toJSON === "function"
? (v as { toJSON(): unknown }).toJSON()
: null;
if (typeof tag === "string" && tag.startsWith("__CHANNEL__:")) {
const id = Number(tag.slice("__CHANNEL__:".length));
channels.set(id, v as { onmessage: (data: unknown) => void });
plain[k] = { $channel: id };
} else {
plain[k] = v;
}
}
return JSON.parse(JSON.stringify(plain)) as unknown;
};
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
const id = nextCallback++;
callbacks.set(id, cb);
return id;
},
unregisterCallback(id: number) {
callbacks.delete(id);
},
convertFileSrc: (path: string) => path,
async invoke(cmd: string, args: Record<string, unknown> = {}) {
if (cmd === "plugin:event|listen") {
listeners.push({ event: args.event as string, handler: args.handler as number });
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
if (cmd === "plugin:path|resolve_directory") return config.directories[args.directory as number] ?? null;
if (cmd === "plugin:path|join") return join(args.paths as string[]);
if (cmd.startsWith("plugin:")) return null;
const forward = w.__openideaForward;
if (!forward) throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
return forward(cmd, encode(args));
},
};
w.__TAURI_INTERNALS__ = internals;
w.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
w.__openideaShim = {
channel: (id, data) => channels.get(id)?.onmessage(data),
event(target, event) {
if (target !== null && target !== "main") return;
for (const l of listeners) {
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
}
},
reset: () => channels.clear(),
};
}
+11 -3
Переглянути файл
@@ -181,7 +181,12 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
className={clsx(styles.listPopup, popup.preview && styles.withPreview)}
style={popup.preview ? undefined : { width }}
>
<div className={styles.listMain} style={popup.preview ? { width } : undefined}>
<div
className={styles.listMain}
style={popup.preview ? { width } : undefined}
data-testid="list-popup"
aria-busy={popup.busy || undefined}
>
<div className={styles.listHeader}>
<span className={styles.listTitle}>{popup.title}</span>
{query && <span className={styles.query}>{query}</span>}
@@ -218,6 +223,9 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
<div
className={clsx(styles.item, item.disabled && styles.disabled, item.emphasis && styles.emphasis)}
title={item.disabled ?? undefined}
data-testid="list-popup-item"
data-path={item.location?.path}
data-line={item.location?.line}
>
{popup.numbered && <span className={styles.mnemonic}>{n > 0 && n <= 9 ? n : ""}</span>}
<ItemIcon item={item} />
@@ -445,7 +453,7 @@ function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
// As in IDEA the popup leaves the keyboard to the editor until F1 focuses it.
autoFocus={false}
>
<div ref={scope} className={styles.docScope}>
<div ref={scope} data-testid="doc-popup" className={styles.docScope}>
<DocView host="popup" history={popup} themeClasses={popup.themeClasses} />
</div>
</Popup>
@@ -608,7 +616,7 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
})}
</ul>
) : (
<ul className={styles.previewList} aria-label={tx("Changes")}>
<ul data-testid="refactor-preview" className={styles.previewList} aria-label={tx("Changes")}>
{summary.entries.map((e) => (
<li key={e.path} className={styles.previewRow}>
<Icon name={fileIconName(basename(e.path), e.path)} size={14} className={styles.itemIcon} />
+2 -2
Переглянути файл
@@ -357,8 +357,8 @@ export function registerLspActions(): () => void {
description: "Shows `{ text, tone? }` as a transient hint at the caret of the active editor.",
run: ({ arg }) => {
const view = activeEditor()?.view;
const a = (arg ?? {}) as { text?: string; tone?: "info" | "warning" | "error" };
if (view && a.text) showHint(view, a.text, { tone: a.tone });
const a = (arg ?? {}) as { text?: string; tone?: "info" | "warning" | "error"; data?: Record<string, string> };
if (view && a.text) showHint(view, a.text, { tone: a.tone, data: a.data });
},
},
]);
+11
Переглянути файл
@@ -28,10 +28,21 @@ describe("pending hints", () => {
const done = showPendingHint(view, "Waiting…");
vi.advanceTimersByTime(250);
expect(hint()).toBe("Waiting…");
expect(document.querySelector<HTMLElement>(".cm-lsp-hint")?.dataset.pending).toBe("true");
done();
expect(hint()).toBeNull();
});
it("marks only progress as pending", () => {
showHint(view, "Done");
expect(document.querySelector<HTMLElement>(".cm-lsp-hint")?.dataset.pending).toBeUndefined();
});
it("puts what the hint answers in its data attributes", () => {
showHint(view, "Only one usage", { data: { usages: "1" } });
expect(document.querySelector<HTMLElement>(".cm-lsp-hint")?.dataset.usages).toBe("1");
});
it("does not dismiss a newer hint when an old request finishes", () => {
const done = showPendingHint(view, "Waiting…");
vi.advanceTimersByTime(250);
+11 -3
Переглянути файл
@@ -13,6 +13,10 @@ interface Hint {
/** Text, or DOM built when shown (with `destroy` when it holds e.g. a React root). */
content: string | (() => HTMLElement | { dom: HTMLElement; destroy: () => void });
tone: "info" | "error" | "warning";
/** Progress of a request still running (`data-pending` on the hint). */
pending?: boolean;
/** `data-*` attributes saying what the hint answers, for tests and tools. */
data?: Record<string, string>;
}
const setHint = StateEffect.define<Hint | null>();
@@ -35,6 +39,8 @@ const hintField = StateField.define<Hint | null>({
const dom = document.createElement("div");
dom.className = `cm-lsp-hint cm-lsp-hint-${h.tone}`;
dom.setAttribute("role", "status");
if (h.pending) dom.dataset.pending = "true";
Object.assign(dom.dataset, h.data);
if (typeof h.content === "string") {
dom.textContent = txTemplate(h.content);
return { dom };
@@ -57,10 +63,12 @@ const timers = new WeakMap<EditorView, ReturnType<typeof setTimeout>>();
export function showHint(
view: EditorView,
content: Hint["content"],
opts: { pos?: number; tone?: Hint["tone"]; timeout?: number } = {},
opts: { pos?: number; tone?: Hint["tone"]; timeout?: number; pending?: boolean; data?: Hint["data"] } = {},
) {
const pos = opts.pos ?? view.state.selection.main.head;
const effects: StateEffect<unknown>[] = [setHint.of({ pos, content, tone: opts.tone ?? "info" })];
const effects: StateEffect<unknown>[] = [
setHint.of({ pos, content, tone: opts.tone ?? "info", pending: opts.pending, data: opts.data }),
];
if (!view.state.field(hintField, false)) effects.push(StateEffect.appendConfig.of(hintExtension()));
view.dispatch({ effects });
clearTimeout(timers.get(view));
@@ -85,7 +93,7 @@ export function showPendingHint(view: EditorView, content: string): () => void {
view.state.field(hintField, false) !== previous
)
return;
showHint(view, content, { timeout: 30_000 });
showHint(view, content, { timeout: 30_000, pending: true });
shown = view.state.field(hintField);
}, 250);
return () => {
+4
Переглянути файл
@@ -192,6 +192,7 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
label: l.name,
detail: [l.container, where].filter(Boolean).join(" · "),
symbol: l.kind,
location: { path: l.path, line: l.range.start.line },
run: () => navigateTo(l),
};
return {
@@ -199,6 +200,7 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
label: names[i] ?? basename(l.path),
detail: [indexed.get(i) ?? enclosingType(files.get(l.path), l), where].filter(Boolean).join(" · "),
icon: fileIconName(basename(l.path), l.path),
location: { path: l.path, line: l.range.start.line },
run: () => navigateTo(l),
};
});
@@ -256,6 +258,7 @@ function streamedImplementations(view: EditorView, offset: number, onOpen: () =>
kind: "list",
anchor: caretAnchor(view, offset),
title: searchingTitle(name, first.length),
busy: true,
items,
pin: pinToFind(name ?? "", first),
onClose: () => {
@@ -282,6 +285,7 @@ function streamedImplementations(view: EditorView, offset: number, onOpen: () =>
if (closed || superseded()) return;
updatePopup("list", {
title: chooserTitle("implementation", name, all.length),
busy: false,
items,
pin: pinToFind(name ?? "", all),
});
+4
Переглянути файл
@@ -26,6 +26,8 @@ export interface ListPopupItem {
/** Disabled reason; disabled rows are shown but not activatable. */
disabled?: string | null;
shortcut?: string;
/** The place the row goes to (`data-path` / `data-line`, 0-based, on the row). */
location?: { path: string; line: number };
run: () => void | Promise<void>;
/** IDEA's right-arrow menu of the row (inspection options of a fix). */
submenu?: MenuItem[];
@@ -94,6 +96,8 @@ export type LspPopup =
items: ListPopupItem[];
/** Rows 1-9 get a number; typing it runs the row (IDEA's numbered popups). */
numbered?: boolean;
/** More rows are still being looked for (`aria-busy`). */
busy?: boolean;
/** Header pin button (e.g. "Open in Find Tool Window"). */
pin?: { label: string; run: () => void };
/** Side panel for the selected row, fetched lazily; `signal` aborts when the selection moves. */
+16 -3
Переглянути файл
@@ -146,7 +146,16 @@ function UsagesPopupInner() {
const keys = shortcutLabel("find.usages");
return (
<Popup open anchor={anchor} onClose={close} aria-label="Usages" className={styles.popup}>
<div className={styles.focus} tabIndex={0} data-autofocus onKeyDown={onKeyDown}>
<div
className={styles.focus}
tabIndex={0}
data-autofocus
data-testid="usages-popup"
data-loading={loading}
data-total={total}
data-scope={scope ?? undefined}
onKeyDown={onKeyDown}
>
<div className={styles.header}>
<span className={styles.title}>
{symbol ? tx`Usages of ${symbol}` : tx("Usages")}{" "}
@@ -191,7 +200,11 @@ function UsagesPopupInner() {
</button>
</div>
<UsageAccessFilter items={all} />
{note && <div className={styles.note}>{note}</div>}
{note && (
<div className={styles.note} data-testid="usages-note">
{note}
</div>
)}
<div className={styles.body} style={{ height: Math.max(1, Math.min(14, items.length)) * ROW }}>
<List
items={items}
@@ -211,7 +224,7 @@ function UsagesPopupInner() {
</div>
}
renderItem={(u) => (
<div className={styles.row}>
<div className={styles.row} data-testid="usage-row" data-path={u.path} data-line={u.line}>
<Icon name={fileIconName(basename(u.path), u.path)} size={16} className={styles.icon} />
<UsagePreview usage={u} />
<span className={styles.spacer} />
+3 -3
Переглянути файл
@@ -21,9 +21,9 @@ export async function lspRequest<T>(run: () => Promise<Result<T>>): Promise<T> {
export const isUnavailable = (e: unknown) => e instanceof IpcError && e.kind === "unavailable";
/** Small hint at the caret (via editor/lsp), or a notification without an editor. */
export function hint(text: string, tone: "info" | "warning" | "error" = "info") {
if (activeEditor()) void executeAction("editor.showHint", { text, tone });
/** Small hint at the caret (via editor/lsp), or a notification without an editor; `data`: its `data-*` attributes. */
export function hint(text: string, tone: "info" | "warning" | "error" = "info", data?: Record<string, string>) {
if (activeEditor()) void executeAction("editor.showHint", { text, tone, data });
else
notifications().notify({
group: "lsp",
+18 -6
Переглянути файл
@@ -447,21 +447,33 @@ async function searchPopup(c: CaretContext, libraries: boolean, widenable: boole
// As in IDEA, the hint offers to widen the search, which Show Usages does while it shows.
const widen = scope === "project" && again !== "";
if (widen) popupTarget.hintUntil = Date.now() + HINT_MS;
// The count and scope, for tests: the hint is the whole answer.
const counted: Record<string, string> = scope ? { usages: String(total), scope } : { usages: String(total) };
if (!total) {
if (widen) hint(`No usages found in Project Files. Press ${again} again to search in 'Project and Libraries'`);
else if (scope === "project") hint("No usages found in Project Files");
if (widen)
hint(
`No usages found in Project Files. Press ${again} again to search in 'Project and Libraries'`,
"info",
counted,
);
else if (scope === "project") hint("No usages found in Project Files", "info", counted);
else {
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
hint(note ? `${none}. ${note}` : none);
hint(note ? `${none}. ${note}` : none, "info", counted);
}
return;
}
// IDEA jumps to the only usage and says so in the target editor.
const u = groups[0]!.usages[0]!;
await openLocation(u.path, u.line, u.col);
if (widen) hint(`Only one usage in Project Files. Press ${again} again to search in 'Project and Libraries'`);
else if (scope === "libraries") hint("Only one usage in Project and Libraries");
else hint("Only one usage in Project Files");
if (widen)
hint(
`Only one usage in Project Files. Press ${again} again to search in 'Project and Libraries'`,
"info",
counted,
);
else if (scope === "libraries") hint("Only one usage in Project and Libraries", "info", counted);
else hint("Only one usage in Project Files", "info", counted);
} catch (e) {
if (popupRequest !== request || isCancelled(e)) return;
if (early || reopened) useUsagesPopup.setState({ loading: false });
+69 -5
Переглянути файл
@@ -3,12 +3,15 @@
* Loaded only in dev and in `vite build --mode perf`; release builds never
* include it. Measurement itself lives in the harness so it stays out of the app.
*/
import { completionStatus, currentCompletions } from "@codemirror/autocomplete";
import { syntaxParserRunning } from "@codemirror/language";
import { forEachDiagnostic } from "@codemirror/lint";
import { executeAction } from "@/platform/actions";
import { bindingsFor } from "@/platform/keymap";
import { activeEditor } from "@/editor/api";
import { docBackend, type MemoryBackend } from "@/editor/core/backend";
import type { Commit, CompletionItem, CompletionKind, LogQuery } from "@/bindings";
import { openProject } from "@/platform/project";
import { setInvokeRoute } from "@/platform/tauriCore";
import { editorService } from "./contributions";
@@ -16,6 +19,8 @@ export interface PerfProbe {
/** Seeds file contents for the in-memory document backend (plain-browser mode). */
seed(path: string, text: string): void;
openFile(path: string): Promise<void>;
/** Opens project `path` in this window (as the welcome screen does), without the window chooser. */
openProject(path: string): Promise<void>;
runAction(id: string, arg?: unknown): Promise<boolean>;
/** Effective keymap bindings of an action, e.g. `["Ctrl+Shift+A"]`. */
bindings(id: string): string[];
@@ -23,8 +28,22 @@ export interface PerfProbe {
setCursor(offset: number): void;
/** True while the active editor's background syntax parser still has work. */
parsing(): boolean;
/** Document length and caret of the active editor. */
editorState(): { path: string; length: number; lines: number; head: number } | null;
/** The active editor's text. */
editorText(): string | null;
/** Inserts `text` after the first `after` in the active editor, as an edit of the user's; the offset after it. */
insertAfter(after: string, text: string): number;
/** Replaces the active editor's whole text, as an edit of the user's. */
setText(text: string): void;
/** Labels of the completion lookup open in the active editor. */
completions(): string[];
/** True while a completion source of the active editor has not answered yet. */
completionPending(): boolean;
/** The diagnostics in the active editor (0-based line). */
diagnostics(): Array<{ line: number; severity: string; message: string }>;
/** 0-based line of the active editor shown at viewport `y` (e.g. a gutter marker's), or -1. */
lineAtY(y: number): number;
/** Document length and caret (offset, 0-based line, the word around it) of the active editor. */
editorState(): { path: string; length: number; lines: number; head: number; line: number; word: string } | null;
/**
* Answers code completion with `items` generated items (a language server's
* list); other language server requests report no server. `0` removes it.
@@ -126,6 +145,12 @@ function setRoute(name: string, next: Route | null) {
);
}
function requireView() {
const view = activeEditor()?.view;
if (!view) throw new Error("no active editor");
return view;
}
export function installPerfProbe(): () => void {
const probe: PerfProbe = {
seed(path, text) {
@@ -134,11 +159,11 @@ export function installPerfProbe(): () => void {
backend.files.set(path, text);
},
openFile: (path) => editorService().openFile(path),
openProject: async (path) => void (await openProject(path)),
runAction: (id, arg) => executeAction(id, arg),
bindings: (id) => bindingsFor(id),
setCursor(offset) {
const view = activeEditor()?.view;
if (!view) throw new Error("no active editor");
const view = requireView();
view.dispatch({ selection: { anchor: Math.min(offset, view.state.doc.length) }, scrollIntoView: true });
view.focus();
},
@@ -177,11 +202,50 @@ export function installPerfProbe(): () => void {
: null,
);
},
editorText: () => activeEditor()?.view.state.doc.toString() ?? null,
insertAfter(after, text) {
const view = requireView();
const at = view.state.doc.toString().indexOf(after);
if (at < 0) throw new Error(`no ${JSON.stringify(after)} in the active editor`);
const from = at + after.length;
view.dispatch({ changes: { from, insert: text }, userEvent: "input" });
return from + text.length;
},
setText(text) {
const view = requireView();
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, userEvent: "input" });
},
completions: () => currentCompletions(requireView().state).map((c) => c.label),
completionPending: () => completionStatus(requireView().state) === "pending",
diagnostics() {
const found: Array<{ line: number; severity: string; message: string }> = [];
const state = activeEditor()?.view.state;
if (!state) return found;
forEachDiagnostic(state, (d, from) =>
found.push({ line: state.doc.lineAt(from).number - 1, severity: d.severity, message: d.message }),
);
return found;
},
lineAtY(y) {
const view = activeEditor()?.view;
if (!view) return -1;
const block = view.lineBlockAtHeight(y - view.documentTop);
return view.state.doc.lineAt(block.from).number - 1;
},
editorState() {
const e = activeEditor();
if (!e) return null;
const { doc, selection } = e.view.state;
return { path: e.ctx.path, length: doc.length, lines: doc.lines, head: selection.main.head };
const head = selection.main.head;
const word = e.view.state.wordAt(head);
return {
path: e.ctx.path,
length: doc.length,
lines: doc.lines,
head,
line: doc.lineAt(head).number - 1,
word: word ? doc.sliceString(word.from, word.to) : "",
};
},
};
const w = window as unknown as { __openideaPerf?: PerfProbe };
+1 -1
Переглянути файл
@@ -46,6 +46,6 @@ export default defineConfig(() => ({
environment: "jsdom",
globals: false,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}", "perf/**/*.test.ts", "scripts/**/*.test.ts"],
include: ["src/**/*.test.{ts,tsx}", "perf/**/*.test.ts", "evals/**/*.test.ts", "scripts/**/*.test.ts"],
},
}));