diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbd0104..8260ffe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,74 @@ ## [Unreleased] +### Added + +- The run configuration writes an HTML report (`--log-html`, on by default) and a JUnit report (`--log-junit`, off) + into an IDE-managed folder on every run; both are kept in the run history, and HTML opens in a tab. +- The run configuration chooses which coverage reports a Coverage run asks Testo for: Clover, Cobertura, coverage-xml. +- A Coverage run applies every report it produced on its own, one per format. +- The Coverage button on the test toolbar gathers every announced report under one click, with a checkbox per report. +- The Coverage panel gained expand/collapse, an editor-highlighting switch, report-format badges, and a column + counting the tests that cover each file. +- *Run Covering Tests* on the Coverage panel runs, with coverage, the tests covering the selected file or directory. +- *Select Opened File* on the Coverage panel selects the file open in the editor. +- A gutter icon on every covered method, function and class lists its covering tests, runnable together or one at a + time, with a switch on the Coverage panel. +- The popup on a covered line highlights the row under the pointer and runs all of that line's covering tests. +- The covering-tests lens on a covered declaration leads its list with *Run all covering tests*, then a jump to each. +- Every run is archived — output, reports and parameters — and replays from *Test History* as a full Testo console. +- *Show history* above a test pops up the archived runs containing it (the one already open in a tab in bold); each + entry offers *Load replay* (opens the archive and selects the test's node) and *Repeat run* (re-executes it), so a + bare click never launches anything. +- How many runs the history keeps is set in *Tools | Testo* or from the history list itself, which also clears it. +- *Expand All* / *Collapse All* now sit on the toolbar itself, next to *Show Passed* / *Show Ignored*. +- A tab opened from the history reruns with the executor the archived run used. +- A *Replay* button exports the run as one archive, imports one back, reveals its folder, and sets what the history + may do with it: keep, drop or lock. +- Every report a run announces is archived with it — HTML with its assets — so a replay opens its own reports. +- The history list bolds the run the tab shows, marks locked runs, and gives each entry the icon of its kind. +- An imported run comes in locked, so retention never deletes it. +- The run configuration keeps its coverage settings in a group of their own: the analysis level (`--coverage-level`, + or *auto*) and coverage-only options (`--type=!bench` by default). +- Group and Exclude group are lists of tags, picked from the `#[Group]` names the project declares. +- Suite is a list of tags too; a run may narrow to several suites at once, one `--suite` flag each. +- The run configuration is laid out in groups: Parallel under Test Runner Options, then Run Options, Filter and + Coverage. + +### Removed + +- The Repeat field — Testo's command line has no `--repeat`. +- The Command field — a test run is always `testo run`; other subcommands are what *Run Anything* is for. +- Parallel no longer sends a flag Testo does not have: the field is parked at 1 until it does. + +### Changed + +- A Coverage run at level *auto* now collects branch coverage when the engine is Xdebug and a Cobertura report is on. +- The *Log Levels* filter is now a minimum-level picker: an `info +` combo box at the right of the channel tabs row + shows that level and everything above it (default `info`), instead of per-level checkboxes. +- The channel console keeps the open channel when you change the log level or switch tests, reselecting the same-named + tab after the rebuild; Output now leads the tabs, apart from the channel-aggregating *All*. + +### Fixed + +- The *Show history* lens shows again (it never did on Windows): the lens built its location from the OS-native path + (`D:\…`) while the archive stored Testo's `D:/…`, so nothing matched. Locations are now compared with unified path + separators, and the archive index is built when the lens is first asked rather than on a later repaint. +- The two Code Vision lenses (test history, covering tests) now carry a name and description in *Settings | Editor | + Inlay Hints | Code Vision* instead of showing up blank. +- The debug toolbar has its rerun/restart button back, during the session and after it ends; while the session runs + it wears the restart-debugger icon. +- Starting a debug session no longer logs a *split debugger* error: the run descriptor now comes off the session + builder's result instead of the deprecated `XDebugSession.getRunContentDescriptor()`. +- The Test Runner Options help button opens Testo's CLI reference. +- The channel console no longer throws an EDT-threading error while streaming live output into an aggregate tab. +- The report buttons no longer trigger a "slow operations on EDT" error. +- The elapsed time in the run summary no longer counts up forever when a run ends before the toolbar is wired. +- Clearing the history updates the *Replay* menu of the run it spares to *Do not keep in history*. +- The first coverage run of an IDE session paints the editor right away. +- Lists of covering tests name the test class without its namespace. +- Excluding a group runs again: it goes out as `--group=!name`. + ## [2026.5.262] - 2026-08-12 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 7f6e7de7..7e0c2747 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,20 +40,22 @@ Dependabot bumps these regularly — read the files rather than trusting this ta ### Two build variants (`phpApi`) -PHP moved its coverage classes from `com.jetbrains.php.phpunit.coverage` to `com.intellij.php.coverage` in 2026.2, and -no single artifact can reference both (`` inside an optional descriptor is ignored, and there is no module -that exists only on ≤261 to gate on). So every platform-dependent property in `gradle.properties` is declared twice with -an API suffix and selected by `phpApi`: +The plugin ships as two artifacts because the platform since/until ranges and a few bundled modules differ across +2025.2 and 2026.2 (jcef / smRunner / testRunner split out of the monolith on 262). So every platform-dependent property +in `gradle.properties` is declared twice with an API suffix and selected by `phpApi`: ```bash ./gradlew buildPlugin # 262: platform 2026.2, since 262, no untilBuild ./gradlew buildPlugin -PphpApi=252 # 252: platform 2025.2, since 252, until 261.* ``` -The only source difference is `src/php252/kotlin` vs `src/php262/kotlin`, each holding one file of `typealias`es -(`PhpCoverageRunner`, `PhpCoverageSuite`, `PhpUnitCoverageEngine`, `PhpUnitCoverageRunner`) pointing at whichever package -is current. `src/main/kotlin/.../coverage/` imports none of them — the aliases live in its own package. The enum -`PhpUnitCoverageEngine.CoverageEngine` did **not** move and is still imported directly from `com.jetbrains.php`. +**The two artifacts no longer differ in source** — coverage runs on 100 % public platform API (`coverage/`, see +"Generated reports" / the `coverage/` tree), so the old `src/php252/kotlin` vs `src/php262/kotlin` typealias split is +gone. `phpApi` is now purely a build selector (platform version, since/until, per-platform modules). The enum +`PhpUnitCoverageEngine.CoverageEngine` (the Xdebug/PCOV driver) is the one PHP coverage symbol still used and did **not** +move — it is imported directly from `com.jetbrains.php`. + +Source that is single but not version-agnostic is reached by reflection, never a direct symbol: `XDebuggerManager.newSessionBuilder` (`TestoDebugRunner`) exists only on 262, so a direct call compiles green locally on 262 and breaks the 252 build in CI. Guard any such 262-only platform symbol behind a reflective lookup with a 252 fallback, or compile both variants before calling it done. Each variant is published as `.` (e.g. `2026.3.1.252` / `2026.3.1.262`). The Marketplace keys uploads by version and rejects a second upload carrying a version it already has, so the two builds *must not* share @@ -105,10 +107,19 @@ src/main/kotlin/com/github/xepozz/testo/ │ ├── coverage/ # optional, enabled via META-INF/coverage.xml │ ├── TestoCoverageEngine.kt # PhpUnitCoverageEngine subclass + suite/enabled-configuration -│ └── TestoCoverageProgramRunner.kt # --coverage-clover=, Xdebug/PCOV toggling +│ ├── TestoCoverageProgramRunner.kt # --coverage-* flags on the IDE-managed paths, Xdebug/PCOV toggling +│ ├── TestoCoverageRunner.kt # loads a report into ProjectData + the per-test index +│ ├── TestoCoverageAnnotator.kt # per-file/dir percentages behind the Coverage view's columns +│ ├── TestoCoverageViewExtension.kt # the view's columns (Branches, Tests) and its extra toolbar +│ ├── TestoCoverageViewActions.kt # those toolbar actions: highlight, gutters, run covering, badges +│ ├── TestoCoverageSelectOpenedFile.kt # our own "select the opened file" (the platform's cannot work here) +│ ├── editor/ # the editor side: stripes, the line popup, the covering-tests gutter +│ ├── format/ # clover / cobertura / coverage-xml parsers → one model +│ └── perTest/ # which test touched which line: index, keys, identity, launcher │ ├── index/ │ ├── TestoDataProvidersIndex.kt # FileBasedIndex: provider name → {class, method, providerFqn} +│ ├── TestoGroupsIndex.kt # FileBasedIndex: every name a #[Filter\Group] in the project spells │ └── TestoDataProviderUtils.kt # isDataProvider / findDataProviderUsages / usage index │ ├── references/ @@ -137,13 +148,12 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoProtocolGate.kt # nodeId-less messages ⇒ pre-0.10.39 Testo; banner → version │ │ ├── ChannelOutputStore.kt # per-test live buffers: all / output / per-channel │ │ ├── ChannelIcons.kt # channel name or icon= hint → platform icon -│ │ ├── LogLevelFilter.kt # persisted display-time log-level filter -│ │ ├── TestoLogLevelFilterAction.kt # toolbar dropdown for the filter +│ │ ├── LogLevelFilter.kt # persisted minimum-log-level filter (a message shows at or above it) +│ │ ├── TestoLogLevelFilterAction.kt # the `info +` combo box on the channel tabs row picking that minimum │ │ ├── TestoChannelsUi.kt # the tabbed channel view (~1150 lines) + testoDisplayName() │ │ ├── TestoConsoleAugmenter.kt # ExecutionListener that installs the channel tabs -│ │ ├── TestoChannelHistory.kt # channel output ⇄ SMTestProxy.metainfo (survives history export) -│ │ ├── TestoHistoryImport.kt # "Show history": import a saved run onto our own console properties -│ │ ├── TestoHistoryIndex.kt # which locationUrls exist in saved history XMLs (+ lens refresh) +│ │ ├── TestoReplaySelection.kt # selects a test's node once the replayed tree stops growing +│ │ ├── TestoHistoryIndex.kt # which test locations the run archive holds (+ lens refresh) │ │ ├── TestoTestStatus.kt # the 8 cases of Testo\Core\Value\Status: wire name, icon, label │ │ ├── TestoStatusStore.kt # per-test status/assertions + the tally the toolbar summary renders │ │ ├── TestoRunTimings.kt # start/first test/last test/finish marks + summed test durations @@ -154,6 +164,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoReportStore.kt # reports announced by `##teamcity[testoReport …]` + where to look for them │ │ ├── TestoReportAutoOpen.kt # when a report opens on its own: this-run arm / project / application scopes │ │ ├── TestoReportAction.kt # right-aligned panel of hand-drawn report buttons (WebView / browser / copy) +│ │ ├── TestoTreeToolbarActions.kt # expand/collapse for the test tree and the Coverage view alike │ │ ├── TestoTestTreeDecorator.kt # wraps the tree's cell renderer: status icons + description tooltips │ │ ├── TestoRepeatedFrameFolding.kt # folds repeated `#N frame` lines │ │ └── PhpBacktraceFileFilter.kt # file(line) / file:line / "on line N" → hyperlinks @@ -172,6 +183,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoRunConfigurationHandler.kt # maps scope/settings → CLI flags │ │ ├── TestoRunConfigurationSettings.kt # persistence; default options "-q -n --teamcity" │ │ ├── TestoRunnerSettings.kt # Testo-specific persisted fields + transient rerunFilters +│ │ ├── TestoTagsField.kt # the Group / Exclude group fields: removable tags + an add popup │ │ ├── TestoRunConfigurationProducer.kt # context → configuration (~615 lines, the trickiest file) │ │ ├── TestoTestRunConfigurationEditor.kt # "Testo Options" panel wrapping the PHP editor │ │ ├── TestoTestRunnerSettingsValidator.kt # + the finder that switches the "Cannot find …" gate off @@ -180,9 +192,21 @@ src/main/kotlin/com/github/xepozz/testo/ │ └── runAnything/ │ └── TestoRunAnythingProvider.kt # "testo " in Run Anything │ +├── runs/ # the run archive: every run replayable, reports kept beside it +│ ├── TestoRunStore.kt # project service: archive root, listing, reading, retention +│ ├── TestoRunRecording.kt # one live run being written (output.log framing, tests.txt, run.json) +│ ├── TestoRunManifest.kt # run.json: executor, timings, per-status tally, captured reports +│ ├── TestoRunArchiver.kt # finalizes a run: captures reports, writes the manifest, prunes +│ ├── TestoRunReplayProfile.kt # replays an archive through the live console properties +│ ├── TestoRunArchive.kt # a run as one zip: export, import (zip-slip guarded), export file name +│ ├── TestoReplayGroup.kt # toolbar "Replay": keep-discard-lock + export/import/reveal, for this tab's run +│ ├── TestoRunHistoryGroup.kt # the "Test History" toolbar button, replacing the platform's +│ └── TestoRunHistoryActions.kt # run kind icons + summaries, the retention submenu, the lens's lookups +│ └── ui/ ├── TestoIconProvider.kt # Testo-marked icons for PHP test files ├── TestoHistoryCodeVisionProvider.kt # "Show history" lens above each test + ├── TestoCodeVisionGroupSettings.kt # the two lenses' name/description in Inlay Hints settings (else blank) ├── TestoReportEditor.kt # JCEF editor tab for a generated report (light file + provider) └── TestoStackTraceConsoleFolding.kt # folds `[internal function]` frame runs @@ -216,8 +240,8 @@ src/test/testData/mixin, rename # PHP fixtures for PSI-backed tests `com.jetbrains.php` namespace: `testFrameworkType` (`TestoFrameworkType`), `composerConfigClient` (`TestoComposerConfig`). -`META-INF/coverage.xml` (optional, `com.intellij.modules.coverage`) adds `coverageEngine` + the coverage -`programRunner`. +`META-INF/coverage.xml` (optional, `com.intellij.modules.coverage`) adds `coverageEngine`, `coverageRunner`, the +coverage `programRunner`, the annotator service and the *Run covering tests* `codeInsight.lineMarkerProvider`. `projectListeners`: `TestoConsoleAugmenter` on `ExecutionListener` — the only hook where the PHP-built test console can be reached to install the channel tabs. @@ -238,19 +262,26 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor [testRunnerOptions] [runner flags] [--config ] [scope flags] ``` -- `command` — the subcommand, default `run` (editable in the editor's combo box). +- `command` — the subcommand, always `run` for a test run (other subcommands live in Run Anything). - `testRunnerOptions` default to **`-q -n --teamcity`** (`TestoRunConfigurationSettings.createDefault`). The `--teamcity` flag is what makes Testo emit the SM service messages this plugin parses. -- Runner flags from `TestoRunnerSettings` (only emitted when non-empty / > 0): `--type`, `--suite`, `--group`, - `--exclude-group`, `--repeat`, `--parallel`, plus one `--filter ` per entry in `rerunFilters`. - `group`/`excludeGroup` are single persisted strings holding comma-separated names; the handler splits them into one - flag per name (Testo ORs repeated `--group`s, and a `!name` prefix excludes). `groups`/`excludeGroups` are - persisted **lists** (`@XCollection`), so a name is opaque — whatever `#[Group]` spells reaches the CLI untouched. +- Runner flags from `TestoRunnerSettings` (only emitted when non-empty): `--type`, `--suite`, `--group`, + plus one `--filter ` per entry in `rerunFilters` (no `--parallel` — Testo's CLI does not take it yet). + `suites`, `groups`/`excludeGroups` are persisted + **lists** (`@XCollection`), one `--group` flag each — Testo ORs repeated `--group`s and reads a `!name` prefix as an + exclusion, which is the only exclusion form its CLI has. A name is opaque: whatever `#[Group]` spells reaches the + CLI untouched. +- `--log-html`/`--log-junit` at an IDE-managed path (`TestoReportFlags`, `logHtml` on / `logJunit` off by default), + emitted from `createCommand` so every executor gets them — local interpreters only, and the archive copies the + reports into history the same way it does coverage. Not in `prepareArguments`: that has no project/interpreter. - `--config ` when an alternative configuration file is set (`getConfigFileOption()`). - Scope flags: `Type` → `--suite `; `Directory`/`File` → `--path `; `Method` → `--path --filter [--data-provider ]`; `ConfigurationFile` → nothing (the config file argument alone drives the run). -- Coverage adds `--coverage-clover=` (or bare `--coverage` if no path), plus Xdebug or PCOV +- Coverage adds one `--coverage-=` per checked report (or bare `--coverage` if no path), + `--coverage-level=` (`resolveCoverageLevel`: an explicit level, else *auto* → `branch` when the + engine is Xdebug and a Cobertura report is on — branches need Xdebug and Cobertura carries them — else nothing), + the configuration's own coverage-only options (`coverageOptions`, `--type=!bench` by default), plus Xdebug or PCOV INI options depending on `coverageEngine`. - Working directory is always `project.basePath`. @@ -368,10 +399,12 @@ it keeps everything the class holds (a `#[Test]` class typed as `test` would dro `TestoRunTimings` splits the run into startup / tests / post-processing for the hover and sums the `duration` attributes beside them, which concurrency pushes past the window the tests ran in. -4. **Run history** — three cooperating pieces: `TestoChannelHistory` round-trips channel output through - `SMTestProxy.metainfo` (the only per-test datum the platform's history XML preserves), `TestoHistoryIndex` knows - which tests appear in saved history files, and `TestoHistoryCodeVisionProvider` shows a clickable - *Show history* lens that imports the newest run containing that specific test and selects its node. +4. **Run history** (`runs/`) — every run is archived under the IDE system dir as its raw teamcity output + (`output.log`), a manifest (`run.json`: executor, per-status tally, captured reports) and the report files + themselves; `tests.txt` lists the test locations it holds. `TestoRunReplayProfile` feeds that stream back through + the live console, so a replayed run has the whole Testo UI. `TestoHistoryIndex` answers which tests the archive + holds, and `TestoHistoryCodeVisionProvider` shows a *Show history* lens that replays the newest run containing + that specific test and selects its node. Retention is the plugin's own (`Tools | Testo`). 5. **Rerun toolbar** — two user-selectable styles (`Tools | Testo`): `MIRROR_AWARE` (three executor-pinned buttons that hide whichever duplicates the platform Rerun) and `SPLIT_BUTTON` (default; one split button, platform Rerun @@ -416,11 +449,15 @@ Non-obvious constraints already paid for in blood — read before touching the r - **`TestoNodeIndex` makes an id-keyed store readable from the tree.** `SMTestProxy` does not carry the id, but `SMTRunnerEventsListener.onTestStarted(proxy, nodeId, parentNodeId)` hands both out together. Hooked from `TestoOutputToGeneralEventsConverter.setProcessor`, before any output is read. Writes never consult it. -- **Channel storage keys still go through `ChannelOutputStore.keyFor(name)`** and so inherit the name collision. - They cannot move to node ids: an imported history run has none, and `TestoChannelHistory` rebuilds its tabs from - the saved XML. +- **Channel storage keys still go through `ChannelOutputStore.keyFor(name)`** and so inherit the name collision: + the channel UI is looked up by test name, which is all a tab has when the selection changes. - **`TestoChannelsUi` reaches `TestResultsPanel.myConsole` by reflection** — there is no public accessor. It degrades gracefully (logs a warning, no channel tabs) if the field disappears. +- **The log-level filter rides on each tab's `TabInfo.setTabPaneActions`** (right edge of the tab row), not on + `JBTabs.getEntryPointActionGroup()`: that getter is `@ApiStatus.Internal` and fails the verifier's default + `failureLevel` (overriding it cost a red `verifyPlugin` that `buildPlugin` and `test` never catch). `setTabPaneActions` + is public, and the platform feeds the selected tab's group into the same entry-point toolbar on every tab change, so + setting it on every `TabInfo` is equivalent — put it on all tabs, since the toolbar follows the active one. - **The tree has one filter slot, shared with *Show passed* / *Show ignored*.** `TestoProgressAction.applyFilter` is its single writer: a selected counter replaces the toggles rather than narrowing them (intersecting would answer "show me the passed ones" with an empty tree), and releasing it recomposes them via `hiddenByToggles` — off Testo's @@ -432,33 +469,82 @@ Non-obvious constraints already paid for in blood — read before touching the r `setCellRenderer`). Safe: `attachToModel` is the only installer and runs at form construction, and nothing in the test-framework packages reads the renderer back. The proxy comes off `NodeDescriptor.getElement()` for the same reason — same object, public class. +- **`TestoHistoryIndex` builds its location set synchronously in `contains`**, on the daemon's background thread, so + the *first* code-vision pass over a freshly opened file already answers. An earlier async build left the first pass + empty and leaned on a later repaint, but `refreshLens` does not reliably force a daemon-bound recompute in 2026.2 — + so the *Show history* lens never appeared until the file was edited. The read is bounded (a few small `tests.txt`) + and cached per archive generation. +- **Location hints from PSI and from Testo differ in path separators.** `getLocationHint` runs the file through the + local `PhpCommandLinePathProcessor`, which yields the OS-native path (`D:\…` on Windows); Testo emits the same + location with `/`. So any lookup of a PSI-built hint against an archived one (the history lens, *Show history*'s + replay) must go through `runLocationKey`, which folds separators to `/` — otherwise it never matches on Windows. - **`TestoHistoryIndex.refreshLens` uses the internal `ModificationStampUtil`** to force code-vision recomputation - after a run; a test run never touches PHP source, so neither `DaemonCodeAnalyzer.restart()` nor - `invalidateProvider` alone re-runs `getHint`. Wrapped in `runCatching`. -- **Imported history needs our own console properties.** `ImportedTestConsoleProperties` does not delegate - `createImportActions`, so `TestoHistoryImport` reconstructs the import on `TestoImportedConsoleProperties` - to keep the log-level filter button. Import wiring polls for a stable node count instead of subscribing — - a small import can finish replaying before the augmenter hands us the console. -- **The log-level filter is added via `createImportActions`, not `appendAdditionalActions`** — the latter is routed - into the gear submenu and would not survive the RunTab toolbar snapshot. + after a run — for editors already open when a run finishes; a test run never touches PHP source, so neither + `DaemonCodeAnalyzer.restart()` nor `invalidateProvider` alone re-runs `getHint`. Wrapped in `runCatching`. +- **History is replayed, not imported.** The platform's import forces `ImportedTestConsoleProperties` and its own + converter, so none of our stores fill — an imported tab is a PHPUnit-looking tree. `TestoRunReplayProfile` feeds + the archived teamcity stream through the *live* properties instead. Three switches keep a replay from acting like + a run: `replayMode`, `getConfiguration()` answering the replay profile, `reportStore.startedAtOverride`. +- **Whoever waits for a replayed tree polls for a stable node count** instead of subscribing to + `SMTRunnerEventsListener`: a short run finishes replaying before the augmenter hands us the console, so the + events are already fired and missed. +- **Whatever our own `createImportActions` returns must survive the RunTab toolbar snapshot** — `appendAdditionalActions` + is routed into the gear submenu instead and would not. +- **`createImportActions` deliberately does not call `super`.** Super's entries (`ImportTestsGroup`, + `ImportTestsFromFileAction`) open a saved XML through the platform import — a console with none of our UI; the + history and *Replay* groups are our counterparts. The array is the only writable seam onto the visible toolbar row + (actions land there without `PREFERRED_PLACE`) and is laid out right-to-left: listed first = furthest right, always + after the platform's own actions. The platform's export is gone the same way — `ToolbarPanel` builds + `ExportTestResultsAction` only for a real `RunConfiguration`, and `getConfiguration()` answers the replay profile. +- **What the platform put on the test toolbar cannot be moved or removed.** `ToolbarPanel` builds those actions + inline (no ids, no extension point, no `CustomActionsSchema` entry) and copies its groups into the arrays `RunTab` + rebuilds the toolbar from — mutating the live groups afterwards changes nothing the user sees. +- **Every executor but Run and Debug is hidden behind the "More Run/Debug" submenu** (`ExecutorRegistryImpl`; the + `executor.actions.submenu` registry key is global). The actions are shared instances, so copying *Run with + Coverage* into a popup only duplicates the submenu entry — tried in the test tree's popup and reverted. +- **The Coverage view's *Always select opened element* cannot work for a file-based view**: it matches the PSI *leaf* + under the caret against nodes holding `PsiFile`/`PsiDirectory`, so nothing ever matches, and everything involved is + `@ApiStatus.Internal`. Hence `TestoCoverageSelectOpenedFile`: our own toggle, following the editor off the message + bus and selecting via `TreeUtil.promiseSelect` on the tree taken from the toolbar's target component. +- **The Coverage view's tree has no extensible context menu** — `CoverageView.createPopupGroup` is private, built + inline, and holds `EditSource` alone. Anything acting on the selected row goes on the toolbar instead + (`createExtraToolbarActions`, `@Experimental`) and reads the selection as `CommonDataKeys.NAVIGATABLE`. +- **A column's width comes from `getPercentage(column, rootNode)`** — a non-percentage column must still answer there + (the *Tests* column returns its count) or the view sizes it for "100% (1234/1234)". The user's own width then sticks + in `CoverageViewManager.StateBean.myColumnSize` whenever the column count matches. +- **`CoverageViewExtension` is instantiated three times per view** (`CoverageView`, `CoverageTableModel`, + `CoverageViewTreeStructure`), so no instance sees another's fields — anything `getPercentage` needs must be derived + from the bundle, not remembered from `createColumnInfos`. +- **The editor highlighter is installed from `applyTestoCoverage`, not from the annotator**: `onSuiteChosen` fires + only on reload/close, never on the session's first `chooseSuitesBundle` — which left the first coverage run of an + IDE session unpainted. - **`ConsoleFolding` instances are shared across consoles** and get no per-console reset; both foldings track state in a `ThreadLocal` and clear it on the first non-frame line. - **Debug installs channel tabs itself** (`TestoDebugRunner`): the augmenter's descriptor lookup misses debug - sessions. `TestoConsoleProperties.channelsInstalled` guards against a double install. The debug session also - gets the `Testo.RerunSplit` action handed to it explicitly, since it does not use `RunTab.TopToolbar`. -- **Group names are a list in the model, a comma-separated string only in the editor.** `TestoRunnerSettings` - persists `groups`/`excludeGroups` via `@XCollection`; the comma lives in the editor's text field (`parseNames`/ - `formatNames`) and in the pre-list persisted form. `migrateLegacyNames` folds an old `group="a,b"` attribute into - the list and clears it, and `TestoRunConfigurationSettings.getTestoRunnerSettings` calls it — that is the first - point after deserialization every reader goes through. `TestoRunnerSettingsSerializationTest` pins the XML shape. + sessions. `TestoConsoleProperties.channelsInstalled` guards against a double install. +- **The debug toolbar's restart button is the overridden platform `Rerun`** (`TestoAwareRerunAction`), and it must + stay visible on debug tabs. The split button that normally replaces it in SPLIT_BUTTON mode lives on + `RunTab.TopToolbar`, which the debug tab does not use — so the action's hide branch is gated on *not* the Debug + executor. A descriptor's restart actions are constructor-only, so this cannot be fixed by handing the debug + `RunContentDescriptor` its own. +- **Suite and group names are lists everywhere.** `TestoRunnerSettings` persists `suites`/`groups`/`excludeGroups` + via `@XCollection`, the editor shows them as tags (`TestoTagsField`), and a name is never split on anything — the + comma lives only in the legacy persisted form. `migrateLegacyNames` folds that form in, called from + `TestoRunConfigurationSettings.getTestoRunnerSettings` — the first point every reader passes after deserialization. + `TestoRunnerSettingsSerializationTest` pins the XML shape. - **`rerunFilters` is `@Transient`** — it lives only on the throwaway clone a "rerun failed" launch creates, and that clone's scope is reset to `ConfigurationFile` so no scope flag narrows the filters away. - **`TestoRunConfigurationType.ID` is a pinned literal**, not `::class.simpleName`: renaming the class must not invalidate users' saved run configurations. -- **`TestoDataProvidersIndex.getVersion()`** must be bumped whenever indexing logic or the attribute FQN changes, - or stale on-disk indexes silently stay empty. +- **`getVersion()` of both file-based indexes** (`TestoDataProvidersIndex`, `TestoGroupsIndex`) must be bumped whenever + indexing logic changes — for the groups index that includes `TestoRunConfigurationProducer.extractGroupNames`, which + it indexes through — or stale on-disk indexes silently stay empty. - **The run-configuration editor calls the parent editor's `resetEditorFrom`/`applyEditorTo` reflectively** (they are not public on `PhpTestRunConfigurationEditor`) and swallows `ReadOnlyModificationException`. +- **Parallel is injected into the PHP editor's own form.** The *Test Runner options* row is a one-row + `GridLayoutManager`, so `injectParallelRow` finds it by its label (the bundle string carries a `&` mnemonic the + rendered label does not), rebuilds it with a second row and re-adds the children with their own constraints — that + keeps the label column shared. Falls back to a row of our own below the panel if the PhpStorm form changes. - **`TestoFrameworkType.getComposerPackageNames()` currently returns `arrayOf("php")`**, not `testo/testo` — deliberate (the commented-out line records the intent); changing it affects framework auto-detection. - **`TestoTestRunLineMarkerProviderInfo.shouldReplace = true`** so Testo's gutter icon wins over PhpStorm's diff --git a/build.gradle.kts b/build.gradle.kts index 85f5ab2f..23442183 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,8 @@ plugins { group = providers.gradleProperty("pluginGroup").get() -// Which PHP coverage API to build against — see the `phpApi` comment in gradle.properties. +// Which platform variant to build — see the `phpApi` comment in gradle.properties. Selects the platform version, +// since/until range and per-platform modules; the two artifacts no longer differ in source (coverage is now on public API). val phpApi = providers.gradleProperty("phpApi").get() // The Marketplace keys uploads by version and rejects a second one carrying a version it already has, so the two @@ -29,7 +30,6 @@ fun apiProperty(name: String) = providers.gradleProperty("$name.$phpApi") // Set the JVM language level used to build the project. kotlin { jvmToolchain(21) - sourceSets["main"].kotlin.srcDir("src/php$phpApi/kotlin") } // Configure project's dependencies diff --git a/gradle.properties b/gradle.properties index 65b3393b..b9b7dc2f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,11 +6,14 @@ pluginRepositoryUrl = https://github.com/j-plugins/testo-plugin # SemVer format -> https://semver.org pluginVersion = 2026.5 -# PHP moved its coverage classes from com.jetbrains.php.phpunit.coverage to com.intellij.php.coverage in 2026.2, so a -# single artifact cannot cover both. Everything below is therefore declared twice, suffixed by the target API, and -# `phpApi` picks the variant: `./gradlew buildPlugin -PphpApi=252` for 2025.2-2026.1, the default for 2026.2+. -# Only src/php/kotlin differs between the two — it holds typealiases to whichever package is current. +# Two artifacts because 2026.2 changed the platform module topology, not the source (which is now identical for both): +# smRunner/testRunner/ui.jcef split out of the monolith and must be requested by name on 262, but that name does not +# resolve on 252 where they are still bundled (see platformBundledModules below). So every platform-dependent property +# is declared twice, suffixed by the target API, and `phpApi` picks the variant: +# `./gradlew buildPlugin -PphpApi=252` for 2025.2-2026.1, the default for 2026.2+. # `publishPlugin` releases every API listed in `phpApis`, re-entering Gradle once per variant. +# (Coverage used to be the reason — a package moved to an internal module — but coverage now runs on public API and no +# longer references either package, so it no longer forces the split.) phpApi = 262 phpApis = 252,262 @@ -44,8 +47,8 @@ platformBundledPlugins = # 2026.2 split smRunner/testRunner out of the monolith, so they have to be requested explicitly there. # intellij.platform.ui.jcef is 262-only for the same reason: on 252 JCEF is still part of the monolith, and asking # for the module by name fails to resolve. -platformBundledModules.252 = intellij.platform.coverage,intellij.spellchecker -platformBundledModules.262 = intellij.platform.coverage,intellij.spellchecker,intellij.platform.smRunner,intellij.platform.testRunner,intellij.platform.ui.jcef +platformBundledModules.252 = intellij.platform.coverage,intellij.platform.coverage.agent,intellij.spellchecker +platformBundledModules.262 = intellij.platform.coverage,intellij.platform.coverage.agent,intellij.spellchecker,intellij.platform.smRunner,intellij.platform.testRunner,intellij.platform.ui.jcef # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion = 9.5.0 diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt new file mode 100644 index 00000000..d21bae49 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt @@ -0,0 +1,64 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.editor.TestoCoverageEditorHighlighter +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.format.detectCoverageFormat +import com.intellij.coverage.CoverageDataManager +import com.intellij.coverage.CoverageRunner +import com.intellij.coverage.CoverageSuite +import com.intellij.coverage.CoverageSuitesBundle +import com.intellij.coverage.DefaultCoverageFileProvider +import com.intellij.openapi.project.Project +import java.nio.file.Files +import java.nio.file.Path + +/** One already-written coverage report to load: how Testo announced it plus where it landed on this machine. */ +data class TestoCoverageReport(val name: String?, val format: CoverageFormat?, val dataFile: Path) + +/** + * Loads already-written Testo coverage reports into the IDE with no process launch: one suite per + * report, all in **one** [CoverageSuitesBundle] handed to [CoverageDataManager.chooseSuitesBundle] — the platform then + * reads each file via [TestoCoverageRunner.loadCoverageData], merges the `ProjectData`s, opens the Coverage tool window + * and applies [TestoCoverageAnnotator]. `chooseSuitesBundle` rather than `coverageGathered`: the bundle's composition + * is the user's checkbox choice, not something the replace/merge option dialog should renegotiate. + * + * The suites are **not** registered with [CoverageDataManager] (no `addCoverageSuite`/`addExternalCoverageSuite`): those + * persist into `workspace.xml`, and the platform's reload then crashes on Windows when the saved absolute report + * path no longer exists — `readDataFileProviderAttribute` falls back to `Path.of(systemPath, absolutePath)`, which + * throws `InvalidPathException` on the second drive letter and breaks *all* coverage init. A chosen-but-unregistered + * bundle shows the same annotation, updates the per-test index, and never persists — the reports are transient anyway. + * + * Returns false when the coverage module is absent (the runner is registered only by `coverage.xml`) or no report was + * given; the format falls back to sniffing when unknown. Call on the EDT — `chooseSuitesBundle` opens UI. + */ +fun applyTestoCoverage(project: Project, reports: List): Boolean { + if (reports.isEmpty()) return false + val runner = CoverageRunner.getInstance(TestoCoverageRunner::class.java) ?: return false + val suites = reports.mapNotNull { report -> + val timestamp = runCatching { Files.getLastModifiedTime(report.dataFile).toMillis() }.getOrDefault(0L) + // The File ctor is the one present on both 252 and 262 — Path was added only on 262. + val provider = DefaultCoverageFileProvider(report.dataFile.toFile()) + val suite = TestoCoverageEngine.INSTANCE + .createCoverageSuite(report.name ?: "Testo coverage", project, runner, provider, timestamp) as? TestoCoverageSuite + ?: return@mapNotNull null + suite.format = report.format ?: detectCoverageFormat(report.dataFile) ?: CoverageFormat.CLOVER + suite + } + if (suites.isEmpty()) return false + // Before the bundle is handed over, not after: the highlighter paints on `coverageDataCalculated`, and that fires + // from inside chooseSuitesBundle. Its other install point, the annotator's onSuiteChosen, is not reached on the + // first bundle of a session at all — the platform calls it only when a bundle is reloaded or closed. + TestoCoverageEditorHighlighter.getInstance(project).install() + CoverageDataManager.getInstance(project).chooseSuitesBundle(CoverageSuitesBundle(suites.toTypedArray())) + return true +} + +/** Closes the active Testo bundle, if any — the "no reports checked" state. */ +fun closeTestoCoverage(project: Project) { + val manager = CoverageDataManager.getInstance(project) + manager.activeSuites().filter { it.coverageEngine is TestoCoverageEngine }.forEach { manager.closeSuitesBundle(it) } +} + +/** Whether a Testo coverage bundle is currently applied. */ +fun isTestoCoverageActive(project: Project): Boolean = + CoverageDataManager.getInstance(project).activeSuites().any { it.coverageEngine is TestoCoverageEngine } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt new file mode 100644 index 00000000..2d961e3b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt @@ -0,0 +1,190 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.editor.TestoCoverageEditorHighlighter +import com.github.xepozz.testo.coverage.format.LineTotals +import com.intellij.coverage.BaseCoverageAnnotator +import com.intellij.coverage.CoverageDataManager +import com.intellij.coverage.CoverageSuitesBundle +import com.intellij.coverage.RemappingCoverageAnnotator +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.rt.coverage.data.ClassData +import com.intellij.rt.coverage.data.LineData +import com.intellij.rt.coverage.data.ProjectData + +/** + * Answers the tool window and project view straight from the suite's [ProjectData] instead of from + * `SimpleCoverageAnnotator`'s cache. + * + * That cache is filled by a content-root walk which only descends into files the engine claims through + * `CoverageEngine.coverageProjectViewStatisticsApplicableTo` — `@ApiStatus.Internal`, defaulting to `false`, so a + * plugin staying on public API can never fill it and every lookup returns null. + * + * The view reads the annotator through two overload families that feed different parts of the UI: + * `DirectoryCoverageViewExtension.getChildrenNodes` calls the `PsiFile`/`PsiDirectory` ones (which rows exist) and + * `getPercentage` the `VirtualFile` ones (the statistics column), so both must be answered. The `PsiFile` one is + * overridden only to skip the interface default's canonicalization: the keys are the paths the runner resolved + * through the VFS, and a canonical path can differ from those. + */ +class TestoCoverageAnnotator(project: Project) : RemappingCoverageAnnotator(project) { + private val lock = Any() + private var indexedData: ProjectData? = null + private var index = Index(emptyMap(), emptyMap(), emptyMap()) + + private class Index( + val files: Map, + val dirs: Map, + // Files and directories share one map: a path is one or the other, and the platform has no branch-aware info type. + val branches: Map, + ) + + private class BranchStat { + var totalBranchCount: Int = 0 + var coveredBranchCount: Int = 0 + } + + override fun onSuiteChosen(newSuite: CoverageSuitesBundle?) { + super.onSuiteChosen(newSuite) + synchronized(lock) { + indexedData = null + index = Index(emptyMap(), emptyMap(), emptyMap()) + } + // The one hook that fires on closeSuitesBundle too (no CoverageSuiteListener event exists for a close), and the + // earliest coverage activity of a session — so it both installs the editor highlighter and clears it. + TestoCoverageEditorHighlighter.getInstance(project).install() + } + + override fun getFileCoverageInformationString( + psiFile: PsiFile, + currentSuite: CoverageSuitesBundle, + manager: CoverageDataManager, + ): String? { + val file = psiFile.virtualFile ?: return null + return getFileCoverageInformationString(psiFile.project, file, currentSuite, manager) + } + + override fun getFileCoverageInformationString( + project: Project, + file: VirtualFile, + currentSuite: CoverageSuitesBundle, + manager: CoverageDataManager, + ): String? { + val files = indexFor(currentSuite)?.files ?: return null + val info = files[key(file.path)] ?: files[key(file.canonicalPath ?: return null)] ?: return null + return getLinesCoverageInformationString(info) + } + + override fun getDirCoverageInformationString( + project: Project, + directory: VirtualFile, + currentSuite: CoverageSuitesBundle, + manager: CoverageDataManager, + ): String? { + val dirs = indexFor(currentSuite)?.dirs ?: return null + val info = dirs[key(directory.path)] ?: dirs[key(directory.canonicalPath ?: return null)] ?: return null + val filesInfo = getFilesCoverageInformationString(info) ?: return null + val linesInfo = getLinesCoverageInformationString(info) ?: return filesInfo + return "$filesInfo, $linesInfo" + } + + // A file the report lists without executable lines would otherwise read 100% — calcPercent answers a zero total that way. + override fun getLinesCoverageInformationString(info: BaseCoverageAnnotator.FileCoverageInfo): String? = + if (info.totalLineCount == 0) null else super.getLinesCoverageInformationString(info) + + /** Branch coverage of a file or of everything under a directory; null when the report carries no branches for it. */ + fun getBranchCoverageInformationString(file: VirtualFile, currentSuite: CoverageSuitesBundle): String? { + val branches = indexFor(currentSuite)?.branches ?: return null + val stat = branches[key(file.path)] ?: branches[key(file.canonicalPath ?: return null)] ?: return null + val percent = stat.coveredBranchCount * 100 / stat.totalBranchCount + return TestoBundle.message( + "testo.coverage.view.branches.covered", + percent, + stat.coveredBranchCount, + stat.totalBranchCount, + ) + } + + private fun indexFor(bundle: CoverageSuitesBundle): Index? { + val data = bundle.coverageData ?: return null + synchronized(lock) { + // RemappingCoverageAnnotator can swap the suite's data for a remapped copy, so compare identity, not content. + if (indexedData !== data) { + index = buildIndex(data, lineTotalsOf(bundle)) + indexedData = data + } + return index + } + } + + private fun lineTotalsOf(bundle: CoverageSuitesBundle): Map = + bundle.suites.filterIsInstance().flatMap { it.lineTotals.entries }.associate { it.toPair() } + + private fun buildIndex(data: ProjectData, lineTotals: Map): Index { + val files = HashMap() + val branches = HashMap() + for ((path, classData) in data.classes) { + // Data we did not build ourselves can hold a line-less ClassData, and fileInfoForCoveredFile NPEs on one. + if (classData.lines == null) continue + val info = fileInfoForCoveredFile(classData) ?: continue + val filePath = key(path) + files[filePath] = info + branchStatFor(classData)?.let { branches[filePath] = it } + } + // Reported tallies win over the lines in the data, and add the files that have no covered line at all. + for ((path, totals) in lineTotals) { + files[key(path)] = BaseCoverageAnnotator.FileCoverageInfo().apply { + totalLineCount = totals.total + coveredLineCount = totals.executed + } + } + return Index(files, aggregateDirs(files, branches), branches) + } + + private fun aggregateDirs( + files: Map, + branches: MutableMap, + ): Map { + val dirs = HashMap() + for ((filePath, info) in files) { + val branchStat = branches[filePath] + var dir = filePath.substringBeforeLast('/', "") + while (dir.isNotEmpty()) { + val aggregate = dirs.getOrPut(dir) { BaseCoverageAnnotator.DirCoverageInfo() } + aggregate.totalLineCount += info.totalLineCount + aggregate.totalFilesCount++ + if (info.coveredLineCount > 0) { + aggregate.coveredLineCount += info.coveredLineCount + aggregate.coveredFilesCount++ + } + if (branchStat != null) { + val branchAggregate = branches.getOrPut(dir) { BranchStat() } + branchAggregate.totalBranchCount += branchStat.totalBranchCount + branchAggregate.coveredBranchCount += branchStat.coveredBranchCount + } + dir = dir.substringBeforeLast('/', "") + } + } + return dirs + } + + private fun branchStatFor(classData: ClassData): BranchStat? { + val stat = BranchStat() + for (line in classData.lines ?: return null) { + val branchData = (line as? LineData)?.branchData ?: continue + stat.totalBranchCount += branchData.totalBranches + stat.coveredBranchCount += branchData.coveredBranches + } + return stat.takeIf { it.totalBranchCount > 0 } + } + + private fun key(path: String): String = + FileUtil.toSystemIndependentName(path).let { if (SystemInfo.isWindows) it.lowercase() else it } + + companion object { + fun getInstance(project: Project): TestoCoverageAnnotator = project.getService(TestoCoverageAnnotator::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt new file mode 100644 index 00000000..f8871cb0 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt @@ -0,0 +1,54 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoReportRef +import com.github.xepozz.testo.tests.console.resolveCoverageDataFile +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import java.nio.file.Path + +/** + * One report per format out of everything the run announced — a flag-requested report beats a `testo.php`-configured + * one of the same format (the IDE controls its path; the config one belongs to the project), later announcements beat + * earlier ones otherwise. Pure; keyed by the resolved local path, normalized like every other coverage key. + */ +fun dedupeCoverageByFormat( + resolved: List>, + flagPaths: Set, +): List> = + resolved + .groupBy { it.first.coverageFormat } + .mapNotNull { (format, group) -> + if (format == null) return@mapNotNull null + group.lastOrNull { TestoCoverageKeys.normalize(it.second.toString()) in flagPaths } ?: group.last() + } + +/** + * On process exit: applies the run's announced coverage reports through the same [applyTestoCoverage] the grouped + * button uses, honoring its checkboxes — no click needed. + */ +internal fun autoApplyCoverage(project: Project, props: TestoConsoleProperties, flagLocalPaths: List) { + ApplicationManager.getApplication().executeOnPooledThread { + val mapToLocal: (String) -> String? = { runCatching { props.pathMapper.getLocalPath(it) }.getOrNull() } + val writtenAfter = props.reportStore.runStartedAt + val resolved = props.reportStore.coverage().mapNotNull { ref -> + resolveCoverageDataFile(ref, project, mapToLocal, writtenAfter)?.let { ref to it } + } + val flagKeys = flagLocalPaths.map { TestoCoverageKeys.normalize(it.toString()) }.toSet() + // Filter by the checkboxes before the per-format dedup, not after: otherwise an unchecked flag report wins the + // dedup and is then discarded, dropping a format whose checked testo.php-configured report should have applied. + val checked = resolved.filter { props.reportStore.isCoverageChecked(it.first.path) } + val chosen = dedupeCoverageByFormat(checked, flagKeys) + .map { (ref, path) -> TestoCoverageReport(ref.name, ref.coverageFormat, path) } + if (chosen.isEmpty()) return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater({ applyTestoCoverage(project, chosen) }, project.disposed) + } +} + +/** The local file each enabled flag makes Testo write — for coverage-xml, the `index.xml` the loader consumes. */ +internal fun flagLocalDataFiles(flags: List>): List = flags.map { (format, local) -> + val path = Path.of(local) + if (format == CoverageFormat.COVERAGE_XML) path.resolve("index.xml") else path +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt index d20c7522..ac1b61c3 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt @@ -1,58 +1,117 @@ package com.github.xepozz.testo.coverage +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.format.LineTotals import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.intellij.coverage.CoverageAnnotator +import com.intellij.coverage.CoverageEngine import com.intellij.coverage.CoverageFileProvider import com.intellij.coverage.CoverageRunner import com.intellij.coverage.CoverageSuite +import com.intellij.coverage.CoverageSuitesBundle +import com.intellij.coverage.BaseCoverageSuite +import com.intellij.coverage.view.CoverageViewExtension import com.intellij.execution.configurations.RunConfigurationBase import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.jetbrains.php.lang.psi.PhpFile - +/** + * Testo coverage on 100% public platform API — no `com.intellij.php.coverage.*` (internal, closed to third-party + * plugins) and no deprecated `com.jetbrains.php.phpunit.coverage.*`. The report path is IDE-managed, so we pass it to + * the CLI and read it back where the IDE expects it (see [TestoCoverageProgramRunner]). + */ class TestoCoverageEnabledConfiguration( - configuration: TestoRunConfiguration -) : CoverageEnabledConfiguration(configuration, CoverageRunner.getInstance(PhpUnitCoverageRunner::class.java)) { + configuration: TestoRunConfiguration, +) : CoverageEnabledConfiguration(configuration, CoverageRunner.getInstance(TestoCoverageRunner::class.java)) { override fun coverageFileNameSeparator(): String = "@" - - // The report path is left to the platform default (CoverageEnabledConfiguration.createCoverageFile()), an - // IDE-managed path under /coverage/@.xml — same convention as the PhpUnit/Codeception - // engines. We pass that path to Testo via `--coverage-clover=` (see TestoCoverageProgramRunner), so the tool - // writes the Clover report exactly where the IDE reads it back, instead of a fixed runtime/ dir inside the project. } /** - * The report path is IDE-managed (under [com.intellij.openapi.application.PathManager.getSystemPath]), so the platform's - * default delete-on-disk confirmation never fires. We still skip deletion: the report is regenerated each run at the - * same path, so there is nothing to clean up. + * Carries the parsed report's format-dependent side-data — the format (which decides the CLI flag and how the runner + * reads the file) and whether it holds branch data. + * Deletion is a no-op: the report is regenerated at the same IDE-managed path each run, so there is nothing to clean. */ -class TestoCoverageSuite( - name: String, - project: Project, - coverageRunner: CoverageRunner, - fileProvider: CoverageFileProvider, - timeStamp: Long, -) : PhpCoverageSuite(name, project, coverageRunner, fileProvider, timeStamp) { +class TestoCoverageSuite : BaseCoverageSuite { + var format: CoverageFormat = CoverageFormat.CLOVER + + /** + * Per-file line tallies keyed like the `ClassData` entries, for reports that state how many executable lines a file + * has without saying which they are (coverage-xml). Counting the `ProjectData` lines would call every such file + * fully covered, since only executed lines are in there. + */ + var lineTotals: Map = emptyMap() + private set + + private var branchCoverage: Boolean = false + + constructor() : super() + + constructor( + name: String, + project: Project, + coverageRunner: CoverageRunner, + fileProvider: CoverageFileProvider, + timeStamp: Long, + ) : super(name, project, coverageRunner, fileProvider, timeStamp) + + fun applyParsed(hasBranches: Boolean, lineTotals: Map) { + this.branchCoverage = hasBranches + this.lineTotals = lineTotals + } + + override fun isBranchCoverage(): Boolean = branchCoverage + + override fun getCoverageEngine(): CoverageEngine = TestoCoverageEngine.INSTANCE + override fun deleteCachedCoverageData() = Unit } -class TestoCoverageEngine : PhpUnitCoverageEngine() { - override fun isApplicableTo(conf: RunConfigurationBase<*>) = conf is TestoRunConfiguration +class TestoCoverageEngine : CoverageEngine() { + override fun getPresentableText(): String = "Testo" - override fun createCoverageEnabledConfiguration(conf: RunConfigurationBase<*>) = + override fun isApplicableTo(conf: RunConfigurationBase<*>): Boolean = conf is TestoRunConfiguration + + override fun createCoverageEnabledConfiguration(conf: RunConfigurationBase<*>): CoverageEnabledConfiguration = TestoCoverageEnabledConfiguration(conf as TestoRunConfiguration) override fun createCoverageSuite( name: String, project: Project, - coverageRunner: CoverageRunner, + runner: CoverageRunner, fileProvider: CoverageFileProvider, - timeStamp: Long, - config: CoverageEnabledConfiguration - ): CoverageSuite? { - if (config is TestoCoverageEnabledConfiguration) { - return TestoCoverageSuite(name, project, coverageRunner, fileProvider, timeStamp) - } + timestamp: Long, + ): CoverageSuite = TestoCoverageSuite(name, project, runner, fileProvider, timestamp) + + override fun createCoverageSuite( + name: String, + project: Project, + runner: CoverageRunner, + fileProvider: CoverageFileProvider, + timestamp: Long, + config: CoverageEnabledConfiguration, + ): CoverageSuite? = + if (config is TestoCoverageEnabledConfiguration) TestoCoverageSuite(name, project, runner, fileProvider, timestamp) + else null + + override fun createEmptyCoverageSuite(coverageRunner: CoverageRunner): CoverageSuite = TestoCoverageSuite() + + override fun getCoverageAnnotator(project: Project): CoverageAnnotator = TestoCoverageAnnotator.getInstance(project) + + override fun coverageEditorHighlightingApplicableTo(psiFile: PsiFile): Boolean = psiFile is PhpFile + + override fun acceptedByFilters(psiFile: PsiFile, suite: CoverageSuitesBundle): Boolean = true + + // Must equal the ClassData keys the runner stored (VirtualFile.getPath()): the editor gutter does an exact + // getClassData(getQualifiedNames(file)) lookup. canonicalPath resolves symlinks and can diverge from the key. + override fun getQualifiedNames(sourceFile: PsiFile): Set = + sourceFile.virtualFile?.path?.let { setOf(it) } ?: emptySet() + + override fun createCoverageViewExtension(project: Project, suiteBundle: CoverageSuitesBundle): CoverageViewExtension = + TestoCoverageViewExtension(project, TestoCoverageAnnotator.getInstance(project), suiteBundle) - return super.createCoverageSuite(name, project, coverageRunner, fileProvider, timeStamp, config) + companion object { + val INSTANCE = TestoCoverageEngine() } } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt index 679b9b0c..36146fcd 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -1,68 +1,154 @@ package com.github.xepozz.testo.coverage +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.tests.TestoConsoleProperties import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.intellij.coverage.CoverageRunnerData +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView +import com.intellij.execution.configurations.ConfigurationInfoProvider +import com.intellij.execution.configurations.ParametersList import com.intellij.execution.configurations.RunProfile -import com.intellij.execution.configurations.RuntimeConfigurationError import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.execution.configurations.RuntimeConfigurationError +import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.GenericProgramRunner +import com.intellij.execution.runners.RunContentBuilder +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.remote.RemoteSdkAdditionalData +import com.intellij.util.PathMappingSettings +import com.intellij.util.PathUtil import com.jetbrains.php.config.commandLine.PhpCommandSettings +import com.jetbrains.php.config.commandLine.PhpCommandSettingsBuilder import com.jetbrains.php.config.interpreters.PhpInterpreter import com.jetbrains.php.debug.xdebug.options.XdebugConfigurationOptionsManager import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import com.jetbrains.php.run.PhpConfigurationOption -import com.jetbrains.php.run.PhpRunConfigurationHolder +import com.jetbrains.php.run.remote.PhpRemoteInterpreterManager -open class TestoCoverageProgramRunner : PhpCoverageRunner() { +/** + * Runs a Testo configuration under the Coverage executor. Extends the public [GenericProgramRunner] instead of the + * internal `com.intellij.php.coverage.PhpCoverageRunner`: [doExecute] reproduces that base's Xdebug flow (resolve the + * IDE-managed report path, build the command, attach the platform to the process so it loads coverage on termination + * via [TestoCoverageRunner]) on public PHP execution API alone. + */ +open class TestoCoverageProgramRunner : GenericProgramRunner() { companion object { const val EXECUTOR_ID: String = "Coverage" const val RUNNER_ID: String = "TestoCoverageRunner" } - override fun canRun(executorId: String, profile: RunProfile) = + override fun getRunnerId(): String = RUNNER_ID + + override fun canRun(executorId: String, profile: RunProfile): Boolean = executorId == EXECUTOR_ID && profile is TestoRunConfiguration - // Pass the IDE-managed report path to the CLI (mirrors PhpUnit's createCoverageArguments) so Testo writes the Clover - // XML exactly where the IDE reads it back. `targetCoverage` is the remote-mapped path derived from - // CoverageEnabledConfiguration.getCoverageFilePath() by PhpCoverageRunner. Testo exposes `--coverage-clover=` - // (Symfony Console, VALUE_REQUIRED). Fall back to the bare `--coverage` when no path is provided. - override fun createCoverageArguments(targetCoverage: String?) = - if (targetCoverage.isNullOrEmpty()) mutableListOf("--coverage") - else mutableListOf("--coverage-clover=$targetCoverage") + // The Coverage executor needs CoverageRunnerData so the platform threads RunnerSettings through to attachToProcess. + override fun createConfigurationData(settingsProvider: ConfigurationInfoProvider): RunnerSettings = CoverageRunnerData() - override fun getRunnerId(): String = RUNNER_ID + override fun doExecute(state: RunProfileState, env: ExecutionEnvironment): RunContentDescriptor? { + FileDocumentManager.getInstance().saveAllDocuments() + val runConfiguration = env.runProfile as? TestoRunConfiguration + ?: throw ExecutionException(TestoBundle.message("testo.coverage.run.unsupported.profile")) + val interpreter = runConfiguration.interpreter + ?: throw ExecutionException(PhpCommandSettingsBuilder.getInterpreterNotFoundError()) - override fun createState( - env: ExecutionEnvironment, - interpreter: PhpInterpreter, - runConfigurationHolder: PhpRunConfigurationHolder<*>, - coverageArguments: MutableList, - localCoverage: String, - targetCoverage: String - ): RunProfileState? { - val runConfiguration = runConfigurationHolder.runConfiguration as TestoRunConfiguration + // Kept for its IDE-managed base path alone — loading no longer goes through CoverageHelper. + val coverageConfiguration = CoverageEnabledConfiguration.getOrCreate(runConfiguration) + val localCoverage = coverageConfiguration.coverageFilePath + val settings = runConfiguration.testoSettings.getTestoRunnerSettings() + val flags = coverageFlagLocalPaths(settings, localCoverage) + val reportArguments = when { + // No base path (runner missing) or every report unchecked: a bare --coverage still makes any + // testo.php-configured writer collect, and the announce path picks the reports up. + flags.isEmpty() -> listOf("--coverage") + else -> flags.map { (format, local) -> + coverageFlagFor(format, toTargetPath(runConfiguration, interpreter, local)) + } + } + val coverageArguments = reportArguments + extraCoverageArguments(settings) val command = createTestoCoverageCommand( runConfiguration, interpreter, coverageArguments, localCoverage, - targetCoverage, + localCoverage?.takeIf { it.isNotEmpty() }?.let { toTargetPath(runConfiguration, interpreter, it) }, ) - runConfiguration.checkConfiguration() - return runConfiguration.getState(env, command, null) + + val profileState = runConfiguration.getState(env, command, null) ?: return null + val executionResult = profileState.execute(env.executor, this) ?: return null + + // The platform's CoverageHelper loads exactly one file; a Testo run can produce several reports (flags plus + // testo.php writers), so termination triggers our own merged apply instead. + val flagDataFiles = flagLocalDataFiles(flags) + val props = (executionResult.executionConsole as? SMTRunnerConsoleView)?.properties as? TestoConsoleProperties + // Handed over rather than kept here: the run archive dedupes the same way, and it only sees the properties. + props?.coverageFlagPaths = flagDataFiles + executionResult.processHandler.addProcessListener(object : ProcessAdapter() { + override fun processTerminated(event: ProcessEvent) { + autoApplyCoverage(runConfiguration.project, props ?: return, flagDataFiles) + } + }) + return RunContentBuilder(executionResult, env).showRunContent(env.contentToReuse) + } + + /** The enabled formats with the local file/directory each one writes, derived from the IDE-managed base path. */ + fun coverageFlagLocalPaths(settings: TestoRunnerSettings, localCoverage: String?): List> { + if (localCoverage.isNullOrEmpty()) return emptyList() + val stem = localCoverage.removeSuffix(".xml") + return buildList { + if (settings.coverageClover) add(CoverageFormat.CLOVER to "$stem-clover.xml") + if (settings.coverageCobertura) add(CoverageFormat.COBERTURA to "$stem-cobertura.xml") + if (settings.coverageXml) add(CoverageFormat.COVERAGE_XML to "$stem-coverage-xml") + } + } + + /** + * The analysis level and the configuration's coverage-only options — everything a Coverage run adds beyond the + * report flags. + */ + fun extraCoverageArguments(settings: TestoRunnerSettings): List = buildList { + resolveCoverageLevel(settings)?.let { add("--coverage-level=$it") } + addAll(ParametersList.parse(settings.coverageOptions)) + } + + /** + * The `--coverage-level` to send, or null for none. An explicit choice wins. On *auto* the level is normally left + * to testo.php — except when branch coverage is both achievable and carried: the Xdebug engine (PCOV collects + * lines only) together with a Cobertura report (the format that stores branch data). Then auto means branch. + */ + fun resolveCoverageLevel(settings: TestoRunnerSettings): String? { + val level = settings.coverageLevel.trim() + if (level.isNotEmpty() && level != TestoRunnerSettings.COVERAGE_LEVEL_AUTO) return level + if (settings.coverageEngine == CoverageEngine.XDEBUG && settings.coverageCobertura) return "branch" + return null + } + + fun coverageFlagFor(format: CoverageFormat, targetCoverage: String): String = when (format) { + CoverageFormat.CLOVER -> "--coverage-clover=$targetCoverage" + CoverageFormat.COBERTURA -> "--coverage-cobertura=$targetCoverage" + CoverageFormat.COVERAGE_XML -> "--coverage-xml=$targetCoverage" } fun createTestoCoverageCommand( runConfiguration: TestoRunConfiguration, interpreter: PhpInterpreter, coverageArguments: List, - localCoverage: String, - targetCoverage: String + localCoverage: String?, + targetCoverage: String?, ): PhpCommandSettings { val command = runConfiguration.createCommand( interpreter, - mutableMapOf(), + mutableMapOf(), coverageArguments.toMutableList(), true, ) @@ -82,4 +168,22 @@ open class TestoCoverageProgramRunner : PhpCoverageRunner() { return command } + + private fun setAdditionalMapping(localCoverage: String?, targetCoverage: String?, command: PhpCommandSettings) { + if (!localCoverage.isNullOrEmpty() && !targetCoverage.isNullOrEmpty()) { + command.setAdditionalMapping( + PathMappingSettings.PathMapping(PathUtil.getParentPath(localCoverage), PathUtil.getParentPath(targetCoverage)), + ) + } + } + + private fun toTargetPath(runConfiguration: TestoRunConfiguration, interpreter: PhpInterpreter, localCoverage: String): String { + val data = interpreter.phpSdkAdditionalData + if (data is RemoteSdkAdditionalData) { + PhpRemoteInterpreterManager.getInstance()?.let { manager -> + return manager.createPathMappings(runConfiguration.project, data).convertToRemote(localCoverage) + } + } + return localCoverage + } } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt new file mode 100644 index 00000000..ab55dc98 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt @@ -0,0 +1,54 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.format.BranchCoverage +import com.github.xepozz.testo.coverage.format.ParsedReport +import com.intellij.rt.coverage.data.LineData +import com.intellij.rt.coverage.data.ProjectData + +/** + * Builds the platform coverage model from a parsed report: one `ClassData` per file, lines laid out in a + * number-indexed array. The key comes from [keyFor], which the runner uses to resolve the report path to the matching + * `VirtualFile.getPath()` so [com.github.xepozz.testo.coverage.TestoCoverageAnnotator] can look it up by the same path. + * + * Branch data is approximate by construction — Cobertura reports only `covered/total`, not *which* outcomes — so a + * two-way line becomes a [com.intellij.rt.coverage.data.JumpData] and an n-way line a + * [com.intellij.rt.coverage.data.SwitchData]; touching the default slot only when fully covered keeps a fully-covered + * decision line green rather than partial. + */ +fun ParsedReport.toProjectData(keyFor: (String) -> String = { it }): ProjectData { + val projectData = ProjectData() + for (file in files) { + val executable = file.lines.filter { it.line >= 0 } + // No entry at all rather than one with null lines: coverage-xml lists files it has no covered line for, and + // the platform reads `ClassData.getLines()` without a null check. + if (executable.isEmpty()) continue + val classData = projectData.getOrCreateClassData(keyFor(file.filePath)) + val lines = arrayOfNulls(executable.maxOf { it.line } + 1) + for (lc in executable) { + val lineData = LineData(lc.line, null) + lineData.setHits(lc.hits) + lc.branch?.let { applyBranch(lineData, it) } + lines[lc.line] = lineData + } + classData.setLines(lines) + } + return projectData +} + +private fun applyBranch(line: LineData, branch: BranchCoverage) { + val total = branch.total + if (total <= 0) return + val covered = branch.covered.coerceIn(0, total) + if (total == 2) { + val jump = line.addJump(0) + if (covered >= 1) jump.touchTrueHit() + if (covered >= 2) jump.touchFalseHit() + } else { + val switch = line.addSwitch(0, IntArray(total) { it }) + for (i in 0 until covered) switch.touch(i) + if (covered >= total) switch.touch(-1) + } + // getJumps()/getSwitches() (read by getStatus/getBranchData) return the array fields, null until fillArrays swaps + // the builder lists into them. + line.fillArrays() +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt new file mode 100644 index 00000000..d1a9d9a5 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt @@ -0,0 +1,60 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.format.CoverageParseException +import com.github.xepozz.testo.coverage.format.parseCoverageReport +import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex +import com.intellij.coverage.CoverageEngine +import com.intellij.coverage.CoverageLoadErrorReporter +import com.intellij.coverage.CoverageLoadingResult +import com.intellij.coverage.CoverageRunner +import com.intellij.coverage.CoverageSuite +import com.intellij.coverage.FailedCoverageLoadingResult +import com.intellij.coverage.SuccessCoverageLoadingResult +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.vfs.LocalFileSystem +import java.io.File + +/** + * Loads a Testo coverage report into the platform model. Replaces the internal `com.intellij.php.coverage` + * `PhpUnitCoverageRunner`; parsing is delegated to the format-neutral parsers ([parseCoverageReport]) and the model + * built by [toProjectData]. The report's format is taken from the suite when known, else sniffed off the file. + */ +class TestoCoverageRunner : CoverageRunner() { + override fun getId(): String = ID + + override fun getPresentableName(): String = "Testo" + + override fun getDataFileExtension(): String = "xml" + + override fun acceptsCoverageEngine(engine: CoverageEngine): Boolean = engine is TestoCoverageEngine + + // The File overload is overridable on both platforms; 262's Path overload delegates to it, 252 has only this one. + override fun loadCoverageData( + sessionDataFile: File, + baseCoverageSuite: CoverageSuite?, + reporter: CoverageLoadErrorReporter, + ): CoverageLoadingResult { + val suite = baseCoverageSuite as? TestoCoverageSuite + return try { + val report = parseCoverageReport(sessionDataFile.toPath(), suite?.format) + suite?.project?.let { TestoCoverageByTestIndex.getInstance(it).update(report.perTest) } + // Key each ClassData by the resolved VirtualFile path so it matches how TestoCoverageAnnotator looks files up. + val lfs = LocalFileSystem.getInstance() + val resolvePath = { path: String -> lfs.findFileByPath(path)?.path ?: path } + val projectData = report.toProjectData(resolvePath) + val lineTotals = report.files.mapNotNull { file -> file.totals?.let { resolvePath(file.filePath) to it } } + suite?.applyParsed(report.hasBranches, lineTotals.toMap()) + LOG.info("Testo coverage loaded: ${report.format} ${projectData.classes.size} files from $sessionDataFile") + SuccessCoverageLoadingResult(projectData) + } catch (e: Exception) { + LOG.warn("Failed to load Testo coverage from $sessionDataFile", e) + reporter.reportError(e) + FailedCoverageLoadingResult(e, true) + } + } + + companion object { + const val ID: String = "TestoCoverageRunner" + private val LOG = Logger.getInstance(TestoCoverageRunner::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSelectOpenedFile.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSelectOpenedFile.kt new file mode 100644 index 00000000..de394d1b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSelectOpenedFile.kt @@ -0,0 +1,92 @@ +package com.github.xepozz.testo.coverage + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiFile +import com.intellij.ui.tree.TreeVisitor +import com.intellij.util.ui.tree.TreeUtil +import java.lang.ref.WeakReference +import javax.swing.JTree + +/** + * Selects the file open in the editor in the Coverage view's tree — our answer to the platform's *Always select + * opened element*, which cannot work here. + * + * That one hands `CoverageViewExtension.getElementToSelect` the PSI **leaf** under the caret and then looks for a tree + * node whose value equals it; every node of a directory-based coverage view holds a file or a directory, so nothing + * ever matches. The mapper is `@ApiStatus.Internal`, and so are the view's own select call and its tree, so the fix is + * to do the walk ourselves: the tree is reached through the toolbar's target component, and `TreeUtil.promiseSelect` + * expands the async model along the way. + */ +@Service(Service.Level.PROJECT) +class TestoCoverageSelectOpenedFile(private val project: Project) : Disposable { + + // Whichever Coverage view last showed our toolbar. The service outlives any single view; the reference is weak so + // a closed view is collected, and a stale one is caught by isShowing. + @Volatile + private var tree: WeakReference? = null + + var enabled: Boolean + get() = PropertiesComponent.getInstance(project).getBoolean(KEY, false) + set(value) { + PropertiesComponent.getInstance(project).setValue(KEY, value, false) + if (value) selectCurrentFile() + } + + init { + project.messageBus.connect(this).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + if (enabled) select(event.newFile) + } + }, + ) + } + + /** Called from the toolbar action's update, which is the one place the view's own component is handed to us. */ + fun rememberTree(candidate: JTree) { + if (tree?.get() !== candidate) tree = WeakReference(candidate) + } + + private fun selectCurrentFile() { + select(FileEditorManager.getInstance(project).selectedEditor?.file) + } + + private fun select(file: VirtualFile?) { + if (file == null || file.isDirectory) return + val tree = tree?.get()?.takeIf { it.isShowing } ?: return + TreeUtil.promiseSelect(tree, TreeVisitor { path -> + ReadAction.compute { + when (val value = TreeUtil.getLastUserObject(AbstractTreeNode::class.java, path)?.value) { + is PsiFile -> if (value.virtualFile == file) TreeVisitor.Action.INTERRUPT else TreeVisitor.Action.SKIP_CHILDREN + // Descend only where the file can actually be, so no directory is expanded for nothing. + is PsiDirectory -> when { + VfsUtilCore.isAncestor(value.virtualFile, file, false) -> TreeVisitor.Action.CONTINUE + else -> TreeVisitor.Action.SKIP_CHILDREN + } + else -> TreeVisitor.Action.SKIP_CHILDREN + } + } + }) + } + + override fun dispose() = Unit + + companion object { + private const val KEY = "testo.coverage.view.selectOpenedFile" + + fun getInstance(project: Project): TestoCoverageSelectOpenedFile = + project.getService(TestoCoverageSelectOpenedFile::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt new file mode 100644 index 00000000..c1518910 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt @@ -0,0 +1,167 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.editor.TestoCoverageEditorHighlighter +import com.github.xepozz.testo.coverage.editor.TestoCoveringTestsGutter +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher +import com.github.xepozz.testo.coverage.perTest.testsUnder +import com.intellij.coverage.CoverageSuitesBundle +import com.intellij.icons.AllIcons +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.RightAlignedToolbarAction +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.actionSystem.ex.CustomComponentAction +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFileSystemItem +import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.FlowLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTree + +/** + * The Testo additions to the Coverage view toolbar, plugged in through the one public seam the view offers — + * `CoverageViewExtension.createExtraToolbarActions()` (`@ApiStatus.Experimental`, present on 252 and 262). + * + * Expand/Collapse are shared with the test tree's toolbar — see + * [com.github.xepozz.testo.tests.console.TestoTreeExpandAction]. + */ + +/** Switches the Testo editor gutter stripes on and off without touching the suite or the view. */ +internal class TestoCoverageHighlightToggleAction(private val project: Project) : ToggleAction( + TestoBundle.message("testo.coverage.view.toggle.highlight"), + null, + AllIcons.Actions.Show, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun isSelected(e: AnActionEvent): Boolean = + TestoCoverageEditorHighlighter.getInstance(project).highlightingEnabled + + override fun setSelected(e: AnActionEvent, state: Boolean) = + TestoCoverageEditorHighlighter.getInstance(project).setHighlightingEnabled(state) +} + +/** Switches the *Run covering tests* gutter icons on methods, functions and classes on and off. */ +internal class TestoCoveringTestsGutterToggleAction(private val project: Project) : ToggleAction( + TestoBundle.message("testo.coverage.view.toggle.gutters"), + null, + AllIcons.Toolwindows.ToolWindowRunWithCoverage, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun isSelected(e: AnActionEvent): Boolean = TestoCoveringTestsGutter.getInstance(project).enabled + + override fun setSelected(e: AnActionEvent, state: Boolean) { + TestoCoveringTestsGutter.getInstance(project).enabled = state + } +} + +/** + * *Select Opened File* — the working half of the platform's *Always select opened element*, which does nothing in a + * file-based coverage view (see [TestoCoverageSelectOpenedFile]). + * + * The update pass is also where the view's tree is handed over: the toolbar's target component is the coverage table, + * and the service that follows the editor has no other way to reach it. + */ +internal class TestoSelectOpenedFileAction(private val project: Project) : ToggleAction( + TestoBundle.message("testo.coverage.view.select.opened"), + null, + AllIcons.General.AutoscrollFromSource, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + super.update(e) + val component = e.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT) as? JComponent ?: return + UIUtil.findComponentOfType(component, JTree::class.java) + ?.let { TestoCoverageSelectOpenedFile.getInstance(project).rememberTree(it) } + } + + override fun isSelected(e: AnActionEvent): Boolean = TestoCoverageSelectOpenedFile.getInstance(project).enabled + + override fun setSelected(e: AnActionEvent, state: Boolean) { + TestoCoverageSelectOpenedFile.getInstance(project).enabled = state + } +} + +/** + * Runs the tests covering the selected row — a file's own, a directory's whole subtree. + * + * The selection is read as `CommonDataKeys.NAVIGATABLE`, which is what the view publishes for the selected node; the + * tree's context menu holds `EditSource` and nothing else, and is built inline with no id to extend. + */ +internal class TestoRunCoveringTestsAction(private val project: Project) : AnAction( + TestoBundle.message("testo.coverage.view.run.covering"), + null, + // Not the tool window icon the gutter toggle beside it wears — two buttons on one toolbar must not look alike. + AllIcons.General.RunWithCoverage, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + val tests = selectedTests(e) + e.presentation.isEnabled = tests.isNotEmpty() + e.presentation.text = when { + tests.isEmpty() -> TestoBundle.message("testo.coverage.view.run.covering") + else -> TestoBundle.message("testo.coverage.view.run.covering.count", tests.size) + } + } + + override fun actionPerformed(e: AnActionEvent) { + val item = selectedItem(e) ?: return + val tests = selectedTests(e) + if (tests.isEmpty()) return + TestoCoveringTestsLauncher.run(project, tests, TestoCoveringTestsLauncher.runName(item.name, tests.size)) + } + + private fun selectedTests(e: AnActionEvent): Set { + val item = selectedItem(e) ?: return emptySet() + val file = item.virtualFile ?: return emptySet() + return TestoCoverageByTestIndex.getInstance(project).data().testsUnder(file.path, file.isDirectory) + } + + private fun selectedItem(e: AnActionEvent): PsiFileSystemItem? = + ((e.getData(CommonDataKeys.NAVIGATABLE) as? AbstractTreeNode<*>)?.value) as? PsiFileSystemItem +} + +/** Non-clickable chips naming the report formats merged into the shown bundle — one per distinct format. */ +internal class TestoCoverageFormatBadgesAction( + private val bundle: CoverageSuitesBundle, +) : AnAction(), CustomComponentAction, RightAlignedToolbarAction, DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) = Unit + + override fun createCustomComponent(presentation: Presentation, place: String): JComponent { + val panel = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)) + panel.isOpaque = false + bundle.suites.filterIsInstance() + .map { it.format.id } + .distinct() + .forEach { panel.add(badge(it)) } + return panel + } + + private fun badge(text: String): JComponent = JBLabel(text).apply { + font = JBUI.Fonts.smallFont() + foreground = UIUtil.getContextHelpForeground() + border = JBUI.Borders.compound( + RoundedLineBorder(JBColor.border(), JBUI.scale(10)), + JBUI.Borders.empty(1, 6), + ) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt new file mode 100644 index 00000000..2909d718 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt @@ -0,0 +1,104 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.testsUnder +import com.intellij.coverage.CoverageBundle +import com.intellij.coverage.CoverageSuitesBundle +import com.intellij.coverage.view.DirectoryCoverageViewExtension +import com.intellij.coverage.view.ElementColumnInfo +import com.intellij.coverage.view.PercentageCoverageColumnInfo +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.ide.util.treeView.NodeDescriptor +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.project.Project +import com.intellij.util.ui.ColumnInfo + +/** + * The platform's file tree plus the Testo columns and toolbar. `Branches, %` is shown only for reports that carry + * branch data (cobertura); `Tests` — distinct covering tests per file, directories as the union — only when the shown + * bundle holds a coverage-xml suite *and* the per-test index has data, so neither column ever renders all-empty. + */ +class TestoCoverageViewExtension( + private val project: Project, + private val annotator: TestoCoverageAnnotator, + suitesBundle: CoverageSuitesBundle, +) : DirectoryCoverageViewExtension(project, annotator, suitesBundle) { + override fun createColumnInfos(): Array> { + val columns = mutableListOf>( + ElementColumnInfo(), + PercentageCoverageColumnInfo(LINES_COLUMN, CoverageBundle.message("table.column.name.statistics"), mySuitesBundle), + ) + if (mySuitesBundle.isBranchCoverage) { + val name = TestoBundle.message("testo.coverage.view.column.branches") + columns.add(PercentageCoverageColumnInfo(BRANCHES_COLUMN, name, mySuitesBundle)) + } + if (showsTests()) columns.add(TestsColumnInfo()) + return columns.toTypedArray() + } + + override fun getPercentage(columnIdx: Int, node: AbstractTreeNode<*>): String? { + // Also what the view sizes a column by, off the root node — so the Tests column must answer with a count and + // not fall through to the percentage string, which would size it for "100% (1234/1234)". + if (columnIdx == testsColumn()) return countFor(node)?.takeIf { it > 0 }?.toString() + if (columnIdx != BRANCHES_COLUMN) return super.getPercentage(columnIdx, node) + val file = extractFile(node) ?: return null + return annotator.getBranchCoverageInformationString(file, mySuitesBundle) + } + + /** + * Where the Tests column sits, or -1 when it is not shown. Worked out from the bundle rather than remembered from + * [createColumnInfos]: the view builds a *separate* extension instance for its columns, its tree structure and + * itself, so nothing one of them stores is visible to the one being asked here. + */ + private fun testsColumn(): Int = when { + !showsTests() -> -1 + mySuitesBundle.isBranchCoverage -> BRANCHES_COLUMN + 1 + else -> BRANCHES_COLUMN + } + + private fun showsTests(): Boolean = + hasPerTestData() && TestoCoverageByTestIndex.getInstance(project).data().testsByFile().isNotEmpty() + + /** Distinct covering tests of a node: the file's own set, a directory as the union over everything beneath it. */ + private fun countFor(node: NodeDescriptor<*>): Int? { + val file = (node as? AbstractTreeNode<*>)?.let { extractFile(it) } ?: return null + return TestoCoverageByTestIndex.getInstance(project).data().testsUnder(file.path, file.isDirectory).size + } + + // @Experimental (not @Internal) — the one public seam into the view's toolbar; verified present on 252 and 262. + // The tree's context menu is not a seam: `CoverageView.createPopupGroup` is private and holds `EditSource` alone, + // so "run the covering tests of this row" is offered from the toolbar, acting on the selection. + override fun createExtraToolbarActions(): List = listOf( + com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), + com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), + TestoSelectOpenedFileAction(project), + TestoCoverageHighlightToggleAction(project), + TestoCoveringTestsGutterToggleAction(project), + TestoRunCoveringTestsAction(project), + TestoCoverageFormatBadgesAction(mySuitesBundle), + ) + + // The index outlives coverage sessions (the code-vision lens reads it cold), so a clover-only bundle must not + // resurface the previous run's per-test counts — the column needs a coverage-xml suite in *this* bundle. + private fun hasPerTestData(): Boolean = + mySuitesBundle.suites.filterIsInstance().any { it.format == CoverageFormat.COVERAGE_XML } + + private inner class TestsColumnInfo : + ColumnInfo, String>(TestoBundle.message("testo.coverage.view.column.tests")) { + // One row is asked for per repaint and per sort comparison, and a directory means a walk of the whole map. + private val counts = HashMap, Int>() + + override fun valueOf(node: NodeDescriptor<*>): String? = count(node).takeIf { it > 0 }?.toString() + + override fun getComparator(): Comparator> = compareBy { count(it) } + + private fun count(node: NodeDescriptor<*>): Int = counts.getOrPut(node) { countFor(node) ?: 0 } + } + + companion object { + private const val LINES_COLUMN = 1 + private const val BRANCHES_COLUMN = 2 + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt new file mode 100644 index 00000000..4d95e9dd --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt @@ -0,0 +1,199 @@ +package com.github.xepozz.testo.coverage.editor + +import com.github.xepozz.testo.coverage.TestoCoverageEngine +import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys +import com.intellij.coverage.CoverageDataManager +import com.intellij.coverage.CoverageSuiteListener +import com.intellij.coverage.CoverageSuitesBundle +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.event.EditorFactoryEvent +import com.intellij.openapi.editor.event.EditorFactoryListener +import com.intellij.openapi.editor.impl.DocumentMarkupModel +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.rt.coverage.data.ClassData +import com.intellij.rt.coverage.data.LineData +import com.intellij.rt.coverage.data.ProjectData +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Paints per-line coverage in the editor gutter off the active Testo suite's `ProjectData` — the plugin's own stand-in + * for the platform's editor annotator, whose entry point (`CoverageEngine.createSrcFileAnnotator`) is + * `@ApiStatus.Internal` and closed to third-party engines. Same primitives the platform uses underneath: + * line highlighters in the document markup model carrying a gutter renderer. + * + * Lifecycle: installed lazily from [com.github.xepozz.testo.coverage.TestoCoverageAnnotator.onSuiteChosen] (the first + * coverage activity), then driven by [CoverageSuiteListener.coverageDataCalculated] — fired on the EDT once the report + * is parsed, so applying never loads data on the UI thread. Closing a suite fires no listener event, only the + * annotator's `onSuiteChosen`; that call routes back into [refresh], which finds no active Testo bundle and clears. + * + * Highlighters are `RangeMarker`s, so edits shift them with the code for free. A marker torn across two lines by an + * Enter in its middle is dropped — the run measured a line that no longer exists — while everything below keeps its + * colour at the new position. The platform instead drops every touched marker and restores from VCS history; that + * mapper is internal, and keeping the marker under ordinary typing serves the live-edit case better anyway. + */ +@Service(Service.Level.PROJECT) +class TestoCoverageEditorHighlighter(private val project: Project) : Disposable { + private val installed = AtomicBoolean() + + // EDT-confined. + private var generation = 0 + private var shownData: ProjectData? = null + private var shownIndex: Map = emptyMap() + private val annotated = HashMap() + + /** The user's "highlight in editor" switch (the Coverage view toolbar) — suite events keep firing, we just sit out. */ + var highlightingEnabled: Boolean = true + private set + + fun setHighlightingEnabled(value: Boolean) { + highlightingEnabled = value + refresh() + } + + private class AnnotatedDocument(val highlighters: MutableList, val listenerDisposable: Disposable) + + /** Idempotent; safe off the EDT. */ + fun install() { + if (installed.compareAndSet(false, true)) { + CoverageDataManager.getInstance(project).addSuiteListener(object : CoverageSuiteListener { + override fun coverageDataCalculated(bundle: CoverageSuitesBundle) = refresh() + }, this) + EditorFactory.getInstance().addEditorFactoryListener(object : EditorFactoryListener { + override fun editorCreated(event: EditorFactoryEvent) { + if (event.editor.project === project) annotate(event.editor.document) + } + + override fun editorReleased(event: EditorFactoryEvent) { + if (event.editor.project !== project) return + val document = event.editor.document + if (EditorFactory.getInstance().editors(document, project).noneMatch { it !== event.editor }) { + dropDocument(document) + } + } + }, this) + } + refresh() + } + + fun refresh() { + ApplicationManager.getApplication().invokeLater({ reconcile() }, project.disposed) + } + + private fun reconcile() { + val bundle = CoverageDataManager.getInstance(project).activeSuites() + .firstOrNull { it.coverageEngine is TestoCoverageEngine } + val gen = ++generation + if (bundle == null || !highlightingEnabled) { + clearAll() + return + } + // getCoverageData() parses the report when the soft cache is empty — keep that possibility off the EDT. + ApplicationManager.getApplication().executeOnPooledThread { + val data = bundle.coverageData ?: return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater({ show(gen, data) }, project.disposed) + } + } + + private fun show(gen: Int, data: ProjectData) { + if (gen != generation) return + if (shownData !== data) { + clearAll() + shownData = data + shownIndex = data.classes.entries.associate { TestoCoverageKeys.normalize(it.key) to it.value } + } + for (editor in EditorFactory.getInstance().allEditors) { + if (editor.project === project) annotate(editor.document) + } + } + + private fun annotate(document: Document) { + if (shownData == null || document in annotated) return + val file = FileDocumentManager.getInstance().getFile(document) ?: return + val classData = shownIndex[TestoCoverageKeys.normalize(file.path)] + ?: file.canonicalPath?.let { shownIndex[TestoCoverageKeys.normalize(it)] } + ?: return + val lines = classData.getLines() ?: return + + val markup = DocumentMarkupModel.forDocument(document, project, true) + val scheme = EditorColorsManager.getInstance().globalScheme + val highlighters = ArrayList() + for (raw in lines) { + val lineData = raw as? LineData ?: continue + val docLine = lineData.lineNumber - 1 + if (docLine < 0 || docLine >= document.lineCount) continue + + val renderer = TestoCoverageGutterRenderer(project, file.path, lineData) + val highlighter = markup.addLineHighlighter(docLine, HighlighterLayer.SELECTION - 1, null) + highlighter.lineMarkerRenderer = renderer + val attributes = scheme.getAttributes(renderer.attributesKey) + if (lineData.status == 0) { + // Uncovered lines mark the scrollbar, as the platform's stripe does; covered ones would only be noise. + highlighter.setErrorStripeMarkColor(attributes.errorStripeColor) + highlighter.setThinErrorStripeMark(true) + } + highlighters += highlighter + // The default coverage colours carry no background — this only fires when the user configured one. + if (attributes.backgroundColor != null) { + highlighters += markup.addLineHighlighter(docLine, HighlighterLayer.ADDITIONAL_SYNTAX - 1, attributes) + } + } + if (highlighters.isEmpty()) return + + val listenerDisposable = Disposer.newDisposable(this) + document.addDocumentListener(SplitLineDropper(highlighters), listenerDisposable) + annotated[document] = AnnotatedDocument(highlighters, listenerDisposable) + } + + /** + * Line markers survive edits by shifting, which is the point — but an Enter inside a marked line leaves one marker + * spanning two lines, a claim the coverage run never made. Drop those (and any the platform invalidated). + */ + private class SplitLineDropper(private val highlighters: MutableList) : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + val document = event.document + val iterator = highlighters.iterator() + while (iterator.hasNext()) { + val highlighter = iterator.next() + if (highlighter.isValid && + document.getLineNumber(highlighter.startOffset) == document.getLineNumber(highlighter.endOffset) + ) continue + highlighter.dispose() + iterator.remove() + } + } + } + + private fun dropDocument(document: Document) { + val entry = annotated.remove(document) ?: return + entry.highlighters.forEach { it.dispose() } + Disposer.dispose(entry.listenerDisposable) + } + + private fun clearAll() { + for (entry in annotated.values) { + entry.highlighters.forEach { it.dispose() } + Disposer.dispose(entry.listenerDisposable) + } + annotated.clear() + shownData = null + shownIndex = emptyMap() + } + + override fun dispose() = Unit + + companion object { + fun getInstance(project: Project): TestoCoverageEditorHighlighter = + project.getService(TestoCoverageEditorHighlighter::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt new file mode 100644 index 00000000..ed471c64 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt @@ -0,0 +1,191 @@ +package com.github.xepozz.testo.coverage.editor + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.coverage.perTest.TEST_ID_ORDER +import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher +import com.github.xepozz.testo.coverage.perTest.TestoTestIdentityMapper +import com.github.xepozz.testo.coverage.perTest.shortTestLabel +import com.intellij.icons.AllIcons +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.editor.ex.EditorGutterComponentEx +import com.intellij.openapi.editor.markup.LineMarkerRendererEx +import com.intellij.openapi.editor.markup.ActiveGutterRenderer +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.pom.Navigatable +import com.intellij.rt.coverage.data.LineData +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.panels.VerticalLayout +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.FlowLayout +import java.awt.Graphics +import java.awt.Rectangle +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.SwingUtilities + +/** + * The coverage stripe in the editor gutter, drawn by [TestoCoverageEditorHighlighter] — a public-API stand-in for the + * platform's `CoverageLineMarkerRenderer` (`@ApiStatus.Internal`). Same geometry: `Position.LEFT`, the + * stripe filled with the standard coverage colour keys, so the user's Colors & Fonts settings apply unchanged. + * + * A click inside the line-marker area pops the line's story: coverage status, hit count, branch tally (Cobertura), + * and — when the per-test index holds the line — the covering tests, navigable like the code-vision lens. + */ +internal class TestoCoverageGutterRenderer( + private val project: Project, + private val filePath: String, + private val lineData: LineData, +) : ActiveGutterRenderer, LineMarkerRendererEx { + + val attributesKey: TextAttributesKey = when (lineData.status) { + FULL -> CodeInsightColors.LINE_FULL_COVERAGE + PARTIAL -> CodeInsightColors.LINE_PARTIAL_COVERAGE + else -> CodeInsightColors.LINE_NONE_COVERAGE + } + + override fun getPosition(): LineMarkerRendererEx.Position = LineMarkerRendererEx.Position.LEFT + + override fun paint(editor: Editor, g: Graphics, r: Rectangle) { + val attributes = editor.colorsScheme.getAttributes(attributesKey) + val color = attributes.backgroundColor ?: attributes.foregroundColor ?: return + g.color = color + g.fillRect(r.x, r.y, minOf(r.width, JBUI.scale(STRIPE_WIDTH)), r.height) + } + + // The gutter dispatches clicks by Y alone; the X window is on the renderer (same bounds the platform stripe uses). + override fun canDoAction(e: MouseEvent): Boolean { + val gutter = e.component as? EditorGutterComponentEx ?: return false + return e.x > gutter.lineMarkerAreaOffset && e.x < gutter.iconAreaOffset + } + + override fun doAction(editor: Editor, e: MouseEvent) { + e.consume() + showPopup(RelativePoint(e)) + } + + // The platform coverage popup's shape: one metric per row, the covering tests right below. + private fun showPopup(at: RelativePoint) { + val header = JPanel(VerticalLayout(JBUI.scale(2))) + header.border = JBUI.Borders.empty(6, 10) + header.add(JBLabel(TestoBundle.message("testo.coverage.editor.popup.hits", lineData.hits))) + lineData.branchData?.let { + header.add(JBLabel(TestoBundle.message("testo.coverage.editor.popup.branches", it.coveredBranches, it.totalBranches))) + } + + val tests = coveringTests() + if (tests.isEmpty()) { + JBPopupFactory.getInstance().createComponentPopupBuilder(header, null).createPopup().show(at) + return + } + + header.add(JBLabel(TestoBundle.message("testo.coverage.editor.popup.tests", tests.size))) + val list = JBList(tests) + list.cellRenderer = SimpleListCellRenderer.create("") { shortTestLabel(it) } + list.selectedIndex = 0 + list.visibleRowCount = minOf(tests.size, 8) + // A plain JBList tracks the keyboard only; the row under the pointer is highlighted by the list wrappers the + // platform's own chooser popups are built from, which a hand-assembled panel does not go through. + list.addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + list.locationToIndex(e.point).takeIf { it >= 0 }?.let { list.selectedIndex = it } + } + }) + + val runAll = JButton( + TestoBundle.message("testo.coverage.editor.popup.run.all", tests.size), + AllIcons.Toolwindows.ToolWindowRunWithCoverage, + ) + + val panel = JPanel(BorderLayout()) + panel.add(header, BorderLayout.NORTH) + panel.add(JBScrollPane(list), BorderLayout.CENTER) + panel.add(JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(10), JBUI.scale(4))).apply { + isOpaque = false + add(runAll) + }, BorderLayout.SOUTH) + + val popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(panel, list) + .setRequestFocus(true) + .createPopup() + + val mapper = TestoTestIdentityMapper.getInstance() + val navigateSelected = { + val id = list.selectedValue + if (id != null) { + popup.cancel() + (mapper.resolve(id, project) as? Navigatable)?.takeIf { it.canNavigate() }?.navigate(true) + } + } + list.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (SwingUtilities.isLeftMouseButton(e)) navigateSelected() + } + }) + list.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) navigateSelected() + } + }) + runAll.addActionListener { + popup.cancel() + TestoCoveringTestsLauncher.run( + project, + tests, + TestoCoveringTestsLauncher.runName(lineSubject(), tests.size), + ) + } + popup.show(at) + } + + override fun getTooltipText(): String = statusText() + + override fun getAccessibleName(): String = TestoBundle.message("testo.coverage.editor.accessible.name") + + /** How the run names itself: `Foo.php:42`, the line the tests were read off. */ + private fun lineSubject(): String = "${filePath.substringAfterLast('/')}:${lineData.lineNumber}" + + private fun coveringTests(): List = + TestoCoverageByTestIndex.getInstance(project).data() + .testsCoveringLine(filePath, lineData.lineNumber) + .sortedWith(TEST_ID_ORDER) + + private fun statusText(): String = coverageLineStatusText(lineData) + + companion object { + private const val STRIPE_WIDTH = 8 + } +} + +// com.intellij.rt.coverage.data.LineCoverage constants, spelled as the Int LineData.getStatus() answers. +private const val PARTIAL = 1 +private const val FULL = 2 + +/** The popup/tooltip headline: status, then hits and the branch tally when the line carries them. */ +internal fun coverageLineStatusText(lineData: LineData): String { + val parts = mutableListOf( + when (lineData.status) { + FULL -> TestoBundle.message("testo.coverage.editor.status.full") + PARTIAL -> TestoBundle.message("testo.coverage.editor.status.partial") + else -> TestoBundle.message("testo.coverage.editor.status.none") + } + ) + if (lineData.hits > 1) parts += TestoBundle.message("testo.coverage.editor.status.hits", lineData.hits) + lineData.branchData?.let { parts += TestoBundle.message("testo.coverage.editor.status.branches", it.coveredBranches, it.totalBranches) } + return parts.joinToString(", ") +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt new file mode 100644 index 00000000..91139ecb --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt @@ -0,0 +1,80 @@ +package com.github.xepozz.testo.coverage.editor + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.perTest.TEST_ID_ORDER +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsPopup +import com.github.xepozz.testo.coverage.perTest.testsCoveringElement +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.daemon.LineMarkerInfo +import com.intellij.codeInsight.daemon.LineMarkerProvider +import com.intellij.icons.AllIcons +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.markup.GutterIconRenderer +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.util.elementType +import com.intellij.ui.awt.RelativePoint +import com.jetbrains.php.lang.lexer.PhpTokenTypes +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.lang.psi.elements.PhpNamedElement + +/** + * A gutter icon on every method, function and class the per-test coverage recorded as covered: *Run covering tests (N)*, + * launching exactly those tests again, with coverage. + * + * Only `coverage-xml` carries which test touched which line, so the icon appears only after such a report is loaded + * ([TestoCoverageByTestIndex]) — and only while the Coverage view's toggle for it is on. + */ +class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { + + override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { + if (element.elementType != PhpTokenTypes.IDENTIFIER) return null + val owner = element.parent + if (owner !is Function && owner !is PhpClass) return null + owner as PhpNamedElement + // The declaration's own name, and nothing else that parses as an identifier under it — otherwise one + // declaration can be marked twice. + if (owner.nameNode?.psi !== element) return null + val project = element.project + if (!TestoCoveringTestsGutter.getInstance(project).enabled) return null + + val tests = testsCoveringElement(owner).sortedWith(TEST_ID_ORDER) + if (tests.isEmpty()) return null + val label = TestoBundle.message("testo.coverage.gutter.run.covering", tests.size) + val subject = owner.name + + return LineMarkerInfo( + element, + element.textRange, + AllIcons.Toolwindows.ToolWindowRunWithCoverage, + { label }, + { event, _ -> + TestoCoveringTestsPopup.show(project, tests, subject, RelativePoint(event)) + }, + GutterIconRenderer.Alignment.LEFT, + { label }, + ) + } +} + +/** The user's switch for those gutter icons, off the Coverage view's toolbar. Per project, remembered. */ +@Service(Service.Level.PROJECT) +class TestoCoveringTestsGutter(private val project: Project) { + + var enabled: Boolean + get() = PropertiesComponent.getInstance(project).getBoolean(KEY, true) + set(value) { + PropertiesComponent.getInstance(project).setValue(KEY, value, true) + // The markers are computed by the daemon, which has no reason of its own to rerun: no file changed. + DaemonCodeAnalyzer.getInstance(project).restart() + } + + companion object { + private const val KEY = "testo.coverage.gutter.coveringTests" + + fun getInstance(project: Project): TestoCoveringTestsGutter = + project.getService(TestoCoveringTestsGutter::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CloverCoverageParser.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CloverCoverageParser.kt new file mode 100644 index 00000000..a93cb5d0 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CloverCoverageParser.kt @@ -0,0 +1,26 @@ +package com.github.xepozz.testo.coverage.format + +import java.nio.file.Path + +/** + * Clover: a single file, line coverage only. `` is a host-absolute path with backslashes; both covered + * (`count>=1`) and uncovered (`count=0`) executable lines are emitted. No branch data. + */ +object CloverCoverageParser : TestoCoverageParser { + override val format = CoverageFormat.CLOVER + + override fun parse(reportPath: Path): ParsedReport { + val root = readXmlRoot(reportPath) + val files = root.descendants("file").mapNotNull { fileEl -> + val name = fileEl.getAttribute("name").ifBlank { return@mapNotNull null } + val path = name.replace('\\', '/') + val lines = fileEl.childElements("line").mapNotNull { lineEl -> + val num = lineEl.getAttribute("num").toIntOrNull() ?: return@mapNotNull null + val hits = lineEl.getAttribute("count").toIntOrNull() ?: 0 + LineCoverage(num, hits) + } + FileCoverage(path, lines) + } + return ParsedReport(format, files, hasBranches = false, perTest = null) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt new file mode 100644 index 00000000..d82a25e7 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt @@ -0,0 +1,41 @@ +package com.github.xepozz.testo.coverage.format + +import java.nio.file.Path + +/** + * Cobertura (`coverage-04`): a single file, line coverage plus branch coverage on decision lines. `filename` is + * relative to a `/` root (forward slashes); a branch line carries `condition-coverage="P% (a/b)"`. + */ +object CoberturaCoverageParser : TestoCoverageParser { + override val format = CoverageFormat.COBERTURA + + private val CONDITION = Regex("""\((\d+)/(\d+)\)""") + + override fun parse(reportPath: Path): ParsedReport { + val root = readXmlRoot(reportPath) + val source = root.descendants("source").firstOrNull()?.textContent?.trim()?.trimEnd('/').orEmpty() + var hasBranches = false + // Distinct classes may name the same file; merge their lines under one resolved path. + val byFile = LinkedHashMap>() + for (classEl in root.descendants("class")) { + val filename = classEl.getAttribute("filename").ifBlank { continue } + val path = if (source.isEmpty()) filename else "$source/$filename" + val lines = byFile.getOrPut(path) { mutableListOf() } + // Only the class's own : a foreign writer (PHPUnit) also nests , + // which descendants("line") would count a second time. + for (lineEl in classEl.childElements("lines").flatMap { it.childElements("line") }) { + val num = lineEl.getAttribute("number").toIntOrNull() ?: continue + val hits = lineEl.getAttribute("hits").toIntOrNull() ?: 0 + val branch = if (lineEl.getAttribute("branch") == "true") { + CONDITION.find(lineEl.getAttribute("condition-coverage"))?.let { + hasBranches = true + BranchCoverage(it.groupValues[1].toInt(), it.groupValues[2].toInt()) + } + } else null + lines += LineCoverage(num, hits, branch) + } + } + val files = byFile.map { (path, lines) -> FileCoverage(path, lines) } + return ParsedReport(format, files, hasBranches, perTest = null) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt new file mode 100644 index 00000000..88f0e700 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt @@ -0,0 +1,70 @@ +package com.github.xepozz.testo.coverage.format + +/** + * The three coverage report shapes Testo's `plugin/codecov` writes, identified by the `format=` attribute of the + * `##teamcity[testoReport …]` announce (and by the CLI flag that produced them). + */ +enum class CoverageFormat(val id: String) { + CLOVER("clover"), + COBERTURA("cobertura"), + COVERAGE_XML("coverage-xml"); + + companion object { + fun fromId(id: String?): CoverageFormat? = entries.firstOrNull { it.id.equals(id, ignoreCase = true) } + } +} + +/** A covering test: a `\`-qualified class plus a method, as spelled in coverage-xml ``. */ +data class TestId(val fqcn: String, val method: String) { + companion object { + /** Splits on the last `::`; the namespace separator `\` in [fqcn] is kept verbatim. `null` if malformed. */ + fun parse(reference: String): TestId? { + val sep = reference.lastIndexOf("::") + if (sep < 0) return null + val fqcn = reference.substring(0, sep) + val method = reference.substring(sep + 2) + if (fqcn.isEmpty() || method.isEmpty()) return null + return TestId(fqcn, method) + } + } +} + +/** A source location as it comes off a report: the file path is resolved+forward-slashed, not yet the platform key. */ +data class SourceLine(val filePath: String, val line: Int) + +/** `condition-coverage="P% (covered/total)"` from Cobertura. Identity of *which* branches is unknown. */ +data class BranchCoverage(val covered: Int, val total: Int) + +data class LineCoverage(val line: Int, val hits: Int, val branch: BranchCoverage? = null) + +/** + * A file's own line tally, reported by coverage-xml even for files it lists no covered line for. It is the only place + * that format states how many executable lines a file has: [LineCoverage] covers the executed ones alone. + */ +data class LineTotals(val total: Int, val executed: Int) + +/** One source file's coverage. [filePath] is the resolved absolute path, forward-slashed, ready to normalize. */ +data class FileCoverage(val filePath: String, val lines: List, val totals: LineTotals? = null) + +/** + * The per-test overlay carried only by coverage-xml: which tests touched which source lines, both directions. Keyed by + * the same resolved path as [FileCoverage.filePath]. + */ +data class PerTestCoverage( + val byTest: Map>, + val byLine: Map>, +) { + companion object { + val EMPTY = PerTestCoverage(emptyMap(), emptyMap()) + } +} + +/** The parsed report, format-neutral. Turned into a platform `ProjectData` by the coverage runner. */ +data class ParsedReport( + val format: CoverageFormat, + val files: List, + val hasBranches: Boolean, + val perTest: PerTestCoverage?, +) + +class CoverageParseException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXml.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXml.kt new file mode 100644 index 00000000..c5bc245e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXml.kt @@ -0,0 +1,30 @@ +package com.github.xepozz.testo.coverage.format + +import org.w3c.dom.Element +import java.nio.file.Files +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Reads a report into its root [Element], namespace-unaware (so ``/`` match by their literal tag names even + * under coverage-xml's default namespace) and with all DTD/entity loading off — Cobertura's DOCTYPE points at a remote + * `coverage-04.dtd` we must never fetch. + */ +internal fun readXmlRoot(path: Path): Element { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = false + isValidating = false + setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + } + return Files.newInputStream(path).use { factory.newDocumentBuilder().parse(it).documentElement } +} + +internal fun Element.childElements(): List = + (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element } + +internal fun Element.childElements(tag: String): List = childElements().filter { it.tagName == tag } + +internal fun Element.descendants(tag: String): List = + getElementsByTagName(tag).let { nl -> (0 until nl.length).map { nl.item(it) as Element } } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt new file mode 100644 index 00000000..8ae2abf2 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt @@ -0,0 +1,60 @@ +package com.github.xepozz.testo.coverage.format + +import org.w3c.dom.Element +import java.nio.file.Files +import java.nio.file.Path + +/** + * coverage-xml: a directory — an `index.xml` overview plus one XML per source file, in PHPUnit's coverage schema (the + * root element is still ``). Only executed lines that have covering tests are emitted (a per-test overlay, no + * uncovered lines), so the per-file `` is what states a file's line count. Source file for a `` entry + * is `/`; the per-file XML sits at `/`. + */ +object CoverageXmlParser : TestoCoverageParser { + override val format = CoverageFormat.COVERAGE_XML + + override fun parse(reportPath: Path): ParsedReport { + val indexPath = if (Files.isDirectory(reportPath)) reportPath.resolve("index.xml") else reportPath + val indexDir = indexPath.parent ?: indexPath + val indexRoot = readXmlRoot(indexPath) + val source = indexRoot.descendants("project").firstOrNull()?.getAttribute("source") + ?.trim()?.trimEnd('/').orEmpty() + + val files = mutableListOf() + val byTest = LinkedHashMap>() + val byLine = LinkedHashMap>() + + for (entry in indexRoot.descendants("file")) { + val href = entry.getAttribute("href").ifBlank { continue } + val perFilePath = indexDir.resolve(href) + if (!Files.exists(perFilePath)) continue + val relative = href.removeSuffix(".xml") + val path = if (source.isEmpty()) relative else "$source/$relative" + val fileRoot = readXmlRoot(perFilePath) + val coverage = fileRoot.descendants("coverage").firstOrNull() + + val lines = mutableListOf() + for (lineEl in coverage?.childElements("line").orEmpty()) { + val nr = lineEl.getAttribute("nr").toIntOrNull() ?: continue + lines += LineCoverage(nr, hits = 1) + val ref = SourceLine(path, nr) + for (coveredEl in lineEl.childElements("covered")) { + val testId = TestId.parse(coveredEl.getAttribute("by")) ?: continue + byTest.getOrPut(testId) { linkedSetOf() } += ref + byLine.getOrPut(ref) { linkedSetOf() } += testId + } + } + files += FileCoverage(path, lines, readTotals(fileRoot)) + } + + val perTest = PerTestCoverage(byTest.mapValues { it.value.toSet() }, byLine.mapValues { it.value.toSet() }) + return ParsedReport(format, files, hasBranches = false, perTest = perTest) + } + + private fun readTotals(fileRoot: Element): LineTotals? { + val lines = fileRoot.descendants("totals").firstOrNull()?.childElements("lines")?.firstOrNull() ?: return null + val total = lines.getAttribute("total").toIntOrNull() ?: return null + val executed = lines.getAttribute("executed").toIntOrNull() ?: return null + return LineTotals(total, executed.coerceAtMost(total)) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/TestoCoverageParser.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/TestoCoverageParser.kt new file mode 100644 index 00000000..8f595f9c --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/TestoCoverageParser.kt @@ -0,0 +1,43 @@ +package com.github.xepozz.testo.coverage.format + +import java.nio.file.Files +import java.nio.file.Path + +/** Parses one report path (a file, or a directory for coverage-xml) into a format-neutral [ParsedReport]. */ +interface TestoCoverageParser { + val format: CoverageFormat + fun parse(reportPath: Path): ParsedReport +} + +/** + * The report format, from the known `format` (announce / CLI flag) when given, else sniffed off the path shape and root + * element. `null` only when the path is unreadable or nothing matches. + */ +fun detectCoverageFormat(reportPath: Path): CoverageFormat? { + if (Files.isDirectory(reportPath)) return CoverageFormat.COVERAGE_XML + if (reportPath.fileName?.toString().equals("index.xml", ignoreCase = true)) return CoverageFormat.COVERAGE_XML + val root = try { + readXmlRoot(reportPath) + } catch (_: Exception) { + return null + } + return when { + root.tagName == "phpunit" -> CoverageFormat.COVERAGE_XML + root.tagName == "coverage" && (root.hasAttribute("line-rate") || root.childElements("packages").isNotEmpty()) -> + CoverageFormat.COBERTURA + root.tagName == "coverage" -> CoverageFormat.CLOVER + else -> null + } +} + +/** Parses a report, using [format] when known and falling back to [detectCoverageFormat]. */ +fun parseCoverageReport(reportPath: Path, format: CoverageFormat? = null): ParsedReport { + val resolved = format ?: detectCoverageFormat(reportPath) + ?: throw CoverageParseException("Cannot determine coverage format of $reportPath") + val parser = when (resolved) { + CoverageFormat.CLOVER -> CloverCoverageParser + CoverageFormat.COBERTURA -> CoberturaCoverageParser + CoverageFormat.COVERAGE_XML -> CoverageXmlParser + } + return parser.parse(reportPath) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt new file mode 100644 index 00000000..3a4c346e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt @@ -0,0 +1,80 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.coverage.format.PerTestCoverage +import com.github.xepozz.testo.coverage.format.TestId + +/** A source location as the index stores it: a [TestoCoverageKeys]-normalized file key and a 1-based line. */ +data class SourceRef(val fileKey: String, val line: Int) + +/** + * Read model over coverage-xml's per-test overlay — which tests touched which source lines, both directions. + * File keys are normalized on the way in, so callers may pass raw paths. + */ +interface TestoCoverageByTestData { + fun testsCoveringLine(fileKey: String, line: Int): Set + fun testsCoveringRange(fileKey: String, lines: IntRange): Set + fun linesOfTest(testId: TestId): Set + fun allTests(): Set + + /** Every covered file key → the distinct tests that touched it; the substrate of the view's "Tests" column. */ + fun testsByFile(): Map> + + companion object { + val EMPTY: TestoCoverageByTestData = MapCoverageByTestData(emptyMap(), emptyMap()) + + fun of(perTest: PerTestCoverage?): TestoCoverageByTestData = + if (perTest == null) EMPTY else MapCoverageByTestData.from(perTest) + } +} + +/** + * Every test that touched [path] — the file's own set, or, for a directory, the union over everything beneath it. + * [isDirectory] is passed rather than probed so callers holding a VFS or a PSI item both fit. + */ +fun TestoCoverageByTestData.testsUnder(path: String, isDirectory: Boolean): Set { + val key = TestoCoverageKeys.normalize(path) + if (!isDirectory) return testsByFile()[key] ?: emptySet() + val prefix = "$key/" + return testsByFile().entries.asSequence() + .filter { it.key.startsWith(prefix) } + .flatMapTo(LinkedHashSet()) { it.value } +} + +internal class MapCoverageByTestData( + private val byLine: Map>, + private val byTest: Map>, +) : TestoCoverageByTestData { + + private val byFile: Map> = buildMap> { + for ((ref, tests) in byLine) getOrPut(ref.fileKey) { LinkedHashSet() } += tests + } + + override fun testsCoveringLine(fileKey: String, line: Int): Set = + byLine[SourceRef(TestoCoverageKeys.normalize(fileKey), line)] ?: emptySet() + + override fun testsCoveringRange(fileKey: String, lines: IntRange): Set { + val key = TestoCoverageKeys.normalize(fileKey) + val tests = LinkedHashSet() + for (line in lines) byLine[SourceRef(key, line)]?.let { tests += it } + return tests + } + + override fun linesOfTest(testId: TestId): Set = byTest[testId] ?: emptySet() + + override fun allTests(): Set = byTest.keys + + override fun testsByFile(): Map> = byFile + + companion object { + fun from(perTest: PerTestCoverage): MapCoverageByTestData { + val byLine = HashMap>() + for ((sourceLine, tests) in perTest.byLine) { + byLine[SourceRef(TestoCoverageKeys.normalize(sourceLine.filePath), sourceLine.line)] = tests + } + val byTest = perTest.byTest.mapValues { (_, lines) -> + lines.mapTo(LinkedHashSet()) { SourceRef(TestoCoverageKeys.normalize(it.filePath), it.line) } + } + return MapCoverageByTestData(byLine, byTest) + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt new file mode 100644 index 00000000..c8bdbdb2 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt @@ -0,0 +1,29 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.coverage.format.PerTestCoverage +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project + +/** + * Holds the latest per-test coverage for the project so consumers can read it with no active coverage session. + * Populated by [com.github.xepozz.testo.coverage.TestoCoverageRunner] when a `coverage-xml` report is loaded. + * + * In-memory: survives until the IDE closes or the next coverage-xml run replaces it. + */ +@Service(Service.Level.PROJECT) +class TestoCoverageByTestIndex { + @Volatile + private var data: TestoCoverageByTestData = TestoCoverageByTestData.EMPTY + + /** Only a report that actually carries per-test data replaces the index — a plain clover/cobertura run leaves it. */ + fun update(perTest: PerTestCoverage?) { + if (perTest != null) data = TestoCoverageByTestData.of(perTest) + } + + fun data(): TestoCoverageByTestData = data + + companion object { + fun getInstance(project: Project): TestoCoverageByTestIndex = project.service() + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageKeys.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageKeys.kt new file mode 100644 index 00000000..052e8c35 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageKeys.kt @@ -0,0 +1,15 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.intellij.openapi.util.SystemInfo + +/** + * One canonical spelling for a source file used as the per-test index key. Forward slashes always; lower-cased on + * Windows, where the file system is case-insensitive and a report path (`D:\…`) and a VFS path may differ only in case. + * Mirrors the intent of the platform's `SimpleCoverageAnnotator.normalizeFilePath` (which is `protected`, so ours). + */ +object TestoCoverageKeys { + fun normalize(path: String): String { + val slashed = path.replace('\\', '/') + return if (SystemInfo.isWindows) slashed.lowercase() else slashed + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTests.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTests.kt new file mode 100644 index 00000000..fc3c033a --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTests.kt @@ -0,0 +1,24 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.coverage.format.TestId +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement + +/** Covering tests in a stable order for lists and gutters: by class, then method. */ +internal val TEST_ID_ORDER: Comparator = compareBy({ it.fqcn }, { it.method }) + +/** + * The tests that touched any line the [element] spans, from the project's per-test coverage — for a declaration, its + * whole body. Empty when the element has no file/document, or nothing covered it. + */ +internal fun testsCoveringElement(element: PsiElement): Set { + val file = element.containingFile ?: return emptySet() + val virtualFile = file.virtualFile ?: return emptySet() + val project = element.project + val document = PsiDocumentManager.getInstance(project).getDocument(file) ?: return emptySet() + val range = element.textRange ?: return emptySet() + // Report line numbers are 1-based; the document is 0-based. + val first = document.getLineNumber(range.startOffset) + 1 + val last = document.getLineNumber(range.endOffset.coerceAtMost(document.textLength)) + 1 + return TestoCoverageByTestIndex.getInstance(project).data().testsCoveringRange(virtualFile.path, first..last) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt new file mode 100644 index 00000000..0aa31f6e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt @@ -0,0 +1,50 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.intellij.execution.ExecutionManager +import com.intellij.execution.ExecutorRegistry +import com.intellij.execution.RunManager +import com.intellij.execution.runners.ExecutionEnvironmentBuilder +import com.intellij.openapi.project.Project +import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings + +/** + * Runs a set of tests read off the per-test coverage — the covering tests of a line, a declaration, a file or a whole + * directory — as one Testo run. + * + * The set is passed as explicit `--filter` selectors, the same shape *Rerun Failed Tests* uses, on a throwaway + * configuration that never reaches `RunManager`'s list. Its scope is reset to `ConfigurationFile` so nothing narrows + * the filters away. + */ +internal object TestoCoveringTestsLauncher { + + /** Coverage by default: the point of running the covering tests is to see the coverage they produce now. */ + fun run( + project: Project, + tests: Collection, + name: String, + executorId: String = TestoCoverageProgramRunner.EXECUTOR_ID, + ) { + val mapper = TestoTestIdentityMapper.getInstance() + val filters = tests.map { mapper.toFilterSelector(it) }.distinct().sorted() + if (filters.isEmpty()) return + val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) ?: return + + val settings = RunManager.getInstance(project).createConfiguration(name, TestoRunConfigurationType.INSTANCE) + val configuration = settings.configuration as? TestoRunConfiguration ?: return + configuration.testoSettings.getTestoRunnerSettings().apply { + rerunFilters = filters + scope = PhpTestRunnerSettings.Scope.ConfigurationFile + } + val environment = ExecutionEnvironmentBuilder.createOrNull(executor, settings)?.build() ?: return + ExecutionManager.getInstance(project).restartRunProfile(environment) + } + + /** The name such a run appears under — the tab title, and what the run archive files it as. */ + fun runName(subject: String, count: Int): String = + TestoBundle.message("testo.coverage.covering.run.name", subject, count) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt new file mode 100644 index 00000000..e19c85f0 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.format.TestId +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.awt.RelativePoint +import javax.swing.Icon + +/** `\Ns\FooTest::method` as a list shows it: the class alone, since the namespace is the same for every row. */ +internal fun shortTestLabel(id: TestId): String = "${id.fqcn.trimStart('\\').substringAfterLast('\\')}::${id.method}" + +/** + * The list behind the *Run covering tests* gutter icon: all of them at the top, then one row per test. A click on a + * declaration should not fire a whole test run before the user has seen what it is about to run. + */ +internal object TestoCoveringTestsPopup { + + private class Row(val test: TestId?, val label: String, val icon: Icon) + + fun show(project: Project, tests: List, subject: String, at: RelativePoint) { + if (tests.isEmpty()) return + val rows = buildList { + add(Row(null, TestoBundle.message("testo.coverage.editor.popup.run.all", tests.size), AllIcons.Actions.RunAll)) + tests.forEach { add(Row(it, shortTestLabel(it), AllIcons.Toolwindows.ToolWindowRunWithCoverage)) } + } + val popup = JBPopupFactory.getInstance() + .createPopupChooserBuilder(rows) + .setTitle(TestoBundle.message("testo.coverage.popup.title", subject)) + .setRenderer(SimpleListCellRenderer.create { label, row, _ -> + label.text = row.label + label.icon = row.icon + }) + .setItemChosenCallback { row -> + val chosen = row.test?.let { listOf(it) } ?: tests + val name = row.test?.let { shortTestLabel(it) } ?: subject + TestoCoveringTestsLauncher.run(project, chosen, TestoCoveringTestsLauncher.runName(name, chosen.size)) + } + .createPopup() + popup.show(at) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt new file mode 100644 index 00000000..6db694dc --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt @@ -0,0 +1,40 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.jetbrains.php.PhpIndex +import com.jetbrains.php.lang.psi.elements.Method + +/** + * The one place that maps a coverage [TestId] (a `\`-qualified class + method, as coverage-xml spells covering tests) + * onto Testo's own identities — so its consumers cannot diverge. A `--filter` selector is a pure string (available + * with no PSI); the `php_qn://` hint and PSI need the class resolved through [PhpIndex]. + */ +interface TestoTestIdentityMapper { + /** `\Ns\FooTest::method` — the selector Testo's `--filter` accepts (matches TestoRunTarget.filterOf output). */ + fun toFilterSelector(id: TestId): String + + /** The canonical `php_qn://…` location hint, or null when the class/method cannot be resolved. */ + fun toLocationHint(id: TestId, project: Project): String? + + fun resolve(id: TestId, project: Project): PsiElement? + + companion object { + fun getInstance(): TestoTestIdentityMapper = DefaultTestIdentityMapper + } +} + +internal object DefaultTestIdentityMapper : TestoTestIdentityMapper { + override fun toFilterSelector(id: TestId): String = "\\" + id.fqcn.trimStart('\\') + "::" + id.method + + override fun toLocationHint(id: TestId, project: Project): String? = + (resolve(id, project) as? Method)?.let { TestoTestRunLineMarkerProvider.getLocationHint(it) } + + override fun resolve(id: TestId, project: Project): PsiElement? { + val fqn = "\\" + id.fqcn.trimStart('\\') + return PhpIndex.getInstance(project).getClassesByFQN(fqn) + .firstNotNullOfOrNull { it.findMethodByName(id.method) } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/index/TestoGroupsIndex.kt b/src/main/kotlin/com/github/xepozz/testo/index/TestoGroupsIndex.kt new file mode 100644 index 00000000..f94a2ba5 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/index/TestoGroupsIndex.kt @@ -0,0 +1,79 @@ +package com.github.xepozz.testo.index + +import com.github.xepozz.testo.TestoClasses +import com.github.xepozz.testo.tests.run.TestoRunConfigurationProducer +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.util.indexing.DataIndexer +import com.intellij.util.indexing.FileBasedIndex +import com.intellij.util.indexing.FileBasedIndexExtension +import com.intellij.util.indexing.FileContent +import com.intellij.util.indexing.ID +import com.intellij.util.io.BooleanDataDescriptor +import com.intellij.util.io.DataExternalizer +import com.intellij.util.io.EnumeratorStringDescriptor +import com.jetbrains.php.lang.PhpFileType +import com.jetbrains.php.lang.psi.elements.PhpAttribute + +/** + * Every group name a `#[\Testo\Filter\Group]` in the project spells, so the run configuration can offer them instead + * of asking the user to remember what exists. The attribute is variadic and may sit on a class, a method or a + * function, so one file can declare any number of names. + * + * The value is a placeholder — the key is the whole payload. + */ +class TestoGroupsIndex : FileBasedIndexExtension() { + override fun getName() = KEY + + override fun getIndexer() = DataIndexer { inputData -> + // Attribute lookup resolves imports; skipping files that cannot mention the attribute keeps that off the + // indexing path for the vast majority of a project's PHP. + if (!inputData.contentAsText.contains(ATTRIBUTE_SHORT_NAME)) return@DataIndexer emptyMap() + + groupNamesIn(inputData.psiFile).associateWith { true } + } + + override fun getKeyDescriptor() = EnumeratorStringDescriptor.INSTANCE + + override fun getValueExternalizer(): DataExternalizer = BooleanDataDescriptor.INSTANCE + + override fun getVersion() = 1 + + override fun getInputFilter() = FileBasedIndex.InputFilter { it.fileType is PhpFileType } + + override fun dependsOnFileContent() = true + + companion object Companion { + val KEY = ID.create("Testo.Groups") + + private const val ATTRIBUTE_SHORT_NAME = "Group" + + fun groupNamesIn(file: PsiFile): Set = + PsiTreeUtil.findChildrenOfType(file, PhpAttribute::class.java) + .filter { it.fqn == TestoClasses.FILTER_GROUP } + .flatMapTo(mutableSetOf()) { TestoRunConfigurationProducer.extractGroupNames(it) } + + /** Every group declared in the project, deduplicated and sorted. Empty while the index is still building. */ + fun allGroups(project: Project): List { + if (DumbService.isDumb(project)) return emptyList() + + val index = FileBasedIndex.getInstance() + val scope = GlobalSearchScope.projectScope(project) + return runCatching { + ReadAction.compute, RuntimeException> { + val names = sortedSetOf() + // The processor's own result stops the walk, so it must not be `add`'s "was new". + index.processAllKeys(KEY, { names.add(it); true }, scope, null) + // That walk answers from the whole on-disk index — one IDE shares it across every project and + // library it has ever indexed, and the scope is only a hint there. A name is this project's own + // only if a file in scope still holds it. + names.filter { index.getContainingFiles(KEY, it, scope).isNotEmpty() } + } + }.getOrDefault(emptyList()) + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt new file mode 100644 index 00000000..d1333fef --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt @@ -0,0 +1,211 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoHistoryIndex +import com.intellij.icons.AllIcons +import com.intellij.ide.actions.RevealFileAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserFactory +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.fileChooser.FileSaverDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.actionSystem.KeepPopupOnPerform +import java.nio.file.Files +import java.nio.file.Path + +/** + * *Replay* — everything about **this tab's** archived run: export it as a single file, say what retention may do with + * it, and load someone else's export back. + * + * The run it acts on is the replayed archive on a history tab, or the recording of a live run — the same directory + * either way, so the group means the same thing on both. + */ +class TestoReplayGroup( + private val project: Project, + private val props: TestoConsoleProperties, +) : ActionGroup( + TestoBundle.messagePointer("testo.runs.replay.group"), + TestoBundle.messagePointer("testo.runs.replay.group.description"), + { AllIcons.Toolwindows.ToolWindowRun }, +), DumbAware { + + init { + isPopup = true + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + // The same icon this run wears in the history list, so the button says what kind of run the tab holds. + override fun update(e: AnActionEvent) { + e.presentation.icon = runKindIcon(runKindOf(executorId())) + } + + override fun getChildren(e: AnActionEvent?): Array = arrayOf( + Separator.create(TestoBundle.message("testo.runs.replay.retention.title")), + RetentionOption(RunRetention.AUTO, "testo.runs.replay.keep"), + RetentionOption(RunRetention.DISCARD, "testo.runs.replay.discard"), + RetentionOption(RunRetention.LOCKED, "testo.runs.replay.lock"), + Separator.getInstance(), + ExportAction(), + ImportAction(), + RevealAction(), + ) + + /** This tab's run directory: the archive a history tab replays, or the one a live run is being recorded into. */ + private fun runDir(): Path? = props.currentRunDir() + + /** What this tab's run was started as: the archived executor on a history tab, the live one otherwise. */ + private fun executorId(): String = + (props.replayProfile as? TestoRunReplayProfile)?.executorId + ?: props.recording?.executorId + ?: props.executor.id + + private inner class ExportAction : AnAction( + TestoBundle.message("testo.runs.replay.export"), + null, + AllIcons.ToolbarDecorator.Export, + ), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + // Only once the run has an archive to export: a live run's directory fills as it goes, and a zip of half of + // it would replay as a run that stops in the middle. + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = runDir()?.let { TestoRunStore.getInstance(project).readManifest(it) } != null + } + + override fun actionPerformed(e: AnActionEvent) { + val dir = runDir() ?: return + val store = TestoRunStore.getInstance(project) + val manifest = store.readManifest(dir) ?: return + val descriptor = FileSaverDescriptor( + TestoBundle.message("testo.runs.replay.export.title"), + TestoBundle.message("testo.runs.replay.export.description"), + "zip", + ) + val target = FileChooserFactory.getInstance() + .createSaveFileDialog(descriptor, project) + .save(null as java.nio.file.Path?, exportFileName(manifest)) + ?: return + val file = target.file.toPath() + ApplicationManager.getApplication().executeOnPooledThread { + val result = runCatching { zipRunDirectory(dir, file) } + notify( + if (result.isSuccess) TestoBundle.message("testo.runs.replay.export.done", file.toString()) + else TestoBundle.message("testo.runs.replay.export.failed"), + if (result.isSuccess) NotificationType.INFORMATION else NotificationType.WARNING, + ) + } + } + } + + /** Import lives here rather than in the history list: it is the same "replay as a file" idea, read instead of written. */ + private inner class ImportAction : AnAction( + TestoBundle.message("testo.runs.replay.import"), + null, + AllIcons.ToolbarDecorator.Import, + ), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) { + val descriptor = FileChooserDescriptor(true, false, true, true, false, false) + .withTitle(TestoBundle.message("testo.runs.replay.import.title")) + .withExtensionFilter("zip") + val chosen = FileChooser.chooseFile(descriptor, project, null) ?: return + val zip = chosen.toNioPath() + ApplicationManager.getApplication().executeOnPooledThread { + val imported = TestoRunStore.getInstance(project).importRun(zip) + if (imported == null) { + notify(TestoBundle.message("testo.runs.replay.import.failed"), NotificationType.WARNING) + return@executeOnPooledThread + } + TestoHistoryIndex.invalidate() + TestoHistoryIndex.refreshLens(project) + ApplicationManager.getApplication().invokeLater( + { TestoRunReplayProfile.replay(project, imported.first, imported.second) }, + project.disposed, + ) + } + } + } + + /** The run's own directory in the file manager — its output, its manifest and the reports captured beside them. */ + private inner class RevealAction : AnAction( + RevealFileAction.getActionName(), + null, + AllIcons.Nodes.Folder, + ), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isVisible = RevealFileAction.isSupported() + e.presentation.isEnabled = runDir()?.let { Files.isDirectory(it) } == true + } + + override fun actionPerformed(e: AnActionEvent) { + RevealFileAction.openDirectory(runDir() ?: return) + } + } + + /** One of the three retention choices — exclusive, so picking one is the whole gesture. */ + private inner class RetentionOption( + private val retention: RunRetention, + labelKey: String, + ) : ToggleAction(TestoBundle.message(labelKey), null, iconOf(retention)), DumbAware { + + init { + templatePresentation.keepPopupOnPerform = KeepPopupOnPerform.Never + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + super.update(e) + e.presentation.isEnabled = runDir() != null + } + + override fun isSelected(e: AnActionEvent): Boolean = current() == retention + + override fun setSelected(e: AnActionEvent, state: Boolean) { + if (!state) return + val dir = runDir() ?: return + // Both, and in this order: the recording carries the choice into the manifest it is about to write, and + // the manifest is what an already-archived run is edited through. + props.recording?.retention = retention + ApplicationManager.getApplication().executeOnPooledThread { + TestoRunStore.getInstance(project).setRetention(dir, retention) + } + } + + // The manifest first: once it is written it is the truth, and clearing the history rewrites it behind this + // tab's back. The recording only answers for a run that has not been archived yet. + private fun current(): RunRetention = + runDir()?.let { TestoRunStore.getInstance(project).retentionOf(it) } + ?: props.recording?.retention + ?: RunRetention.AUTO + } + + private fun notify(text: String, type: NotificationType) { + NotificationGroupManager.getInstance().getNotificationGroup("Testo") + ?.createNotification(text, type) + ?.notify(project) + } + + private companion object { + fun iconOf(retention: RunRetention) = when (retention) { + RunRetention.AUTO -> null + RunRetention.DISCARD -> AllIcons.Actions.GC + RunRetention.LOCKED -> AllIcons.Nodes.Locked + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchive.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchive.kt new file mode 100644 index 00000000..75a661f4 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchive.kt @@ -0,0 +1,78 @@ +package com.github.xepozz.testo.runs + +import java.io.BufferedOutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.io.path.isDirectory + +/** + * An archived run as a single file: the run directory zipped whole (`run.json`, `output.log`, `tests.txt`, `reports/`), + * which is also all an import needs — the same layout goes back under the archive root and replays like any other run. + */ +internal fun zipRunDirectory(source: Path, target: Path) { + Files.createDirectories(target.parent) + ZipOutputStream(BufferedOutputStream(Files.newOutputStream(target))).use { zip -> + Files.walk(source).use { paths -> + paths.filter { !it.isDirectory() }.forEach { file -> + val name = source.relativize(file).joinToString("/") + zip.putNextEntry(ZipEntry(name)) + Files.copy(file, zip) + zip.closeEntry() + } + } + } +} + +/** + * Unpacks an exported run into [target]. Entries that would land outside it are skipped — a zip is a file like any + * other, and this one may not have been written by us. + */ +internal fun unzipRunDirectory(zip: Path, target: Path) { + val root = target.toAbsolutePath().normalize() + Files.createDirectories(root) + ZipInputStream(Files.newInputStream(zip)).use { input -> + while (true) { + val entry = input.nextEntry ?: break + val resolved = root.resolve(entry.name).normalize() + if (!resolved.startsWith(root)) continue + if (entry.isDirectory) { + Files.createDirectories(resolved) + } else { + Files.createDirectories(resolved.parent) + Files.copy(input, resolved, StandardCopyOption.REPLACE_EXISTING) + } + input.closeEntry() + } + } +} + +/** + * Whether the announced report is the entry of a directory rather than a file of its own. + * + * Testo's reports come in both shapes and the announcement always names the entry (`docs/spec/html-report.md`): an + * HTML report is either a self-contained `report.html` or `index.html` beside its assets, and coverage-xml is always + * `index.xml` in a directory. The `index.` prefix is what tells them apart — and a directory report has to be copied + * whole, or the archived copy opens without its assets. + */ +internal fun isDirectoryReport(local: Path): Boolean = + local.fileName?.toString()?.startsWith("index.", ignoreCase = true) == true + +/** What a captured report is called under `reports/`: the format alone for a directory, plus the extension for a file. */ +internal fun capturedReportName(stem: String, local: Path): String { + if (isDirectoryReport(local)) return stem + val extension = local.fileName?.toString()?.substringAfterLast('.', "").orEmpty() + return if (extension.isEmpty()) stem else "$stem.$extension" +} + +/** The name an exported run is offered under: readable, and unique enough to sit in a downloads folder. */ +internal fun exportFileName(manifest: TestoRunManifest): String { + val name = manifest.configurationName.ifEmpty { "testo-run" } + .replace(Regex("[^A-Za-z0-9._-]+"), "-") + .trim('-') + .ifEmpty { "testo-run" } + return "$name-${manifest.startedAt}" +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt new file mode 100644 index 00000000..cfcadc00 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt @@ -0,0 +1,155 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.coverage.dedupeCoverageByFormat +import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoHistoryIndex +import com.github.xepozz.testo.tests.console.TestoReportRef +import com.github.xepozz.testo.tests.console.TestoRunTimings +import com.github.xepozz.testo.tests.console.resolveCoverageDataFile +import com.github.xepozz.testo.tests.console.resolveReport +import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.JDOMUtil +import org.jdom.Element +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +/** + * Finalizes a recorded run once its process ends: closes `output.log`, captures the coverage report files into the + * run's `reports/` (a report path is reused by the next run, the captured copy is not), and writes the manifest — + * whose arrival is what marks the archive complete. Idempotent across the several termination hooks that may fire + * (the run-path ExecutionListener, the debug runner); the first caller wins. + * + * Reports are captured at process termination: Testo writes them before exiting (announced at session start, written + * by the suite-finished listener), the same contract the platform's own coverage loading relies on. + */ +internal object TestoRunArchiver { + private val LOG = Logger.getInstance(TestoRunArchiver::class.java) + + fun finalizeRun(project: Project, props: TestoConsoleProperties) { + if (props.replayMode) return + val recording = props.recording ?: return + if (!recording.tryBeginFinish()) return + ApplicationManager.getApplication().executeOnPooledThread { + try { + recording.closeOutput() + val mapToLocal: (String) -> String? = { runCatching { props.pathMapper.getLocalPath(it) }.getOrNull() } + val writtenAfter = props.reportStore.runStartedAt + // Every report the run announced is kept — the archive is what a replay reads instead of the log, and + // an HTML report is as much part of a run as its coverage. The one exception is a coverage report that + // lost the one-per-format dedup: it is the same run's data under a second path, and it is the loser + // the applied bundle already ignored. + val resolved = props.reportStore.coverage().mapNotNull { ref -> + resolveCoverageDataFile(ref, project, mapToLocal, writtenAfter)?.let { ref to it } + } + val flagKeys = props.coverageFlagPaths.map { TestoCoverageKeys.normalize(it.toString()) }.toSet() + val winners = dedupeCoverageByFormat(resolved, flagKeys).toMap() + val usedNames = HashSet() + val reports = props.reportStore.all().map { ref -> + val local = when { + ref.isCoverage -> winners[ref] + else -> resolveReport(ref, project, mapToLocal, writtenAfter) + } + val stored = local?.let { capture(recording, ref, it, usedNames) } + StoredReport(ref.format, ref.name, ref.path, ref.relativePath, stored) + } + recording.writeLocations() + val finishedAt = System.currentTimeMillis() + recording.writeManifest( + TestoRunManifest( + configurationName = recording.configurationName, + executorId = recording.executorId, + commandLine = props.commandLine.orEmpty(), + configuration = serializeConfiguration(props), + startedAt = recording.startedAt, + finishedAt = finishedAt, + timings = runMarks(props, recording, finishedAt), + retention = recording.retention, + statuses = props.statusStore.counts().entries.associate { it.key.wireName to it.value }, + reports = reports, + ) + ) + TestoRunStore.getInstance(project).prune() + // The archive is the lens's whole source of truth, and it only just became complete — so the lens is + // told here rather than on a timer after the process ends. + TestoHistoryIndex.invalidate() + TestoHistoryIndex.refreshLens(project) + } catch (e: Exception) { + LOG.warn("Failed to archive Testo run ${recording.dir}", e) + } + } + } + + /** + * The toolbar clock's marks, filled in where the run left them open: the process may exit before the clock is + * even wired (a run shorter than the console's own setup), and the archive must still describe a finished run. + */ + private fun runMarks(props: TestoConsoleProperties, recording: TestoRunRecording, finishedAt: Long): TestoRunTimings.Marks { + val marks = props.runTimings.marks() + return marks.copy( + startedAt = marks.startedAt.takeIf { it > 0 } ?: recording.startedAt, + finishedAt = marks.finishedAt.takeIf { it > 0 } ?: finishedAt, + ) + } + + /** + * The run configuration as XML — the same form the IDE persists it in, so a replay can restore it and rerun the + * real thing. Empty when this console is not backed by a Testo configuration (nothing to rerun then). + */ + private fun serializeConfiguration(props: TestoConsoleProperties): String = + (props.configuration as? TestoRunConfiguration)?.let { configuration -> + runCatching { + val element = Element("configuration") + configuration.writeExternal(element) + JDOMUtil.write(element) + }.onFailure { LOG.warn("Failed to serialize the Testo run configuration", it) }.getOrNull() + }.orEmpty() + + /** + * Copies one report into the run's `reports/`, and returns the run-dir-relative path of its **entry** (what the + * announcement pointed at) — or null when the copy failed. A report laid out as a directory travels whole; the + * entry inside it is what the manifest names, so a replay can hand that straight to the report button. + */ + private fun capture(recording: TestoRunRecording, ref: TestoReportRef, local: Path, usedNames: MutableSet): String? = + runCatching { + Files.createDirectories(recording.reportsDir) + val name = uniqueName(capturedReportName(reportStem(ref), local), usedNames) + if (isDirectoryReport(local)) { + copyDirectory(local.parent, recording.reportsDir.resolve(name)) + "${TestoRunRecording.REPORTS_DIR}/$name/${local.fileName}" + } else { + Files.copy(local, recording.reportsDir.resolve(name), StandardCopyOption.REPLACE_EXISTING) + "${TestoRunRecording.REPORTS_DIR}/$name" + } + }.onFailure { LOG.warn("Failed to capture report ${ref.path} of ${ref.format}", it) }.getOrNull() + + private fun reportStem(ref: TestoReportRef): String = + (ref.coverageFormat?.id ?: ref.format).replace(Regex("[^A-Za-z0-9._-]+"), "-").trim('-').ifEmpty { "report" } + + // Two reports can want one name (a CLI flag beside a testo.php writer, or two of an unknown format). + private fun uniqueName(name: String, used: MutableSet): String { + val stem = name.substringBeforeLast('.', name) + val extension = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" } + var candidate = name + var index = 2 + while (!used.add(candidate)) candidate = "$stem-${index++}$extension" + return candidate + } + + private fun copyDirectory(source: Path, target: Path) { + Files.walk(source).use { paths -> + paths.forEach { path -> + val destination = target.resolve(source.relativize(path).toString()) + if (Files.isDirectory(path)) Files.createDirectories(destination) + else { + Files.createDirectories(destination.parent) + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt new file mode 100644 index 00000000..bbcbfcd7 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -0,0 +1,274 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoTestStatus +import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.intellij.execution.ExecutionManager +import com.intellij.execution.ExecutorRegistry +import com.intellij.execution.RunManager +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.runners.ExecutionEnvironmentBuilder +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView +import com.intellij.execution.ui.RunContentManager +import com.intellij.icons.AllIcons +import com.intellij.ide.DataManager +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.JDOMUtil +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.LayeredIcon +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.text.DateFormatUtil +import java.awt.event.MouseEvent +import java.nio.file.Path +import javax.swing.Icon +import javax.swing.JList + +private val LOG = Logger.getInstance("com.github.xepozz.testo.runs.TestoRunHistory") + +/** `Tools | Testo | Testo Run History…`: pick an archived run, replay it into a run tab. */ +class TestoRunHistoryAction : AnAction(TestoBundle.message("testo.runs.history.action"), null, AllIcons.Vcs.History), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = e.project != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + val runs = TestoRunStore.getInstance(project).listRuns() + ApplicationManager.getApplication().invokeLater( + { + if (runs.isEmpty()) { + JBPopupFactory.getInstance() + .createMessage(TestoBundle.message("testo.runs.history.empty")) + .showCenteredInCurrentWindow(project) + return@invokeLater + } + JBPopupFactory.getInstance() + .createPopupChooserBuilder(runs) + .setTitle(TestoBundle.message("testo.runs.history.title")) + .setRenderer(RunHistoryCellRenderer()) + .setItemChosenCallback { (dir, manifest) -> + TestoRunReplayProfile.replay(project, dir, manifest) + } + .createPopup() + .showCenteredInCurrentWindow(project) + }, + project.disposed, + ) + } + } + + private class RunHistoryCellRenderer : ColoredListCellRenderer>() { + override fun customizeCellRenderer( + list: JList>, + value: Pair, + index: Int, + selected: Boolean, + hasFocus: Boolean, + ) { + val manifest = value.second + icon = runHistoryIcon(manifest) + append(manifest.configurationName.ifEmpty { value.first.fileName.toString() }) + append(" — ${DateFormatUtil.formatDateTime(manifest.startedAt)}", SimpleTextAttributes.GRAYED_ATTRIBUTES) + append(" ${runResultSummary(manifest)}", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } +} + +/** `Tools | Testo | Run History Retention`: how many archived runs to keep; pruning runs after each archived run. */ +class TestoRunRetentionGroup : DefaultActionGroup(TestoBundle.message("testo.runs.retention.group"), true), DumbAware { + init { + LIMITS.forEach { add(RetentionOption(it)) } + } + + private class RetentionOption(private val limit: Int) : + ToggleAction(TestoBundle.message("testo.runs.retention.option", limit)), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun isSelected(e: AnActionEvent): Boolean = TestoRunStore.retentionLimit() == limit + + override fun setSelected(e: AnActionEvent, state: Boolean) { + if (state) TestoRunStore.setRetentionLimit(limit) + } + } + + private companion object { + private val LIMITS = listOf(5, 10, 20, 50) + } +} + +/** What a run was started as — all the history list needs to know to draw it. */ +internal enum class TestoRunKind { RUN, DEBUG, COVERAGE } + +internal fun runKindOf(executorId: String?): TestoRunKind = when (executorId) { + TestoCoverageProgramRunner.EXECUTOR_ID -> TestoRunKind.COVERAGE + DefaultDebugExecutor.EXECUTOR_ID -> TestoRunKind.DEBUG + // Also a v1 archive, which recorded no executor: a plain run is the honest guess. + else -> TestoRunKind.RUN +} + +// The tool window icons rather than the toolbar ones: they are the plainer shapes, and the lock overlay has room to +// sit on them. +internal fun runKindIcon(kind: TestoRunKind): Icon = when (kind) { + TestoRunKind.COVERAGE -> AllIcons.Toolwindows.ToolWindowCoverage + TestoRunKind.DEBUG -> AllIcons.Toolwindows.ToolWindowDebugger + TestoRunKind.RUN -> AllIcons.Toolwindows.ToolWindowRun +} + +internal fun runHistoryIcon(manifest: TestoRunManifest): Icon { + val base = runKindIcon(runKindOf(manifest.executorId)) + if (manifest.retention != RunRetention.LOCKED) return base + return LayeredIcon.layeredIcon { arrayOf(base, AllIcons.Nodes.Locked) } +} + +/** How the run ended, as the history list spells it: "145 total, 42 failed". */ +internal fun runResultSummary(manifest: TestoRunManifest): String { + val total = manifest.statuses.values.sum() + if (total == 0) return TestoBundle.message("testo.runs.history.summary.empty") + val failed = manifest.statuses.entries + .filter { TestoTestStatus.fromWire(it.key)?.isProblem == true } + .sumOf { it.value } + return when { + failed > 0 -> TestoBundle.message("testo.runs.history.summary.failed", total.toString(), failed.toString()) + else -> TestoBundle.message("testo.runs.history.summary.passed", total.toString()) + } +} + +/** + * "Show history" for one test: a popup of the archived runs that hold it, newest first, the one already shown in a tab + * in bold. Each row carries the Load-replay / Repeat-run inline buttons (see [RunHistoryRow]). The archive is scanned + * off the EDT, but the open-tab set is read first — RunContentManager is EDT-only. + */ +internal fun showRunHistoryForTest(project: Project, url: String, editor: Editor, event: MouseEvent?) { + val key = runLocationKey(url) + val openDirs = openReplayDirs(project) + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + val store = TestoRunStore.getInstance(project) + // `startsWith("$key::")`, not `startsWith(key)`: `…::testPay` must not answer for `…::testPayment`. + val entries = store.listRuns() + .filter { (dir, _) -> store.readLocations(dir).any { val k = runLocationKey(it); k == key || k.startsWith("$key::") } } + .map { (dir, manifest) -> RunHistoryEntry(dir, manifest, dir.toAbsolutePath().normalize() in openDirs) } + ApplicationManager.getApplication().invokeLater( + { + if (project.isDisposed) return@invokeLater + if (entries.isEmpty()) { + NotificationGroupManager.getInstance().getNotificationGroup("Testo") + ?.createNotification(TestoBundle.message("testo.runs.history.none"), NotificationType.INFORMATION) + ?.notify(project) + return@invokeLater + } + val group = DefaultActionGroup().apply { entries.forEach { add(RunHistoryRow(project, it, url)) } } + val popup = JBPopupFactory.getInstance().createActionGroupPopup( + TestoBundle.message("testo.runs.history.forTest.title"), + group, + DataManager.getInstance().getDataContext(editor.contentComponent), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + false, + ) + if (event != null) popup.show(RelativePoint(event)) else popup.showInBestPositionFor(editor) + }, + project.disposed, + ) + } +} + +private class RunHistoryEntry(val dir: Path, val manifest: TestoRunManifest, val current: Boolean) + +/** The archive dirs a run tab is currently showing (a live run's own, or a replay's), so the list can bold them. */ +private fun openReplayDirs(project: Project): Set = + RunContentManager.getInstance(project).allDescriptors.mapNotNull { descriptor -> + val console = descriptor.executionConsole as? SMTRunnerConsoleView + (console?.properties as? TestoConsoleProperties)?.currentRunDir()?.toAbsolutePath()?.normalize() + }.toSet() + +/** One archived run: Load-replay / Repeat-run inline buttons at the row's right edge; a bare click on the body runs nothing. */ +private class RunHistoryRow(project: Project, entry: RunHistoryEntry, url: String) : AnAction(), DumbAware { + init { + val manifest = entry.manifest + val name = manifest.configurationName.ifEmpty { entry.dir.fileName.toString() } + val text = "$name — ${DateFormatUtil.formatDateTime(manifest.startedAt)} ${runResultSummary(manifest)}" + // Menu items render HTML; the run this tab already shows goes bold, as the history list does. + templatePresentation.text = if (entry.current) "${StringUtil.escapeXmlEntities(text)}" else text + templatePresentation.icon = runHistoryIcon(manifest) + templatePresentation.putClientProperty( + ActionUtil.INLINE_ACTIONS, + listOf(InlineReplay(project, entry, url), InlineRepeat(project, entry)), + ) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + override fun actionPerformed(e: AnActionEvent) = Unit +} + +private class InlineReplay(private val project: Project, private val entry: RunHistoryEntry, private val url: String) : + AnAction( + TestoBundle.message("testo.runs.history.forTest.loadReplay"), + TestoBundle.message("testo.runs.history.forTest.loadReplay"), + AllIcons.Actions.Rollback, + ), DumbAware { + init { templatePresentation.putClientProperty(ActionUtil.ALWAYS_VISIBLE_INLINE_ACTION, true) } + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + override fun actionPerformed(e: AnActionEvent) = TestoRunReplayProfile.replay(project, entry.dir, entry.manifest, url) +} + +private class InlineRepeat(private val project: Project, private val entry: RunHistoryEntry) : + AnAction( + TestoBundle.message("testo.runs.history.forTest.repeat"), + TestoBundle.message("testo.runs.history.forTest.repeat"), + AllIcons.Actions.Restart, + ), DumbAware { + init { templatePresentation.putClientProperty(ActionUtil.ALWAYS_VISIBLE_INLINE_ACTION, true) } + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + override fun actionPerformed(e: AnActionEvent) = repeatArchivedRun(project, entry.dir, entry.manifest) +} + +/** Re-executes an archived run: its own configuration, restored from the manifest, launched with its original executor. */ +private fun repeatArchivedRun(project: Project, runDir: Path, manifest: TestoRunManifest) { + val executor = ExecutorRegistry.getInstance().getExecutorById(manifest.executorId) + ?: DefaultRunExecutor.getRunExecutorInstance() + val name = manifest.configurationName.ifEmpty { runDir.fileName.toString() } + val settings = RunManager.getInstance(project).createConfiguration(name, TestoRunConfigurationType.INSTANCE) + val configuration = settings.configuration as? TestoRunConfiguration ?: return + manifest.configuration.takeIf { it.isNotBlank() }?.let { xml -> + runCatching { configuration.readExternal(JDOMUtil.load(xml)) } + .onFailure { LOG.warn("Failed to restore the run configuration of $runDir", it) } + } + val environment = ExecutionEnvironmentBuilder.createOrNull(executor, settings)?.build() ?: return + ExecutionManager.getInstance(project).restartRunProfile(environment) +} + +/** The lens's fallback when it cannot name a test: replay the newest archived run. */ +internal fun replayNewestRun(project: Project) { + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + val newest = TestoRunStore.getInstance(project).listRuns().firstOrNull() ?: return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater( + { TestoRunReplayProfile.replay(project, newest.first, newest.second) }, + project.disposed, + ) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt new file mode 100644 index 00000000..7ecafe6a --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt @@ -0,0 +1,148 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoHistoryIndex +import com.intellij.CommonBundle +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.text.DateFormatUtil +import java.nio.file.Path + +/** + * The "Test History" button above the test tree, listing this project's archived Testo runs. + * + * It replaces the platform's own `ImportTestsGroup`, which our `createImportActions()` used to inherit from + * `SMTRunnerConsoleProperties`: that one lists the platform's history XMLs and opens them through the import + * machinery — a foreign console with none of the Testo UI. Ours replays the archive instead + * ([TestoRunReplayProfile]), so the reopened run looks and behaves like the run it was. + * + * Children are built on a background thread ([ActionUpdateThread.BGT]), which is what lets them read the archive. + */ +class TestoRunHistoryGroup( + private val project: Project, + /** This tab's own run, so the list can say which entry the user is looking at and spare it when cleared. */ + private val props: TestoConsoleProperties, +) : ActionGroup( + TestoBundle.messagePointer("testo.runs.history.group"), + TestoBundle.messagePointer("testo.runs.history.group.description"), + { AllIcons.Vcs.History }, +), DumbAware { + + init { + isPopup = true + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun getChildren(e: AnActionEvent?): Array { + if (e == null || project.isDisposed) return EMPTY_ARRAY + val runs = TestoRunStore.getInstance(project).listRuns() + val current = runCatching { props.currentRunDir()?.toAbsolutePath()?.normalize() }.getOrNull() + return buildList { + if (runs.isEmpty()) add(NoRuns()) + runs.forEach { (dir, manifest) -> + add(ReplayRun(project, dir, manifest, dir.toAbsolutePath().normalize() == current)) + } + add(Separator.getInstance()) + // How much of this list is kept belongs with the list itself, not in a menu three clicks away. + add(TestoRunRetentionGroup()) + add(ClearHistory(project, props)) + }.toTypedArray() + } + + private class ReplayRun( + private val project: Project, + private val dir: Path, + private val manifest: TestoRunManifest, + current: Boolean, + ) : AnAction( + label(dir, manifest, current), + null, + runHistoryIcon(manifest), + ), DumbAware { + override fun actionPerformed(e: AnActionEvent) = TestoRunReplayProfile.replay(project, dir, manifest) + + private companion object { + fun label(dir: Path, manifest: TestoRunManifest, current: Boolean): String { + val name = manifest.configurationName.ifEmpty { dir.fileName.toString() } + val at = DateFormatUtil.formatDateTime(manifest.startedAt) + val text = "$name — $at ${runResultSummary(manifest)}" + // The run this tab is already showing, in bold — menu items render HTML, and there is no other way + // to weight one of them. + return if (current) "${StringUtil.escapeXmlEntities(text)}" else text + } + } + } + + /** + * Deletes archived runs — output, reports and all. Asks first, and separately about the locked ones: locking a run + * is the one way to say "not this one", so a blanket delete must not be the only offer. + */ + private class ClearHistory( + private val project: Project, + private val props: TestoConsoleProperties, + ) : AnAction( + TestoBundle.message("testo.runs.history.clear"), + null, + AllIcons.Actions.GC, + ), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) { + val choice = Messages.showDialog( + project, + TestoBundle.message("testo.runs.history.clear.confirm"), + TestoBundle.message("testo.runs.history.clear"), + arrayOf( + TestoBundle.message("testo.runs.history.clear.unlocked"), + TestoBundle.message("testo.runs.history.clear.all"), + CommonBundle.getCancelButtonText(), + ), + 0, + Messages.getWarningIcon(), + ) + if (choice != KEEP_LOCKED && choice != DELETE_ALL) return + val current = runCatching { props.currentRunDir() }.getOrNull() + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + val store = TestoRunStore.getInstance(project) + store.clearHistory(keepLocked = choice == KEEP_LOCKED, spare = current) + // A run still being recorded has no manifest to rewrite, so the choice is put on the recording — which + // is also what the archiver will write out. A finished run answers from its manifest instead. + props.recording?.let { recording -> + if (store.retentionOf(recording.dir) == null && recording.dir == current) { + recording.retention = RunRetention.DISCARD + } + } + // Every lens was answered off the archive that just went away. + TestoHistoryIndex.invalidate() + TestoHistoryIndex.refreshLens(project) + } + } + + private companion object { + private const val KEEP_LOCKED = 0 + private const val DELETE_ALL = 1 + } + } + + private class NoRuns : AnAction(TestoBundle.message("testo.runs.history.empty")), DumbAware { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = false + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) = Unit + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt new file mode 100644 index 00000000..d664795c --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt @@ -0,0 +1,63 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.tests.console.TestoRunTimings +import com.google.gson.annotations.SerializedName + +/** + * One report of an archived run, as announced by `##teamcity[testoReport …]` plus where its captured copy sits. + * [stored] is run-dir-relative (`reports/cobertura.xml`, or `reports/coverage-xml` — a directory); null when the file + * was not captured (a non-coverage report, or one that never appeared on disk). + */ +data class StoredReport( + val format: String = "", + val name: String? = null, + val path: String = "", + val relativePath: String? = null, + val stored: String? = null, +) + +/** What retention is allowed to do with an archived run. Chosen per run, from the tab's *Replay* group. */ +enum class RunRetention { + /** The default: kept until the newest-N rotation drops it. */ + AUTO, + + /** Dropped at the next prune, and hidden from the history at once. */ + DISCARD, + + /** Never rotated out, and left alone by "clear history": the user locked this one. */ + @SerializedName(value = "LOCKED", alternate = ["PINNED"]) + LOCKED, +} + +/** + * `run.json` — the metadata of one archived run. Written once at run end; its presence is what marks a run directory + * as complete (a directory without one is a run that crashed mid-flight and is swept by retention). + * + * [executorId] and [statuses] exist for the history chooser alone: it shows what kind of run this was and how it ended + * without replaying it. A v1 manifest has neither, and renders as a plain run with no tally. + */ +data class TestoRunManifest( + val v: Int = VERSION, + val configurationName: String = "", + /** `Run` / `Debug` / `Coverage` — the executor the run was started with. */ + val executorId: String = "", + /** The process command line, as the console header printed it. */ + val commandLine: String = "", + /** The run configuration as XML (`RunConfiguration.writeExternal`), so a replayed tab can rerun the real thing. */ + val configuration: String = "", + val startedAt: Long = 0, + val finishedAt: Long = 0, + /** + * The toolbar clock's own marks. Kept apart from [startedAt]/[finishedAt], which bracket the *archive*: these are + * what the run summary renders (and breaks into startup / tests / post-processing). + */ + val timings: TestoRunTimings.Marks = TestoRunTimings.Marks(), + val retention: RunRetention = RunRetention.AUTO, + /** [com.github.xepozz.testo.tests.console.TestoTestStatus.wireName] → how many tests ended that way. */ + val statuses: Map = emptyMap(), + val reports: List = emptyList(), +) { + companion object { + const val VERSION = 3 + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt new file mode 100644 index 00000000..a06420ab --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt @@ -0,0 +1,123 @@ +package com.github.xepozz.testo.runs + +import com.google.gson.Gson +import java.io.Writer +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.concurrent.atomic.AtomicBoolean + +/** + * One live run being written into its archive directory. The output stream is framed as JSON-lines + * (`{"s":1,"t":"…"}`, `s` = 1 stdout / 2 stderr / 3 system) so a replay can re-emit the exact chunks in order; Gson's + * escaping keeps one chunk on one line whatever the text holds. Chunks come off the process-output thread, the + * finalizer off a pooled one — hence the lock. + */ +class TestoRunRecording internal constructor( + val dir: Path, + val configurationName: String, + val executorId: String, + val startedAt: Long, +) { + private val lock = Any() + private val gson = Gson() + private var writer: Writer? = null + private var closed = false + private val finishing = AtomicBoolean() + private val locations = LinkedHashSet() + + /** What the tab's *Replay* group says to do with this run once it is archived. */ + @Volatile + var retention: RunRetention = RunRetention.AUTO + + val reportsDir: Path get() = dir.resolve(REPORTS_DIR) + + fun appendChunk(stream: Int, text: String) { + synchronized(lock) { + if (closed) return + val target = writer + ?: Files.newBufferedWriter(dir.resolve(OUTPUT_FILE), StandardCharsets.UTF_8).also { writer = it } + gson.toJson(Chunk(stream, text), target) + target.write("\n") + } + } + + fun closeOutput() { + synchronized(lock) { + closed = true + runCatching { writer?.close() } + writer = null + } + } + + /** True for exactly one caller — the run is finalized from more than one termination hook. */ + fun tryBeginFinish(): Boolean = finishing.compareAndSet(false, true) + + /** Remembers a test this run announced, so the "Show history" lens can tell which archive holds it. */ + fun noteLocation(hint: String) { + val key = normalizeRunLocation(hint) + if (key.isEmpty()) return + synchronized(lock) { + if (locations.size < MAX_LOCATIONS) locations.add(key) + } + } + + fun writeManifest(manifest: TestoRunManifest) = writeManifestFile(dir, manifest, gson) + + fun writeLocations() { + val snapshot = synchronized(lock) { locations.toList() } + if (snapshot.isEmpty()) return + Files.write(dir.resolve(TESTS_FILE), snapshot, StandardCharsets.UTF_8) + } + + /** One framed line of `output.log`. Short field names: there is a line per output chunk. */ + data class Chunk(val s: Int = STDOUT, val t: String = "") + + companion object { + const val STDOUT = 1 + const val STDERR = 2 + const val SYSTEM = 3 + + const val OUTPUT_FILE = "output.log" + const val MANIFEST_FILE = "run.json" + const val TESTS_FILE = "tests.txt" + const val REPORTS_DIR = "reports" + + // A suite of a few thousand tests writes a few thousand short lines; the cap only stops a pathological run + // (a data provider yielding tens of thousands of sets) from holding the whole list in memory. + private const val MAX_LOCATIONS = 50_000 + } +} + +/** + * The form of a location hint the archive stores and the lens looks up: the test's own url, without the dataset + * coordinates Testo appends. The lens asks about a method (`…::testFoo`), and every dataset of that method + * (`…::testFoo#3`) has to answer for it — collapsing them here also keeps one entry per test rather than per dataset. + */ +internal fun normalizeRunLocation(hint: String): String = + hint.substringBefore('#').substringBefore(" with data set").trim() + +/** + * Compare-ready form: [normalizeRunLocation] plus `\`→`/`. The lens builds its hint from PSI via the local path + * processor (OS-native `D:\…` on Windows), while Testo emits `D:/…` — folding separators makes the two match. + */ +internal fun runLocationKey(hint: String): String = + normalizeRunLocation(hint).replace('\\', '/') + +/** + * Writes `run.json` through a temp file and an atomic move, so a concurrent reader (`listRuns`/`prune`/`retentionOf`, + * all off other pooled threads) never sees a half-written manifest — for which `prune` would read null and, on a + * day-old run, delete it. + */ +internal fun writeManifestFile(dir: Path, manifest: TestoRunManifest, gson: Gson) { + val target = dir.resolve(TestoRunRecording.MANIFEST_FILE) + val tmp = Files.createTempFile(dir, "run", ".json.tmp") + try { + Files.writeString(tmp, gson.toJson(manifest), StandardCharsets.UTF_8) + runCatching { Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE) } + .getOrElse { Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING) } + } finally { + runCatching { Files.deleteIfExists(tmp) } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt new file mode 100644 index 00000000..ac721143 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt @@ -0,0 +1,200 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.TestoIcons +import com.github.xepozz.testo.coverage.TestoCoverageReport +import com.github.xepozz.testo.coverage.applyTestoCoverage +import com.github.xepozz.testo.coverage.closeTestoCoverage +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.console.TestoConsoleAugmenter +import com.github.xepozz.testo.tests.console.TestoReplaySelection +import com.github.xepozz.testo.tests.console.TestoReportRef +import com.github.xepozz.testo.tests.console.TestoRunTimings +import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.intellij.execution.DefaultExecutionResult +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.process.NopProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.ExecutionEnvironmentBuilder +import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.JDOMUtil +import com.intellij.openapi.util.Key +import java.nio.file.Files +import java.nio.file.Path +import javax.swing.Icon + +/** + * Replays an archived run: feeds the recorded output through a [NopProcessHandler] into a console built on the *live* + * [TestoConsoleProperties], so the whole UI — channel tabs, statuses, node tree, report buttons — is reconstructed by + * the same converter that built it originally. No import machinery, no metainfo. + * + * Three switches keep a replay from acting like a run: [TestoConsoleProperties.replayMode] stops the converter from + * recording the replayed stream into a new archive; `getConfiguration()` answers this profile instead of the throwaway + * configuration, so the platform's `addToHistory` (which saves only for a real `RunConfiguration`) skips it; and the + * report store's clock is pinned to the original run, so the captured report copies pass the mtime-vs-start gate. + */ +internal class TestoRunReplayProfile( + private val project: Project, + /** The archive this tab shows — what the tab's *Replay* group exports, pins or throws away. */ + val runDir: Path, + private val manifest: TestoRunManifest, + /** The test the "Show history" lens was clicked on: its node is selected once the replayed tree is built. */ + private val targetUrl: String? = null, +) : RunProfile { + + /** The executor the archived run used — what the tab's rerun button offers, whatever executor opened the replay. */ + val executorId: String get() = manifest.executorId + + /** + * The archived run's own configuration, restored from the manifest — what the rerun buttons on a replayed tab + * run. Falls back to a bare template for an archive that predates the recording (nothing to rerun there, but the + * console still needs a configuration to be built from). + */ + val testoConfiguration: TestoRunConfiguration by lazy { + val configuration = TestoRunConfigurationType.INSTANCE.createTemplateConfiguration(project) + manifest.configuration.takeIf { it.isNotBlank() }?.let { xml -> + runCatching { configuration.readExternal(JDOMUtil.load(xml)) } + .onFailure { LOG.warn("Failed to restore the run configuration of $runDir", it) } + } + configuration.name = manifest.configurationName.ifEmpty { runDir.fileName.toString() } + configuration + } + + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState = + RunProfileState { _, _ -> + val configuration = testoConfiguration + + val props = configuration.createTestConsoleProperties(executor) as TestoConsoleProperties + props.replayMode = true + props.replayProfile = this + props.reportStore.startedAtOverride = manifest.startedAt + // The toolbar clock shows the archived run, frozen: the replayed stream would otherwise restamp every mark + // with today's time, and a replay that outruns the toolbar's own wiring would leave it counting forever. + // An archive from before the marks were recorded still has the two the recording itself brackets it with. + val marks = manifest.timings.takeIf { !it.isEmpty } + ?: TestoRunTimings.Marks(startedAt = manifest.startedAt, finishedAt = manifest.finishedAt) + if (!marks.isEmpty) props.runTimings.restore(marks) + seedReports(props) + // The command line is not part of the recorded stream (the live run puts it on the channel store, not the + // process output), so the header is reprinted from the manifest — with the original run's clock. + manifest.commandLine.takeIf { it.isNotBlank() }?.let { commandLine -> + props.commandLine = commandLine + props.channelStore.setHeader(TestoConsoleAugmenter.runHeader(commandLine, manifest.startedAt)) + } + + val handler = NopProcessHandler() + val console = SMTestRunnerConnectionUtil.createAndAttachConsole("Testo", handler, props) + handler.addProcessListener(object : ProcessAdapter() { + override fun startNotified(event: ProcessEvent) { + ApplicationManager.getApplication().executeOnPooledThread { + feed(handler) + applyArchivedCoverage() + val url = targetUrl ?: return@executeOnPooledThread + (console as? SMTRunnerConsoleView)?.let { TestoReplaySelection.selectWhenReady(it, url) } + } + } + }) + DefaultExecutionResult(console, handler) + } + + private fun feed(handler: ProcessHandler) { + try { + TestoRunStore.getInstance(project).readChunks(runDir) { stream, text -> + handler.notifyTextAvailable(text, keyOf(stream)) + } + } catch (e: Exception) { + LOG.warn("Testo run replay failed for $runDir", e) + } finally { + handler.destroyProcess() + } + } + + /** + * Fills the report buttons from the archive rather than from the replayed announcements: the recorded log names + * paths a later run has since overwritten, while the archive holds this run's own copies. A coverage report with + * no copy lost the dedup to one that has it, so it is not offered at all. + */ + private fun seedReports(props: TestoConsoleProperties) { + manifest.reports.forEach { report -> + val captured = report.stored?.let { runDir.resolve(it).toAbsolutePath().toString() } + if (captured == null && CoverageFormat.fromId(report.format) != null) return@forEach + props.reportStore.note( + TestoReportRef( + format = report.format, + path = captured ?: report.path, + // A captured copy is already a local absolute path; nothing is left to resolve it from. + relativePath = if (captured != null) null else report.relativePath, + name = report.name, + schemaVersion = null, + ) + ) + } + } + + /** + * Brings the Coverage tool window in line with the run being opened: its own captured reports, or — for a run that + * produced none — nothing at all. Leaving whatever the previous session applied would attribute one run's coverage + * to another; a replay is a whole state, not an addition to the current one. + */ + private fun applyArchivedCoverage() { + val reports = manifest.reports + .mapNotNull { report -> + val stored = report.stored ?: return@mapNotNull null + val format = CoverageFormat.fromId(report.format) ?: return@mapNotNull null + // The manifest names the report's entry file, which is what the loader consumes — for coverage-xml + // that is the index.xml inside the captured directory. + val dataFile = runDir.resolve(stored) + if (!Files.exists(dataFile)) null else format to TestoCoverageReport(report.name, format, dataFile) + } + // One per format: a CLI-flag report and a testo.php-configured one of the same format hold the same run's + // data, and merging both would count every hit twice. + .associate { it } + .values.toList() + ApplicationManager.getApplication().invokeLater( + { + if (reports.isEmpty()) closeTestoCoverage(project) else applyTestoCoverage(project, reports) + }, + project.disposed, + ) + } + + private fun keyOf(stream: Int): Key<*> = when (stream) { + TestoRunRecording.STDERR -> ProcessOutputTypes.STDERR + TestoRunRecording.SYSTEM -> ProcessOutputTypes.SYSTEM + else -> ProcessOutputTypes.STDOUT + } + + override fun getName(): String = + TestoBundle.message("testo.runs.replay.name", manifest.configurationName.ifEmpty { runDir.fileName.toString() }) + + override fun getIcon(): Icon = TestoIcons.TESTO + + companion object { + private val LOG = Logger.getInstance(TestoRunReplayProfile::class.java) + + /** Opens the archived run in a run tab. Call on the EDT. */ + fun replay(project: Project, runDir: Path, manifest: TestoRunManifest, targetUrl: String? = null) { + try { + val executor = DefaultRunExecutor.getRunExecutorInstance() + ExecutionEnvironmentBuilder + .create(project, executor, TestoRunReplayProfile(project, runDir, manifest, targetUrl)) + .buildAndExecute() + } catch (e: Exception) { + LOG.warn("Failed to start Testo run replay for $runDir", e) + } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt new file mode 100644 index 00000000..7d0f7e4c --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -0,0 +1,194 @@ +package com.github.xepozz.testo.runs + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.name + +/** + * The Testo run archive: one directory per run under the IDE system dir, holding the raw teamcity output + * (`output.log`), the metadata (`run.json`) and the captured report files (`reports/`). The whole console — channels, + * statuses, node tree, report buttons — is built by parsing that stream, so replaying it through the live converter + * reconstructs the run exactly; nothing else needs persisting. + * + * Retention is the plugin's own (the platform's 10-file history rotation deletes with a bare `FileUtil.delete`, no + * event to hook): the newest [retentionLimit] *complete* runs are kept, plus incomplete directories younger than a + * day — a run in flight looks incomplete until its manifest lands. + */ +@Service(Service.Level.PROJECT) +class TestoRunStore(private val project: Project) { + private val gson = Gson() + + fun root(): Path = Path.of(PathManager.getSystemPath(), "testo", "runs", project.locationHash) + + fun beginRun(configurationName: String, executorId: String): TestoRunRecording { + val startedAt = System.currentTimeMillis() + val dir = root().resolve("$startedAt-${FileUtil.sanitizeFileName(configurationName)}") + Files.createDirectories(dir) + return TestoRunRecording(dir, configurationName, executorId, startedAt) + } + + /** + * Complete runs (manifest present) the user has not thrown away, newest first. Touches the filesystem — call off + * the EDT. + */ + fun listRuns(): List> = runDirectories() + .mapNotNull { dir -> readManifest(dir)?.let { dir to it } } + .filter { it.second.retention != RunRetention.DISCARD } + .sortedByDescending { it.second.startedAt } + + /** The archived run's retention, or null while the run is still in flight — its choice rides on the recording. */ + fun retentionOf(dir: Path): RunRetention? = readManifest(dir)?.retention + + /** Rewrites the manifest's retention; a no-op while the run is still in flight. */ + fun setRetention(dir: Path, retention: RunRetention) { + val manifest = readManifest(dir) ?: return + runCatching { writeManifest(dir, manifest.copy(retention = retention)) } + .onFailure { LOG.warn("Failed to set retention of $dir", it) } + } + + /** + * Unpacks an exported run into the archive and returns it. The directory is named after the run it holds, so it + * sorts with the rest; the manifest is what decides the zip was one of ours at all. + * + * An imported run comes in locked: it was carried here by hand, often from another machine, and rotation deleting + * it after ten local runs would throw away the one copy that exists. + */ + fun importRun(zip: Path): Pair? { + val staging = root().resolve("import-${System.currentTimeMillis()}") + return runCatching { + unzipRunDirectory(zip, staging) + val imported = readManifest(staging) ?: run { + FileUtil.delete(staging) + return null + } + val manifest = imported.copy(retention = RunRetention.LOCKED) + val target = freeDirectory(manifest) + Files.move(staging, target) + writeManifest(target, manifest) + target to manifest + }.onFailure { + LOG.warn("Failed to import a Testo run from $zip", it) + runCatching { FileUtil.delete(staging) } + }.getOrNull() + } + + private fun freeDirectory(manifest: TestoRunManifest): Path { + val stem = "${manifest.startedAt}-${FileUtil.sanitizeFileName(manifest.configurationName)}" + var candidate = root().resolve(stem) + var index = 2 + while (candidate.exists()) candidate = root().resolve("$stem-${index++}") + return candidate + } + + private fun writeManifest(dir: Path, manifest: TestoRunManifest) = writeManifestFile(dir, manifest, gson) + + fun readManifest(dir: Path): TestoRunManifest? = runCatching { + val file = dir.resolve(TestoRunRecording.MANIFEST_FILE) + if (!file.exists()) return null + // Every field has a default, so Gson deserializes a bare `{}` — or any foreign JSON object — into a + // valid-looking v=VERSION manifest. Require `v` to be spelled out; a manifest we wrote always carries it. + val root = gson.fromJson(Files.readString(file, StandardCharsets.UTF_8), JsonObject::class.java) ?: return null + if (!root.has("v") || root.get("v").asInt < 1) return null + gson.fromJson(root, TestoRunManifest::class.java) + }.getOrNull() + + /** The tests the run announced, as [normalizeRunLocation] keys. Empty for a v1 archive, which recorded none. */ + fun readLocations(dir: Path): Set = runCatching { + val file = dir.resolve(TestoRunRecording.TESTS_FILE) + if (!file.exists()) return emptySet() + Files.readAllLines(file, StandardCharsets.UTF_8).filterTo(LinkedHashSet()) { it.isNotBlank() } + }.getOrDefault(emptySet()) + + /** Streams `output.log` back in recorded order. Skips lines that fail to parse rather than aborting the replay. */ + fun readChunks(dir: Path, consumer: (stream: Int, text: String) -> Unit) { + val file = dir.resolve(TestoRunRecording.OUTPUT_FILE) + if (!file.exists()) return + Files.newBufferedReader(file, StandardCharsets.UTF_8).useLines { lines -> + for (line in lines) { + val chunk = runCatching { gson.fromJson(line, TestoRunRecording.Chunk::class.java) }.getOrNull() ?: continue + consumer(chunk.s, chunk.t) + } + } + } + + /** Applies retention. Called after each archived run, off the EDT. */ + fun prune() { + val keep = retentionLimit() + val now = System.currentTimeMillis() + val rotating = ArrayList>() + for (dir in runDirectories()) { + val manifest = readManifest(dir) + when { + manifest == null -> + // A directory that never got its manifest: the run crashed or the IDE died mid-write. + if (now - startedAtOf(dir) > INCOMPLETE_GRACE_MS) delete(dir) + manifest.retention == RunRetention.DISCARD -> delete(dir) + manifest.retention == RunRetention.LOCKED -> Unit + else -> rotating += dir to manifest.startedAt + } + } + rotating.sortedByDescending { it.second }.drop(keep).forEach { delete(it.first) } + } + + /** + * Clears the history. Touches the filesystem — call off the EDT. + * + * @param keepLocked leave [RunRetention.LOCKED] runs where they are. + * @param spare the run a tab is currently showing: it is marked [RunRetention.DISCARD] rather than deleted, so the + * open tab keeps the files it is built on — it leaves the history now and the disk at the next prune, and + * setting *Keep* on that tab brings it back. + */ + fun clearHistory(keepLocked: Boolean, spare: Path?) { + val spared = spare?.let { runCatching { it.toAbsolutePath().normalize() }.getOrNull() } + val now = System.currentTimeMillis() + for (dir in runDirectories()) { + val retention = retentionOf(dir) + // No manifest yet = possibly another tab's run still being written — same grace as prune(). + if (retention == null && now - startedAtOf(dir) <= INCOMPLETE_GRACE_MS) continue + if (keepLocked && retention == RunRetention.LOCKED) continue + if (dir.toAbsolutePath().normalize() == spared) { + setRetention(dir, RunRetention.DISCARD) + continue + } + delete(dir) + } + } + + private fun runDirectories(): List = runCatching { + Files.list(root()).use { stream -> stream.filter { it.isDirectory() }.toList() } + }.getOrDefault(emptyList()) + + private fun startedAtOf(dir: Path): Long = + dir.name.substringBefore('-').toLongOrNull() + ?: runCatching { Files.getLastModifiedTime(dir).toMillis() }.getOrDefault(0L) + + private fun delete(dir: Path) { + runCatching { FileUtil.delete(dir) }.onFailure { LOG.warn("Failed to delete archived Testo run $dir", it) } + } + + companion object { + private val LOG = Logger.getInstance(TestoRunStore::class.java) + private const val RETENTION_KEY = "testo.runs.retention" + private const val RETENTION_DEFAULT = 10 + private const val INCOMPLETE_GRACE_MS = 24L * 60 * 60 * 1000 + + fun getInstance(project: Project): TestoRunStore = project.getService(TestoRunStore::class.java) + + fun retentionLimit(): Int = + PropertiesComponent.getInstance().getInt(RETENTION_KEY, RETENTION_DEFAULT).coerceAtLeast(1) + + fun setRetentionLimit(limit: Int) = + PropertiesComponent.getInstance().setValue(RETENTION_KEY, limit, RETENTION_DEFAULT) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt index 7a734e69..3478e491 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -52,13 +52,43 @@ class TestoConsoleProperties( val progressAction = TestoProgressAction() + // The run being archived (runs.TestoRunStore) — created lazily by the converter on the first output chunk, + // finalized by TestoRunArchiver on process termination. Null on replays and before any output. + @Volatile + var recording: com.github.xepozz.testo.runs.TestoRunRecording? = null + + /** True on a replayed archive: the converter must not re-record the stream, the archiver must not re-archive it. */ + var replayMode = false + + // The process command line, as the console header shows it. Captured when the channel tabs are installed (the one + // place holding the ProcessHandler) and archived, so a replay can reprint the header of the run it replays. + @Volatile + var commandLine: String? = null + + // A replay's console answers this profile as its "configuration". The platform's addToHistory saves a run into its + // own history only for a real RunConfiguration — the throwaway configuration a replay is built on must stay hidden, + // or every replay would spawn a new platform-history entry (and re-write per-test states). + var replayProfile: com.intellij.execution.configurations.RunProfile? = null + + // The coverage report files each `--coverage-*` flag of this run points at, set by the Coverage runner. They win + // the one-per-format dedup — over a report a testo.php writer put somewhere the IDE does not control. + @Volatile + var coverageFlagPaths: List = emptyList() + // getLocalPath, not getLocalFile: the report was written moments ago and the VFS may not know the file yet. - val reportsAction = TestoReportsAction(reportStore, project) { pathMapper.getLocalPath(it) } + val reportsAction = TestoReportsAction(reportStore, project) { path -> pathMapper.getLocalPath(path) } // Guards the channel-tab install: set once whoever wires the tabs first (the run-path ExecutionListener or the // debug runner, which installs them directly), so the other side is a no-op instead of a double install. var channelsInstalled = false + override fun getConfiguration(): com.intellij.execution.configurations.RunProfile = + replayProfile ?: super.getConfiguration() + + /** The archive this tab stands for: the one a history tab replays, or the one a live run is recorded into. */ + fun currentRunDir(): java.nio.file.Path? = + (replayProfile as? com.github.xepozz.testo.runs.TestoRunReplayProfile)?.runDir ?: recording?.dir + override fun createTestEventsConverter( testFrameworkName: String, consoleProperties: TestConsoleProperties, @@ -67,7 +97,6 @@ class TestoConsoleProperties( testFrameworkName, consoleProperties, channelStore, - levelFilter, statusStore, runTimings, targetStore, @@ -96,15 +125,21 @@ class TestoConsoleProperties( override fun isIdBasedTestTree() = true - // The log-level filter belongs on the test results toolbar's visible row. Adding it here (rather than via - // appendAdditionalActions, which the platform routes into the gear submenu) puts it among the primary actions at - // construction time — so it survives the snapshot that RunTab merges into the run tab's toolbar, and it shows in - // the standalone debug console toolbar too. + // Our own actions on the test results toolbar's visible row. Added here (rather than via appendAdditionalActions, + // which the platform routes into the gear submenu) they land among the primary actions at construction time — so + // they survive the snapshot that RunTab merges into the run tab's toolbar, and show in the standalone debug + // console toolbar too. public override fun createImportActions(): Array = arrayOf( - com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), - *(super.createImportActions() ?: emptyArray()), - // Right-aligned actions are laid out from the right edge inwards: listed first = furthest right. + // Laid out from the right edge inwards: listed first = furthest right. + com.github.xepozz.testo.runs.TestoReplayGroup(project, this), + // Deliberately not super's: that array is where the platform's own "Test History" comes from, and its + // entries open a saved XML through the import machinery — a console that is none of ours. + com.github.xepozz.testo.runs.TestoRunHistoryGroup(project, this), + com.intellij.openapi.actionSystem.Separator.getInstance(), + // The platform's own pair is stuck in the overflow group and cannot be moved (see TestoTreeToolbarActions). + com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), + com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), reportsAction, progressAction, ) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt index 2c38fde2..450a2f2c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoRerunWithExecutorAction.kt @@ -1,6 +1,7 @@ package com.github.xepozz.testo.tests.actions import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.runs.TestoRunReplayProfile import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.execution.ExecutionManager import com.intellij.execution.ExecutorRegistry @@ -20,6 +21,7 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.ExecutionDataKeys +import com.intellij.openapi.actionSystem.LangDataKeys import com.intellij.openapi.actionSystem.SplitButtonAction import com.intellij.openapi.project.DumbAware import javax.swing.Icon @@ -30,14 +32,72 @@ internal fun ExecutionEnvironment.testoRunProfile(): RunProfile? = when (val profile = runProfile) { is TestoRunConfiguration -> profile is WrappingRunConfiguration<*> -> profile.peer as? TestoRunConfiguration - // An imported Testo history tab — recognize it so the rerun split button shows there too. Its rerun runs the - // original reconstructed configuration. - is com.github.xepozz.testo.tests.console.TestoImportRunProfile -> profile.testoConfiguration as? TestoRunConfiguration + // A replayed archive: rerun runs the configuration the archived run was started with, restored from its + // manifest. (An archive that predates that recording restores a bare template — it reruns nothing useful, + // but nothing destructive either.) + is TestoRunReplayProfile -> profile.testoConfiguration else -> null } internal fun ExecutionEnvironment.isTestoRunTab(): Boolean = testoRunProfile() != null +/** + * The executor a rerun of this tab should use: the tab's own, except on a replayed archive — that tab is opened by the + * Run executor whatever it holds, so a rerun there follows the *archived* run instead (a coverage archive reruns with + * coverage). + */ +internal fun ExecutionEnvironment.testoRerunExecutorId(): String { + val archived = (runProfile as? TestoRunReplayProfile)?.executorId + ?.takeIf { ExecutorRegistry.getInstance().getExecutorById(it) != null } + return archived ?: executor.id +} + +internal fun ExecutionEnvironment.isTestoReplay(): Boolean = runProfile is TestoRunReplayProfile + +/** + * Launches [target] under [executorId] the way the platform's own executor action does — through + * `RunnerAndConfigurationSettings`, so the executor's `RunnerSettings` are attached (e.g. `CoverageRunnerData`, without + * which the Coverage tool window never opens). + */ +internal fun relaunchTesto(e: AnActionEvent, environment: ExecutionEnvironment, target: RunProfile, executorId: String) { + val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) ?: return + val settings = settingsFor(environment, target) ?: return + val relaunch = ExecutionEnvironmentBuilder.createOrNull(executor, settings) + ?.dataContext(e.dataContext) + ?.build() + ?: return + ExecutionManager.getInstance(relaunch.project).restartRunProfile(relaunch) +} + +// Reuse the tab's saved settings when they describe this exact config; for the "rerun failed" clone and a replay's +// restored configuration (neither lives in RunManager) wrap it in throwaway settings so the RunnerSettings are created. +private fun settingsFor(environment: ExecutionEnvironment, target: RunProfile): RunnerAndConfigurationSettings? { + environment.runnerAndConfigurationSettings + ?.takeIf { it.configuration === target } + ?.let { return it } + val configuration = target as? RunConfiguration ?: return null + val factory = configuration.factory ?: return null + return RunManager.getInstance(configuration.project).createConfiguration(configuration, factory) +} + +// A double-click delivers two actionPerformed a millisecond or two apart; both launches then start in the same +// millisecond and interleave their output into one console and one archive directory (TestoRunStore.beginRun keys +// the run dir by name + start time). A human cannot hit two *different* buttons that fast, so a per-button cooldown +// catches the only realistic sub-millisecond case — the same button firing twice. +private const val RELAUNCH_COOLDOWN_MS = 400L + +private class LaunchThrottle { + private var lastAt = 0L + + /** True at most once per [RELAUNCH_COOLDOWN_MS]. EDT-confined (every caller is an actionPerformed), so unlocked. */ + fun tryLaunch(): Boolean { + val now = System.currentTimeMillis() + if (now - lastAt < RELAUNCH_COOLDOWN_MS) return false + lastAt = now + return true + } +} + open class TestoRerunWithExecutorAction( text: String, icon: Icon, @@ -45,6 +105,8 @@ open class TestoRerunWithExecutorAction( private val hideWhenCurrent: Boolean = false, ) : AnAction(text, null, icon), DumbAware { + private val throttle = LaunchThrottle() + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { @@ -65,29 +127,10 @@ open class TestoRerunWithExecutorAction( } override fun actionPerformed(e: AnActionEvent) { + if (!throttle.tryLaunch()) return val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return val target = environment.testoRunProfile() ?: return - val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) ?: return - // Build the env from RunnerAndConfigurationSettings, the way the platform executor action does, so the - // executor's own RunnerSettings are attached (e.g. CoverageRunnerData — without it the Coverage tool window - // never opens). - val settings = settingsFor(environment, target) ?: return - val relaunch = ExecutionEnvironmentBuilder.createOrNull(executor, settings) - ?.dataContext(e.dataContext) - ?.build() - ?: return - ExecutionManager.getInstance(relaunch.project).restartRunProfile(relaunch) - } - - // Reuse the tab's saved settings when they describe this exact config; for the "rerun failed" clone (which lives - // outside RunManager) wrap it in throwaway settings so the executor's RunnerSettings are still created. - private fun settingsFor(environment: ExecutionEnvironment, target: RunProfile): RunnerAndConfigurationSettings? { - environment.runnerAndConfigurationSettings - ?.takeIf { it.configuration === target } - ?.let { return it } - val configuration = target as? RunConfiguration ?: return null - val factory = configuration.factory ?: return null - return RunManager.getInstance(configuration.project).createConfiguration(configuration, factory) + relaunchTesto(e, environment, target, executorId) } } @@ -115,20 +158,52 @@ class TestoRerunWithCoverageAction : TestoRerunWithExecutorAction( // The split button's main action: reruns the current tab's environment with its own executor. Mirrors the platform // "Rerun" (per-executor icon + restart-current) through public API, so it needs no internal FakeRerunAction. class TestoRerunCurrentAction : AnAction(), DumbAware { + private val throttle = LaunchThrottle() + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) e.presentation.isEnabledAndVisible = environment != null - if (environment != null) e.presentation.icon = environment.executor.icon ?: AllIcons.Actions.Restart + if (environment != null) e.presentation.icon = rerunIcon(e, environment) } override fun actionPerformed(e: AnActionEvent) { - val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return - ExecutionManager.getInstance(environment.project).restartRunProfile(environment) + if (throttle.tryLaunch()) rerunCurrent(e) } } +/** + * The icon of the executor a rerun would use (the archived one on a replayed tab), or the restart-debugger icon while + * a debug session is live. + */ +internal fun rerunIcon(e: AnActionEvent, environment: ExecutionEnvironment): Icon { + val executorId = environment.testoRerunExecutorId() + if (executorId == DefaultDebugExecutor.EXECUTOR_ID && isProcessAlive(e)) return AllIcons.Actions.RestartDebugger + return ExecutorRegistry.getInstance().getExecutorById(executorId)?.icon + ?: environment.executor.icon + ?: AllIcons.Actions.Restart +} + +private fun isProcessAlive(e: AnActionEvent): Boolean { + val handler = e.getData(LangDataKeys.RUN_CONTENT_DESCRIPTOR)?.processHandler ?: return false + return !handler.isProcessTerminated +} + +/** + * Restarts what the tab shows. A replayed archive is restarted as the *run* it holds — replaying the recorded log + * again would be a no-op the user cannot tell from a rerun that did nothing. + */ +internal fun rerunCurrent(e: AnActionEvent) { + val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return + val target = environment.testoRunProfile() + if (environment.isTestoReplay() && target != null) { + relaunchTesto(e, environment, target, environment.testoRerunExecutorId()) + return + } + ExecutionManager.getInstance(environment.project).restartRunProfile(environment) +} + class TestoRerunSplitButtonAction : SplitButtonAction(buildExecutorGroup()) { private val mainAction = TestoRerunCurrentAction() @@ -172,6 +247,8 @@ class TestoRerunSplitButtonAction : SplitButtonAction(buildExecutorGroup()) { // otherwise it reruns the current environment, the same restart the platform action performs — done through public // API (restartRunProfile) so it carries no dependency on the internal FakeRerunAction. class TestoAwareRerunAction : AnAction(), DumbAware { + private val throttle = LaunchThrottle() + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { @@ -180,17 +257,18 @@ class TestoAwareRerunAction : AnAction(), DumbAware { e.presentation.isEnabledAndVisible = false return } - // Step aside for the split button on Testo tabs in split-button mode. - if (environment.isTestoRunTab() && TestoRerunStyleSettings.style == TestoRerunStyle.SPLIT_BUTTON) { + // Step aside for the split button in split-button mode — but not on the debug tab, whose toolbar has no + // RunTab.TopToolbar (where the split button lives), so hiding this Rerun would leave it with no restart button. + val splitButtonPresent = environment.executor.id != DefaultDebugExecutor.EXECUTOR_ID + if (environment.isTestoRunTab() && TestoRerunStyleSettings.style == TestoRerunStyle.SPLIT_BUTTON && splitButtonPresent) { e.presentation.isEnabledAndVisible = false return } e.presentation.isEnabledAndVisible = true - e.presentation.icon = environment.executor.icon ?: AllIcons.Actions.Restart + e.presentation.icon = rerunIcon(e, environment) } override fun actionPerformed(e: AnActionEvent) { - val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return - ExecutionManager.getInstance(environment.project).restartRunProfile(environment) + if (throttle.tryLaunch()) rerunCurrent(e) } } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/LogLevelFilter.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/LogLevelFilter.kt index af065c1e..c174a8ed 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/LogLevelFilter.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/LogLevelFilter.kt @@ -3,21 +3,19 @@ package com.github.xepozz.testo.tests.console import com.intellij.ide.util.PropertiesComponent /** - * Display-time filter for per-message log levels (PSR-style: error/warning/info/debug/…). The [ChannelOutputStore] - * always keeps every chunk; this only decides what the channel UI renders. Levels actually seen in the current run are - * recorded in [seenLevels] so the toolbar menu can list exactly what occurred, and the set of [hidden] levels is - * persisted application-wide so a choice survives test reruns and IDE restarts. + * Display-time filter for per-message log levels. The [ChannelOutputStore] always keeps every chunk; this only decides + * what the channel UI renders. It is a single *minimum level*: a message shows when its level is at or above the chosen + * one (`warning` shows emergency…warning, hides notice/info/debug). The choice is persisted application-wide so it + * survives reruns and IDE restarts; the default is [DEFAULT] (`info`). * * Chunks without a level, plus the synthetic `stderr`/`stdout` streams (plain output and failed-test details), are not - * log messages and are always visible regardless of the filter. + * log messages and are always visible regardless of the filter; so is any level outside the known [LEVELS]. */ class LogLevelFilter { - private val lock = Any() - private val seen = LinkedHashSet() - - // Read on the test-reader thread (isVisible) and the EDT (toggles); swapped atomically so reads need no lock. + // Read on the test-reader thread (isVisible) and the EDT (menu); assigned atomically so reads need no lock. @Volatile - private var hidden: Set = loadHidden() + var minLevel: String = load() + private set /** Set by the channel UI to rebuild its tabs when the filter changes; cleared on dispose. */ @Volatile @@ -27,58 +25,33 @@ class LogLevelFilter { if (level == null) return true val normalized = level.lowercase() if (normalized == STDERR || normalized == STDOUT) return true - return normalized !in hidden - } - - /** Records a real log level; returns true if it had not been seen before. No-op for null/stderr/stdout. */ - fun noteSeen(level: String?): Boolean { - if (level == null) return false - val normalized = level.lowercase() - if (normalized == STDERR || normalized == STDOUT) return false - return synchronized(lock) { seen.add(normalized) } + val index = LEVELS.indexOf(normalized) + if (index < 0) return true + return index <= LEVELS.indexOf(minLevel) } - fun seenLevels(): List = synchronized(lock) { seen.toList() } - - fun isHidden(level: String): Boolean = level.lowercase() in hidden - - fun isAllEnabled(): Boolean = hidden.isEmpty() - - fun setHidden(level: String, hide: Boolean) { + fun setMinLevel(level: String) { val normalized = level.lowercase() - hidden = if (hide) hidden + normalized else hidden - normalized - persist() - } - - fun enableAll() { - hidden = emptySet() - persist() + if (normalized !in LEVELS || normalized == minLevel) return + minLevel = normalized + PropertiesComponent.getInstance().setValue(KEY, minLevel, DEFAULT) } - // Disables only the levels seen so far; a level first seen later is shown by default (you don't want a never-before - // seen error to be silently swallowed by an earlier "hide all"). - fun disableAll() { - hidden = seenLevels().toSet() - persist() - } + /** The dropdown button's label, e.g. `info +` — the chosen minimum, and everything above it. */ + fun label(): String = "$minLevel +" fun fireChange() { onChange?.invoke() } - private fun persist() { - PropertiesComponent.getInstance().setValue(KEY, hidden.joinToString(","), "") - } - - private fun loadHidden(): Set = - PropertiesComponent.getInstance().getValue(KEY, "") - .split(",") - .map { it.trim().lowercase() } - .filter { it.isNotEmpty() } - .toSet() + private fun load(): String = + PropertiesComponent.getInstance().getValue(KEY, DEFAULT).lowercase().takeIf { it in LEVELS } ?: DEFAULT companion object { - private const val KEY = "testo.console.hiddenLogLevels" + // PSR-3 Level enum, most severe first; a chosen level shows itself and everything to its left. + val LEVELS = listOf("emergency", "alert", "critical", "error", "warning", "notice", "info", "debug") + const val DEFAULT = "info" + private const val KEY = "testo.console.minLogLevel" private const val STDERR = "stderr" private const val STDOUT = "stdout" } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt deleted file mode 100644 index 42da48e2..00000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.github.xepozz.testo.tests.console - -import com.google.gson.Gson -import com.intellij.execution.testframework.sm.runner.SMTRunnerEventsAdapter -import com.intellij.execution.testframework.sm.runner.SMTestProxy -import com.intellij.execution.testframework.sm.runner.SMTestProxy.SMRootTestProxy -import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView -import com.intellij.execution.testframework.sm.runner.ui.SMTestRunnerResultsForm -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.project.Project -import com.intellij.util.Alarm - -/** - * Channel output survives a run only in memory ([ChannelOutputStore]); the IDE's test-history XML keeps just the plain - * per-test stdout/stderr text and drops our channel/level/icon structure. And on import the platform forces its own - * [com.intellij.execution.testframework.sm.runner.history.ImportedTestConsoleProperties] + - * `ImportedToGeneralTestEventsConverter`, so neither our console nor our converter runs — the channel tabs never appear. - * - * This bridges both gaps using the one per-test datum the history writer round-trips: [SMTestProxy.getMetainfo]. On a - * live run we encode each test's whole "all" stream (every chunk in order, tagged with its channel/level, plus the - * icon/color of each channel it used) into the proxy's metainfo, which [com.intellij.execution.testframework.export.TestResultsXmlFormatter] - * serializes. On import we decode it back into a fresh store and install the same channel UI. Our test locator does not - * read metainfo, so this is free to use. - */ -internal object TestoChannelHistory { - private val gson = Gson() - private const val VERSION = 1 - - // Short field names keep the serialized metainfo (an XML attribute) compact. Nulls are omitted by Gson on write and - // arrive as null on read, so a non-Testo metainfo string deserializes to v=0 and is ignored. - private data class Wire(val v: Int = 0, val c: List = emptyList(), val m: Map = emptyMap()) - private data class WChunk(val t: String = "", val l: String? = null, val ch: String? = null) - private data class WMeta(val i: String? = null, val co: String? = null) - - /** - * Subscribe (for the lifetime of [console]) so each finished test stamps its channel output onto its proxy's - * metainfo, before the history export reads it. Called from the live install path. - */ - fun subscribeMetainfoWriter(project: Project, console: SMTRunnerConsoleView, store: ChannelOutputStore) { - val connection = project.messageBus.connect(console) - var root: SMTestProxy? = null - connection.subscribe( - com.intellij.execution.testframework.sm.runner.SMTRunnerEventsListener.TEST_STATUS, - object : SMTRunnerEventsAdapter() { - override fun onTestingStarted(testsRoot: SMRootTestProxy) { - root = testsRoot - } - - override fun onTestFinished(test: SMTestProxy) { - // Topic is project-wide; ignore proxies from other concurrent runs. - if (root != null && !isUnder(root!!, test)) return - val key = store.keyFor(test.name) - test.metainfo = encode(store, key) ?: return - } - }, - ) - } - - /** - * Wire an imported-history console: once the replayed tree is fully built, decode every proxy's metainfo into the - * store and install the channel UI. Called when the augmenter sees a `TestoImportRunProfile` run tab. - * - * We poll instead of subscribing to `SMTRunnerEventsListener`: the augmenter only hands us the console after - * `processStarted`, by which point a small import may have already replayed and fired (and missed) its events, - * leaving the channels empty even though the tree shows. Polling for a stable node count is immune to that race and - * never double-decodes (a single pass over the finished tree). - */ - fun installForImport(project: Project, console: SMTRunnerConsoleView, targetUrl: String?) { - // The platform builds the imported console, so there is no shared delegate state. Rebuild the channels from the - // metainfo the run stored into each proxy, into a fresh store + level filter. - val store = ChannelOutputStore() - val levelFilter = LogLevelFilter() - val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, console) - var lastCount = -1 - fun poll(attempt: Int) { - val root = (console.resultsViewer as? SMTestRunnerResultsForm)?.testsRootNode - val count = root?.let { countDescendants(it) } ?: 0 - // Install once the tree has stopped growing (stable, non-empty), or give up after ~10s and show what we have. - if ((count > 0 && count == lastCount) || attempt >= 200) { - root?.let { forEachDescendant(it) { proxy -> decode(store, levelFilter, proxy) } } - // Pass the root so install() renders the whole imported tree's aggregate immediately, independent of the - // async JTree selection (which is often still null at this instant). - TestoChannelsUi.install(console, store, levelFilter, project, console, root) - // If "Show history" was clicked on a specific test, select its node so the user lands on that test. - if (targetUrl != null && root != null) { - val match = findByLocationUrl(root, targetUrl) - val form = console.resultsViewer as? SMTestRunnerResultsForm - if (match != null && form != null) { - ApplicationManager.getApplication().invokeLater { form.selectAndNotify(match) } - } - } - return - } - lastCount = count - alarm.addRequest({ poll(attempt + 1) }, 50) - } - alarm.addRequest({ poll(0) }, 0) - } - - private fun countDescendants(node: SMTestProxy): Int { - var n = 0 - for (child in node.children) n += 1 + countDescendants(child) - return n - } - - private fun forEachDescendant(node: SMTestProxy, action: (SMTestProxy) -> Unit) { - for (child in node.children) { - action(child) - forEachDescendant(child, action) - } - } - - // Find the node for a clicked test. Prefer an exact locationUrl match; fall back to a node whose url starts with the - // target (a data-provider method whose datasets carry a " with data set #N" suffix), so selecting it shows the - // method's aggregate. - private fun findByLocationUrl(root: SMTestProxy, url: String): SMTestProxy? { - var prefixMatch: SMTestProxy? = null - var result: SMTestProxy? = null - forEachDescendant(root) { proxy -> - val loc = proxy.locationUrl - if (loc == url) result = result ?: proxy - else if (prefixMatch == null && loc != null && loc.startsWith(url)) prefixMatch = proxy - } - return result ?: prefixMatch - } - - /** Encodes the test's full "all" stream (and the icon/color of every channel it used) for [SMTestProxy.setMetainfo]. */ - private fun encode(store: ChannelOutputStore, key: String): String? { - val chunks = store.allFor(key) - if (chunks.isEmpty()) return null - val channels = chunks.mapNotNullTo(LinkedHashSet()) { it.channel } - val meta = channels.associateWith { WMeta(store.channelIcon(it), store.channelColor(it)) } - val wire = Wire(VERSION, chunks.map { WChunk(it.text, it.level, it.channel) }, meta) - return gson.toJson(wire) - } - - /** Replays a decoded proxy's chunks into [store] through the same calls the live converter makes. */ - private fun decode(store: ChannelOutputStore, levelFilter: LogLevelFilter, proxy: SMTestProxy) { - val raw = proxy.metainfo?.takeIf { it.isNotBlank() } ?: return - val wire = runCatching { gson.fromJson(raw, Wire::class.java) }.getOrNull() ?: return - if (wire.v != VERSION) return - // Key the same way the channel UI looks tests up: keyFor(name) -> locationUrl once remembered. - val key = proxy.locationUrl ?: proxy.name - store.rememberLocation(proxy.name, key) - wire.m.forEach { (channel, m) -> - m.i?.let { store.setChannelIcon(channel, it) } - m.co?.let { store.setChannelColor(channel, it) } - } - wire.c.forEach { chunk -> - levelFilter.noteSeen(chunk.l) - store.appendAll(key, chunk.t, chunk.l, chunk.ch) - if (chunk.ch != null) store.append(key, chunk.ch, chunk.t, chunk.l) - else store.appendOutput(key, chunk.t, chunk.l) - } - } - - private fun isUnder(root: SMTestProxy, node: SMTestProxy): Boolean { - var current: SMTestProxy? = node - while (current != null) { - if (current === root) return true - current = current.parent - } - return false - } -} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelsUi.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelsUi.kt index 124f5a02..c30ee38f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelsUi.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelsUi.kt @@ -31,6 +31,7 @@ import com.intellij.openapi.editor.highlighter.EditorHighlighterFactory import com.intellij.openapi.editor.markup.HighlighterLayer import com.intellij.openapi.editor.markup.HighlighterTargetArea import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditor @@ -156,6 +157,10 @@ object TestoChannelsUi { private var tabs: JBEditorTabs? = null private var outputComponent: JComponent? = null + // The log-level filter, shown at the right edge of the tab row. It rides on every tab's tabPaneActions rather + // than on JBTabs.getEntryPointActionGroup(): that getter is @ApiStatus.Internal and fails the plugin verifier, + // while setTabPaneActions is public and the platform feeds the selected tab's group into the same toolbar. + private val entryPointActions = DefaultActionGroup(TestoLogLevelFilterAction(levelFilter)) private val dynamicConsoles = mutableListOf() private val subscriptions = mutableListOf<() -> Unit>() // A per-parent live stream: console (LiveAggregate) or syntax-highlighted cards (CardsAggregate). Late leaves @@ -189,6 +194,9 @@ object TestoChannelsUi { ) { val tabbed = ensureInstalled() ?: return val platform = outputComponent ?: return + // Keep the open channel across a rebuild (a log-level toggle or a test switch): remember its title now and + // reselect the same-named tab afterwards, falling back to Output when that channel is gone from the new set. + val previousTitle = tabbed.selectedInfo?.text tabbed.removeAllTabs() lazyTabs.clear() disposeDynamicConsoles() @@ -204,15 +212,16 @@ object TestoChannelsUi { if (!selected.isLeaf) { val leaves = selected.allTests.filter { it !== selected && it.isLeaf } + // Output first: it is the raw process console, outside the channel-aggregating "All" scope. + val outputTab = + addAggregateTab(tabbed, OUTPUT_TAB, AllIcons.Debugger.Console, viewer, leaves, attach = store::attachOutput) + // All: syntax-highlighted cards, language picked per message from its own channel. val allCards = newCards(null) store.header().forEach { allCards.add(it) } addCardsAggregate(allCards, viewer, leaves) { key, sink -> store.attachAll(key, sink) } addComponentTab(tabbed, ALL_TAB, AllIcons.Actions.Show, allCards.component) - val outputTab = - addAggregateTab(tabbed, OUTPUT_TAB, AllIcons.Debugger.Console, viewer, leaves, attach = store::attachOutput) - // Every channel renders as cards (one per test, that test's messages merged); only the Output tab above // stays a console. A language channel highlights each card; a format-less one keeps its ANSI. for (channel in channelsAcross(leaves)) { @@ -227,12 +236,12 @@ object TestoChannelsUi { cards.component } } - // Selected last, once every tab exists: JBEditorTabs activates whichever was added first, and the - // tab that opens on a node has to be Output whatever else the node happens to have. - outputTab?.let { tabbed.select(it, false) } + selectPreferredTab(tabbed, previousTitle, outputTab) return } + // Output first: the raw process console, outside the channel-aggregating "All" scope. + val outputTab = addComponentTab(tabbed, OUTPUT_TAB, AllIcons.Debugger.Console, platform) val key = keyOf(selected) val header = store.header() // All: highlighted cards (per-message language). Header chunks first, then the live "all" stream replays @@ -243,7 +252,6 @@ object TestoChannelsUi { if (key != null) subscriptions += store.attachAll(key) { allCards.add(it) } addComponentTab(tabbed, ALL_TAB, AllIcons.Actions.Show, allCards.component) } - val outputTab = addComponentTab(tabbed, OUTPUT_TAB, AllIcons.Debugger.Console, platform) if (key != null) { for ((channel, chunks) in store.channelsFor(key)) { if (chunks.none { levelFilter.isVisible(it.level) }) continue @@ -252,7 +260,13 @@ object TestoChannelsUi { addComponentTab(tabbed, humanize(channel), channelIcon(channel, chunks), cards.component) } } - tabbed.select(outputTab, false) + selectPreferredTab(tabbed, previousTitle, outputTab) + } + + // Reselect the tab whose title the user last had open, so a rebuild keeps their channel; Output when it is gone. + private fun selectPreferredTab(tabbed: JBEditorTabs, preferredTitle: String?, fallback: TabInfo?) { + val target = preferredTitle?.let { title -> tabbed.tabs.firstOrNull { it.text == title } } ?: fallback + target?.let { tabbed.select(it, false) } } override fun onTestNodeAdded(viewer: TestResultsViewer, test: SMTestProxy) { @@ -294,7 +308,7 @@ object TestoChannelsUi { view?.let { addComponentTab(tabbed, title, icon, it.component) } private fun addComponentTab(tabbed: JBEditorTabs, title: String, icon: Icon, component: JComponent): TabInfo { - val info = TabInfo(component).setText(title).setIcon(icon) + val info = TabInfo(component).setText(title).setIcon(icon).setTabPaneActions(entryPointActions) tabbed.addTab(info) return info } @@ -312,7 +326,7 @@ object TestoChannelsUi { private fun addLazyTab(tabbed: JBEditorTabs, title: String, icon: Icon, build: () -> JComponent) { val placeholder = JBPanel(BorderLayout()).apply { isOpaque = false } - val info = TabInfo(placeholder).setText(title).setIcon(icon) + val info = TabInfo(placeholder).setText(title).setIcon(icon).setTabPaneActions(entryPointActions) lazyTabs[info] = LazyTab(build) tabbed.addTab(info) } @@ -1073,13 +1087,18 @@ object TestoChannelsUi { // Prefix the aggregate's per-test header (a hyperlink to the test) with the Testo icon. Console output is // buffered, so the editor offset only resolves once it is flushed — hence performWhenNoDeferredOutput. + // Live chunks arrive off the EDT (test-reader thread), but performWhenNoDeferredOutput asserts EDT — hop first. private fun addTestoIconInlay(view: ConsoleViewImpl, offset: Int) { - view.performWhenNoDeferredOutput { - val editor = view.editor as? EditorEx ?: return@performWhenNoDeferredOutput - if (offset in 0..editor.document.textLength) { - editor.inlayModel.addInlineElement(offset, false, TestoIconInlayRenderer) + val app = ApplicationManager.getApplication() + val task = Runnable { + view.performWhenNoDeferredOutput { + val editor = view.editor as? EditorEx ?: return@performWhenNoDeferredOutput + if (offset in 0..editor.document.textLength) { + editor.inlayModel.addInlineElement(offset, false, TestoIconInlayRenderer) + } } } + if (app.isDispatchThread) task.run() else app.invokeLater(task) } private fun ensureInstalled(): JBEditorTabs? { @@ -1096,8 +1115,9 @@ object TestoChannelsUi { holder.remove(original) // The editor's own tabs widget: one row that scrolls and shows a "hidden tabs" dropdown when the channels - // don't fit, instead of wrapping to extra rows like JBTabbedPane. - val tabbed = JBEditorTabs(project, this) + // don't fit, instead of wrapping to extra rows like JBTabbedPane. The log-level filter rides on each tab's + // tabPaneActions (see entryPointActions), which the platform paints at the right edge of that row. + val tabbed = JBEditorTabs(project, this@ChannelTabsController) tabbed.addListener(object : com.intellij.ui.tabs.TabsListener { override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) = buildLazyTab(newSelection) }) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt index a8c260c1..11919280 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo.tests.console +import com.github.xepozz.testo.runs.TestoRunArchiver import com.github.xepozz.testo.tests.TestoConsoleProperties import com.intellij.execution.ExecutionListener import com.intellij.execution.ExecutorRegistry @@ -11,7 +12,6 @@ import com.intellij.execution.ui.RunContentDescriptor import com.intellij.execution.ui.RunContentManager import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project -import com.intellij.util.concurrency.EdtScheduledExecutorService import com.intellij.util.text.DateFormatUtil // The console is built by the PHP test framework, so processStarted is the first point we can reach it. @@ -20,19 +20,9 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { ApplicationManager.getApplication().invokeLater { val descriptor = findDescriptor(executorId, handler) ?: return@invokeLater val console = descriptor.executionConsole as? SMTRunnerConsoleView ?: return@invokeLater - val props = console.properties - val importProfile = env.runProfile as? TestoImportRunProfile - when { - // Live run: build the channel UI and start stamping per-test channel output onto proxy metainfo. - props is TestoConsoleProperties -> installChannels(project, console, props, handler) - // Our "Show history" lens import: detected by our own run profile (no dependency on the internal - // ImportedTestConsoleProperties), carrying the clicked test's url so its node gets pre-selected. - importProfile != null -> TestoChannelHistory.installForImport(project, console, importProfile.targetUrl) - // Platform "Import Test Results" (the history clock dropdown) of a Testo run: an imported console with no - // Testo run profile. Recognized by class name so we keep no compile-time tie to the internal - // ImportedTestConsoleProperties; rebuild channels from metainfo just the same (no clicked test to select). - isImportedConsole(props) -> TestoChannelHistory.installForImport(project, console, null) - } + // Live run — and a replayed archive, which runs on the same properties, so it gets the same UI. + val props = console.properties as? TestoConsoleProperties ?: return@invokeLater + installChannels(project, console, props, handler) } } @@ -40,25 +30,11 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { ApplicationManager.getApplication().invokeLater { val descriptor = findDescriptor(executorId, handler) ?: return@invokeLater val console = descriptor.executionConsole as? SMTRunnerConsoleView ?: return@invokeLater - if (console.properties !is TestoConsoleProperties) return@invokeLater - // The run's history XML is written on a background task after the process ends, so nudge the lens a couple - // of times across the save window. Once the index sees the new file it re-invalidates the lens itself; the - // first nudge that lands after the save is what makes the just-run test's lens appear (no IDE restart). - val refresh = Runnable { TestoHistoryIndex.refreshLens(project) } - EdtScheduledExecutorService.getInstance().schedule(refresh, 1500, java.util.concurrent.TimeUnit.MILLISECONDS) - EdtScheduledExecutorService.getInstance().schedule(refresh, 4000, java.util.concurrent.TimeUnit.MILLISECONDS) - } - } - - // True when the console's properties are (a subclass of) the platform's imported-history properties, matched by FQN - // so this carries no bytecode reference to the @ApiStatus.Internal class. - private fun isImportedConsole(props: Any?): Boolean { - var c: Class<*>? = props?.javaClass - while (c != null) { - if (c.name == IMPORTED_CONSOLE_PROPERTIES_FQN) return true - c = c.superclass + val props = console.properties as? TestoConsoleProperties ?: return@invokeLater + // Close the run archive: reports are on disk by process exit, and the archiver is idempotent across the + // debug runner's own hook. It refreshes the "Show history" lens itself, once the archive is complete. + TestoRunArchiver.finalizeRun(project, props) } - return false } private fun findDescriptor(executorId: String, handler: ProcessHandler): RunContentDescriptor? { @@ -70,9 +46,6 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { } companion object { - private const val IMPORTED_CONSOLE_PROPERTIES_FQN = - "com.intellij.execution.testframework.sm.runner.history.ImportedTestConsoleProperties" - // Single entry point for wiring the channel tabs, shared by the run-path listener above and the debug runner // (which installs them directly because its descriptor isn't registered when processStarted fires). The // channelsInstalled flag keeps a second caller for the same console from installing twice. @@ -93,8 +66,6 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { props.statusStore, verdict = props.progressAction::currentVerdict, ) { key -> props.channelStore.description(key) } - // Persist each test's channel output into proxy metainfo so an imported-history run can rebuild the tabs. - TestoChannelHistory.subscribeMetainfoWriter(project, console, props.channelStore) props.progressAction.attachTo( console, props.statusStore, @@ -124,13 +95,17 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { // so the channel UI renders this as the first line of the "All" tab instead. private fun captureHeader(props: TestoConsoleProperties, handler: ProcessHandler) { val commandLine = (handler as? OSProcessHandler)?.commandLine ?: return + props.commandLine = commandLine + props.channelStore.setHeader(runHeader(commandLine, System.currentTimeMillis())) + } + + /** The first lines of the "All" tab. Shared with a replay, which reprints the archived run's own header. */ + fun runHeader(commandLine: String, at: Long): List { // DateFormatUtil emits a narrow no-break space (U+202F) before AM/PM on modern JDKs, which renders as a // tofu box in the channel editor; normalize it (and NBSP) to a plain space. - val startedAt = DateFormatUtil.formatTimeWithSeconds(System.currentTimeMillis()) + val startedAt = DateFormatUtil.formatTimeWithSeconds(at) .replace(' ', ' ').replace(' ', ' ') - props.channelStore.setHeader( - listOf(ChannelOutputStore.Chunk("$commandLine\nTesting started at $startedAt\n\n", null)) - ) + return listOf(ChannelOutputStore.Chunk("$commandLine\nTesting started at $startedAt\n\n", null)) } } } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryImport.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryImport.kt deleted file mode 100644 index 44f10fd0..00000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryImport.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.github.xepozz.testo.tests.console - -import com.intellij.execution.Executor -import com.intellij.execution.configurations.RunConfiguration -import com.intellij.execution.configurations.RunProfile -import com.intellij.execution.configurations.RunProfileState -import com.intellij.execution.executors.DefaultRunExecutor -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.runners.ExecutionEnvironmentBuilder -import com.intellij.execution.testframework.sm.runner.SMRunnerConsolePropertiesProvider -import com.intellij.execution.testframework.sm.runner.history.actions.AbstractImportTestsAction -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFile -import javax.swing.Icon - -/** - * Open a saved test-history file as a run tab. We launch it through a [TestoImportRunProfile] so the platform's standard - * import builds the console (no dependency on the internal `ImportedTestConsoleProperties`); that console still wraps our - * [com.github.xepozz.testo.tests.TestoConsoleProperties], so [TestoChannelHistory.installForImport] rebuilds the channel - * tabs from the metainfo the run stored. The trade-off: the primary-row log-level filter (added via `createImportActions`, - * which the platform import does not delegate) is absent on imported tabs. - */ -internal fun openTestoHistory(project: Project, file: VirtualFile, targetUrl: String? = null) { - try { - val executor = DefaultRunExecutor.getRunExecutorInstance() - val profile = TestoImportRunProfile(file, project, executor, targetUrl) - ExecutionEnvironmentBuilder.create(project, executor, profile) - .executor(executor) - .target(profile.target) - .buildAndExecute() - } catch (e: Exception) { - Logger.getInstance("com.github.xepozz.testo.tests.console.TestoHistoryImport") - .warn("Testo: failed to import test history ${file.path}", e) - } -} - -/** - * "Show history" for one test: open the most recent saved run that actually contains [url] (so clicking a test doesn't - * land on an unrelated latest run), and once imported, select that test's node. Falls back to the newest run overall. - * Scans files off the EDT (the largest history XML is sizeable), then imports on the EDT. - */ -internal fun openTestoHistoryForTest(project: Project, url: String) { - com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread { - // Scan the directory directly (not TestHistoryConfiguration.files): a freshly-saved run lands on disk before - // it's registered there. - val files = (com.intellij.execution.TestStateStorage.getTestHistoryRoot(project) - .listFiles { f -> f.isFile && f.name.endsWith(".xml") } ?: emptyArray()) - .sortedByDescending { it.lastModified() } - // Only the run that actually contains this test — do NOT fall back to an unrelated latest run (a saved run that - // included the test may have been pruned out of the 10-file history; the lens still shows because the last - // status survives in TestStateStorage). - val target = files.firstOrNull { f -> runCatching { f.readText().contains(url) }.getOrDefault(false) } - com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { - if (target == null) { - com.intellij.notification.NotificationGroupManager.getInstance().getNotificationGroup("Testo") - ?.createNotification( - "No saved run history contains this test yet — run it to record one.", - com.intellij.notification.NotificationType.INFORMATION, - ) - ?.notify(project) - return@invokeLater - } - val vf = com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(target) - ?: return@invokeLater - openTestoHistory(project, vf, url) - } - } -} - -/** - * Mirrors `AbstractImportTestsAction.ImportRunProfile` (reused for parsing the saved `` and resolving the - * target): the first [getState] lets the platform import build the console, later ones rerun the reconstructed - * configuration. - */ -// Internal (not private) so the rerun actions' ExecutionEnvironment.testoRunProfile() can recognize an imported Testo -// history tab as a Testo run tab and surface our toolbar's rerun split button on it. -internal class TestoImportRunProfile( - file: VirtualFile, - project: Project, - private val executor: Executor, - // The locationUrl of the test the "Show history" lens was clicked on. The augmenter reads it off env.runProfile to - // select that node once the imported tree is built — keeping history-import detection off the internal - // ImportedTestConsoleProperties. - val targetUrl: String? = null, -) : RunProfile { - private val inner = AbstractImportTestsAction.ImportRunProfile(file, project, executor) - private val fallbackName = file.nameWithoutExtension - private var imported = false - - val target get() = inner.target - - /** The Testo run configuration reconstructed from the history ``, if any — used by the rerun actions. */ - val testoConfiguration: RunConfiguration? get() = inner.initialConfiguration - - override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState? { - val config = inner.initialConfiguration - // First launch: let the platform build the imported-history console. Its standard ImportedTestConsoleProperties - // wraps our TestoConsoleProperties, so the channel UI still rebuilds from the metainfo the run stored. A rerun - // from that tab (second invocation) runs the reconstructed configuration's tests instead of re-importing. - if (!imported && config is SMRunnerConsolePropertiesProvider) { - imported = true - return inner.getState(executor, environment) - } - return config?.getState(executor, environment) ?: inner.getState(executor, environment) - } - - override fun getName(): String = inner.initialConfiguration?.name ?: fallbackName - override fun getIcon(): Icon? = inner.initialConfiguration?.icon -} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryIndex.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryIndex.kt index 3dfe0200..7437d204 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryIndex.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryIndex.kt @@ -1,72 +1,56 @@ package com.github.xepozz.testo.tests.console +import com.github.xepozz.testo.runs.TestoRunStore +import com.github.xepozz.testo.runs.runLocationKey import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.codeInsight.hints.codeVision.ModificationStampUtil -import com.intellij.execution.TestStateStorage import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project -import java.io.File import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong /** - * Cached set of every test locationUrl present in the project's saved run-history XML files, so the "Show history" lens - * can be shown only for tests that actually have saved history. A test's last status survives in [TestStateStorage] - * long after its run XML is pruned from the 10-file history, so checking storage alone would show the lens for tests - * whose history is gone. + * Cached set of every test location the project's archived runs hold, so the "Show history" lens is shown only for + * tests some archive can actually replay. The source is the run archive ([TestoRunStore]) — the platform's own history + * XMLs are not consulted: a lens click replays our archive, so the two would disagree the moment either side rotates. * - * The index is rebuilt on a pooled thread whenever the history files' newest timestamp changes (i.e. after a new run is - * saved), then triggers a daemon restart so the lenses recompute. [contains] never blocks: it returns the last good - * answer while a rebuild is in flight. + * The set is built synchronously on first lookup (a few small `tests.txt`) and cached until the archive changes, so the + * first code-vision pass over a freshly opened file already answers, without waiting on a repaint (see CLAUDE.md). */ internal object TestoHistoryIndex { - private val locationUrl = Regex("locationUrl=\"([^\"]*)\"") - private data class Snapshot(val stamp: Long, val urls: Set) + private data class Snapshot(val generation: Long, val urls: Set) + + private val generation = AtomicLong() private val cache = ConcurrentHashMap() - private val building = ConcurrentHashMap.newKeySet() - /** True if some saved run history contains [url] (an exact node locationUrl, or a dataset under that method). */ - fun contains(project: Project, url: String): Boolean { - val key = project.locationHash - val files = historyFiles(project) - val stamp = files.maxOfOrNull { it.lastModified() } ?: 0L - val snap = cache[key] - if (snap == null || snap.stamp != stamp) scheduleRebuild(project, key, files, stamp) - val urls = (if (snap?.stamp == stamp) snap else cache[key])?.urls ?: return false - return url in urls || urls.any { it.startsWith(url) } + /** The archive changed: the next lookup rebuilds. Callers repaint open editors via [refreshLens] themselves. */ + fun invalidate() { + generation.incrementAndGet() } - // List the history directory directly rather than TestHistoryConfiguration.files: a just-saved run's file lands on - // disk before it is registered there, and we want the lens to appear as soon as the run is written. - private fun historyFiles(project: Project): List = - TestStateStorage.getTestHistoryRoot(project).listFiles { f -> f.isFile && f.name.endsWith(".xml") }?.toList() - ?: emptyList() + /** True if some archived run contains [url] (an exact test location, or a test declared under it). */ + fun contains(project: Project, url: String): Boolean { + val needle = runLocationKey(url) + val urls = snapshot(project).urls + // `startsWith("$needle::")`, not `startsWith(needle)`: `…::testPay` must not answer for `…::testPayment`. + return needle in urls || urls.any { it.startsWith("$needle::") } + } - private fun scheduleRebuild(project: Project, key: String, files: List, stamp: Long) { - if (!building.add(key)) return - ApplicationManager.getApplication().executeOnPooledThread { - try { - val urls = HashSet() - files.forEach { f -> - runCatching { locationUrl.findAll(f.readText()).forEach { urls.add(it.groupValues[1]) } } - } - // Only restart the daemon when the lenses would actually change. A rebuild also runs on the very first - // lookup and whenever a history file's timestamp moves without its contents mattering; restarting then - // interrupts an in-flight highlighting pass for nothing. - val previous = cache.put(key, Snapshot(stamp, urls))?.urls ?: emptySet() - if (previous != urls) refreshLens(project) - } finally { - building.remove(key) - } - } + private fun snapshot(project: Project): Snapshot { + val key = project.locationHash + val current = generation.get() + cache[key]?.let { if (it.generation == current) return it } + val store = TestoRunStore.getInstance(project) + val urls = HashSet() + store.listRuns().forEach { (dir, _) -> store.readLocations(dir).forEach { urls.add(runLocationKey(it)) } } + return Snapshot(current, urls).also { cache[key] = it } } /** - * Recompute the "Show history" lenses now. Code vision is gated by a PSI modification stamp: the daemon's code-vision - * pass self-skips when the file's stamp is unchanged, and a test run never touches the PHP source — so neither - * DaemonCodeAnalyzer.restart() nor CodeVisionHost.invalidateProvider re-runs getHint (the lens only refreshed on a - * full IDE restart). The platform's own recipe (CodeVisionHost.subscribeCVSettingsChanged) is to clear that stamp on - * each editor and then restart the daemon, which forces the pass to recompute getHint and repopulate the cache. + * Recompute the "Show history" lenses now: a test run touches no PHP source, so the code-vision pass self-skips + * unless each editor's PSI modification stamp is cleared first (the platform's own recipe) — then the daemon + * restart re-runs getHint. */ fun refreshLens(project: Project) { ApplicationManager.getApplication().invokeLater { diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoLogLevelFilterAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoLogLevelFilterAction.kt index 8ccbca0b..43fc7241 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoLogLevelFilterAction.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoLogLevelFilterAction.kt @@ -1,102 +1,52 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.TestoBundle -import com.github.xepozz.testo.tests.TestoConsoleProperties -import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView -import com.intellij.icons.AllIcons -import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.LangDataKeys -import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.actionSystem.ex.ComboBoxAction import com.intellij.openapi.project.DumbAware +import javax.swing.JComponent /** - * Toolbar dropdown that toggles which log levels the channel consoles show. "All" flips every seen level on/off at once; - * each level below has its own checkbox. The menu lists exactly the levels encountered in the current run (ordered by - * PSR severity), so it grows as new levels arrive. Toggling rebuilds the tabs via [LogLevelFilter.fireChange] — channel - * tabs left empty by the filter disappear, and re-enabling a level brings them back. + * Labeled dropdown that picks the *minimum* log level the channel consoles show. The button reads `info +` / `debug +` + * (the chosen level, and everything above it); the popup lists all eight PSR levels as a radio group. Picking one + * rebuilds the tabs via [LogLevelFilter.fireChange] — channels left empty by the filter disappear, and lowering the + * minimum brings them back. * - * Registered statically on the run-tab toolbar (`RunTab.TopToolbar`), so a single shared instance is shown on every run - * tab; it resolves the current tab's [LogLevelFilter] from the action context and hides itself on non-Testo tabs. + * Right-aligned on the channel tabs row (installed by [TestoChannelsUi] as the tabs' entry-point action group). */ -// explicitFilter is used by the debug runner, whose toolbar context carries no RUN_CONTENT_DESCRIPTOR to resolve from; -// the statically-registered run-tab instance leaves it null and resolves the filter from the action context instead. class TestoLogLevelFilterAction( - private val explicitFilter: LogLevelFilter? = null, -) : ActionGroup(), DumbAware { + private val filter: LogLevelFilter, +) : ComboBoxAction(), DumbAware { init { - isPopup = true - templatePresentation.icon = AllIcons.Actions.Show - templatePresentation.text = TestoBundle.message("testo.console.loglevel.filter.title") + templatePresentation.description = TestoBundle.message("testo.console.loglevel.filter.title") } override fun getActionUpdateThread() = ActionUpdateThread.EDT override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = resolveFilter(e) != null + e.presentation.text = filter.label() } - private fun resolveFilter(e: AnActionEvent?): LogLevelFilter? = explicitFilter ?: resolveContextFilter(e) - - override fun getChildren(e: AnActionEvent?): Array { - val filter = resolveFilter(e) ?: return AnAction.EMPTY_ARRAY - val levels = filter.seenLevels().sortedWith(LEVEL_ORDER) - val children = mutableListOf(AllToggle()) - if (levels.isNotEmpty()) { - children += Separator.getInstance() - levels.mapTo(children) { LevelToggle(it) } - } - return children.toTypedArray() - } - - private inner class AllToggle : ToggleAction(TestoBundle.message("testo.console.loglevel.filter.all")), DumbAware { - override fun getActionUpdateThread() = ActionUpdateThread.EDT - override fun isSelected(e: AnActionEvent) = resolveFilter(e)?.isAllEnabled() ?: true - override fun setSelected(e: AnActionEvent, state: Boolean) { - val filter = resolveFilter(e) ?: return - if (state) filter.enableAll() else filter.disableAll() - filter.fireChange() - } + override fun createPopupActionGroup(button: JComponent, context: DataContext): DefaultActionGroup { + val group = DefaultActionGroup() + LogLevelFilter.LEVELS.forEach { group.add(LevelItem(it)) } + return group } - private inner class LevelToggle(private val level: String) : ToggleAction(humanize(level)), DumbAware { + private inner class LevelItem(private val level: String) : ToggleAction(humanize(level)), DumbAware { override fun getActionUpdateThread() = ActionUpdateThread.EDT - override fun isSelected(e: AnActionEvent) = resolveFilter(e)?.isHidden(level) == false + override fun isSelected(e: AnActionEvent) = filter.minLevel == level override fun setSelected(e: AnActionEvent, state: Boolean) { - val filter = resolveFilter(e) ?: return - filter.setHidden(level, !state) + if (!state) return + filter.setMinLevel(level) filter.fireChange() } } - companion object { - // Resolve the current run tab's Testo filter from context; null on any non-Testo run tab (hides the button). - private fun resolveContextFilter(e: AnActionEvent?): LogLevelFilter? { - val descriptor = e?.getData(LangDataKeys.RUN_CONTENT_DESCRIPTOR) ?: return null - val console = descriptor.executionConsole as? SMTRunnerConsoleView ?: return null - return (console.properties as? TestoConsoleProperties)?.levelFilter - } - - // PSR-3 severities, most severe first; levels outside this list sort after, alphabetically. - private val PSR_ORDER = listOf( - "emergency", "alert", "critical", "error", "warning", "notice", "info", "debug", - ) - - private val LEVEL_ORDER = Comparator { a, b -> - val ia = PSR_ORDER.indexOf(a.lowercase()) - val ib = PSR_ORDER.indexOf(b.lowercase()) - when { - ia >= 0 && ib >= 0 -> ia - ib - ia >= 0 -> -1 - ib >= 0 -> 1 - else -> a.compareTo(b) - } - } - - private fun humanize(level: String): String = - level.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } - } + private fun humanize(level: String): String = + level.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoOutputToGeneralEventsConverter.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoOutputToGeneralEventsConverter.kt index fcd8860b..1fcd5ad0 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoOutputToGeneralEventsConverter.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoOutputToGeneralEventsConverter.kt @@ -1,12 +1,16 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.runs.TestoRunRecording +import com.github.xepozz.testo.runs.TestoRunStore +import com.intellij.execution.process.ProcessOutputType import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.testframework.TestConsoleProperties import com.intellij.execution.testframework.sm.runner.GeneralTestEventsProcessor import com.intellij.execution.testframework.sm.runner.OutputToGeneralTestEventsConverter import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.util.Key import jetbrains.buildServer.messages.serviceMessages.ServiceMessage import jetbrains.buildServer.messages.serviceMessages.ServiceMessageVisitor @@ -15,7 +19,6 @@ class TestoOutputToGeneralEventsConverter( testFrameworkName: String, private val consoleProperties: TestConsoleProperties, private val store: ChannelOutputStore, - private val levelFilter: LogLevelFilter, private val statusStore: TestoStatusStore, private val timings: TestoRunTimings, private val targetStore: TestoTargetStore, @@ -41,13 +44,45 @@ class TestoOutputToGeneralEventsConverter( /** Hint by node id, for the channel store alone: it keys a description like the output it belongs to. */ private val hintByNodeId = HashMap() + private val testoProperties: com.github.xepozz.testo.tests.TestoConsoleProperties? + get() = consoleProperties as? com.github.xepozz.testo.tests.TestoConsoleProperties + + private val isReplay: Boolean get() = testoProperties?.replayMode == true + + private var recordingBroken = false + override fun process(text: String, outputType: Key<*>) { if (runnerVersion == null) runnerVersion = TestoProtocolGate.parseVersion(text) // Second route: a message behind a colour escape never reaches parseServiceMessage. The store dedups by path. - TestoReportRef.fromServiceMessageLine(text)?.let { reportStore.note(it) } + if (!isReplay) TestoReportRef.fromServiceMessageLine(text)?.let { reportStore.note(it) } + recordChunk(text, outputType) super.process(text, outputType) } + // The converter is the one place every output chunk flows through, from the very first byte (the console attaches + // before startNotify) — so the run archive records here rather than off a ProcessListener added later. + // A write failure gives up on the whole run's archive: retrying per chunk would attempt IO on every line. + private fun recordChunk(text: String, outputType: Key<*>) { + val props = testoProperties ?: return + if (props.replayMode || recordingBroken) return + val recording = props.recording ?: synchronized(props) { + props.recording ?: runCatching { + TestoRunStore.getInstance(props.project).beginRun(props.configuration.name, props.executor.id) + }.onFailure { giveUpRecording(it) }.getOrNull()?.also { props.recording = it } + } ?: return + val stream = when { + ProcessOutputType.isStderr(outputType) -> TestoRunRecording.STDERR + ProcessOutputType.isStdout(outputType) -> TestoRunRecording.STDOUT + else -> TestoRunRecording.SYSTEM + } + runCatching { recording.appendChunk(stream, text) }.onFailure { giveUpRecording(it) } + } + + private fun giveUpRecording(cause: Throwable) { + recordingBroken = true + thisLogger().warn("Testo run archive disabled for this run", cause) + } + override fun processServiceMessage(message: ServiceMessage, visitor: ServiceMessageVisitor) { val attrs = message.attributes @@ -76,6 +111,8 @@ class TestoOutputToGeneralEventsConverter( if (location != null) { store.rememberLocation(name, location) if (nodeId != null) hintByNodeId[nodeId] = location + // What the archive is looked up by: which tests this run holds ("Show history" asks that). + testoProperties?.recording?.noteLocation(location) } val metainfo = attrs["metainfo"] if (!metainfo.isNullOrBlank()) store.setDescription(descriptionKey(attrs), metainfo) @@ -88,8 +125,6 @@ class TestoOutputToGeneralEventsConverter( val out = attrs["out"] ?: "" val level = attrs["level"] val channel = attrs["channel"]?.takeIf { it.isNotEmpty() } - // Record the level so the filter menu can list it; storage keeps every chunk regardless. - levelFilter.noteSeen(level) // Tag the all-stream chunk with its channel so the aggregated All tab can highlight per message. if (key != null) store.appendAll(key, out, level, channel) @@ -117,7 +152,9 @@ class TestoOutputToGeneralEventsConverter( // Testo's own message, naming a report of this run. Not forwarded, for the same reason as buildProblem. TESTO_REPORT -> { - TestoReportRef.fromAttributes(attrs)?.let { reportStore.note(it) } + // A replay's reports come from its archive, where they were deduped and captured; the announcements + // in the recorded log name paths the next run has since overwritten. + if (!isReplay) TestoReportRef.fromAttributes(attrs)?.let { reportStore.note(it) } return } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoProgressAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoProgressAction.kt index 9cee92d8..c928e106 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoProgressAction.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoProgressAction.kt @@ -159,8 +159,12 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb reports.noteRunFinished() } }) - // A run short enough to be over before this wiring lands gets no processTerminated at all. - if (handler?.isProcessTerminated == true) reports.noteRunFinished() + // A run short enough to be over before this wiring lands gets no processTerminated at all — and then nothing + // else would ever stop the clock, which counts up for as long as the tab is open. + if (handler?.isProcessTerminated == true) { + reports.noteRunFinished() + clock.noteFinish() + } } /** diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt new file mode 100644 index 00000000..85f31475 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt @@ -0,0 +1,76 @@ +package com.github.xepozz.testo.tests.console + +import com.intellij.execution.testframework.sm.runner.SMTestProxy +import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView +import com.intellij.execution.testframework.sm.runner.ui.SMTestRunnerResultsForm +import com.intellij.util.Alarm + +/** + * Selects a test's node in a replayed run — what the *Show history* lens clicks through to + * ([com.github.xepozz.testo.runs.TestoRunReplayProfile]). + */ +internal object TestoReplaySelection { + + /** Select the node of [url] once the tree has finished building; the recorded output is still streaming in. */ + fun selectWhenReady(console: SMTRunnerConsoleView, url: String) { + whenTreeStable(console) { root -> root?.let { select(console, it, url) } } + } + + /** + * Run [action] with the results tree once it has stopped growing (stable and non-empty), or after ~10s with + * whatever is there. We poll rather than subscribe to `SMTRunnerEventsListener`: a short run can finish replaying + * before we are handed the console, and its events are then already fired and missed. + */ + private fun whenTreeStable(console: SMTRunnerConsoleView, action: (SMTestProxy?) -> Unit) { + // Called from a pooled thread once the feed ends; the tab may have been closed while the log streamed, and + // registering an Alarm on a disposed console throws. + val alarm = runCatching { Alarm(Alarm.ThreadToUse.SWING_THREAD, console) }.getOrNull() ?: return + var lastCount = -1 + fun poll(attempt: Int) { + val root = (console.resultsViewer as? SMTestRunnerResultsForm)?.testsRootNode + val count = root?.let { countDescendants(it) } ?: 0 + if ((count > 0 && count == lastCount) || attempt >= 200) { + action(root) + return + } + lastCount = count + alarm.addRequest({ poll(attempt + 1) }, 50) + } + alarm.addRequest({ poll(0) }, 0) + } + + private fun select(console: SMTRunnerConsoleView, root: SMTestProxy, url: String) { + val form = console.resultsViewer as? SMTestRunnerResultsForm ?: return + val match = findByLocationUrl(root, url) ?: return + form.selectAndNotify(match) + } + + private fun countDescendants(node: SMTestProxy): Int { + var n = 0 + for (child in node.children) n += 1 + countDescendants(child) + return n + } + + private fun forEachDescendant(node: SMTestProxy, action: (SMTestProxy) -> Unit) { + for (child in node.children) { + action(child) + forEachDescendant(child, action) + } + } + + // Find the node for a clicked test. Prefer an exact locationUrl match; fall back to a node continuing the target + // with a delimiter (`#idx`, ` with data set #N`, `::member`) — never with a name character, or `::testPay` would + // select `::testPayment`. + private fun findByLocationUrl(root: SMTestProxy, url: String): SMTestProxy? { + var prefixMatch: SMTestProxy? = null + var result: SMTestProxy? = null + forEachDescendant(root) { proxy -> + val loc = proxy.locationUrl + if (loc == url) result = result ?: proxy + else if (prefixMatch == null && loc != null && loc.length > url.length && + loc.startsWith(url) && loc[url.length] in ":# " + ) prefixMatch = proxy + } + return result ?: prefixMatch + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt index a41f4a2d..4fb388c3 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt @@ -1,6 +1,11 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.TestoCoverageReport +import com.github.xepozz.testo.coverage.applyTestoCoverage +import com.github.xepozz.testo.coverage.closeTestoCoverage +import com.github.xepozz.testo.coverage.isTestoCoverageActive +import com.github.xepozz.testo.coverage.format.CoverageFormat import com.github.xepozz.testo.ui.TestoReportViewer import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil @@ -11,12 +16,16 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.KeepPopupOnPerform import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.actionSystem.RightAlignedToolbarAction import com.intellij.openapi.actionSystem.ToggleAction import com.intellij.openapi.actionSystem.ex.CustomComponentAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.util.IconLoader import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.JBColor @@ -34,6 +43,7 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel @@ -63,6 +73,7 @@ class TestoReportsAction( /** One cell per announced report, polled: whether the file exists yet changes without anything telling us. */ private inner class ReportsPanel : JPanel() { private val cells = LinkedHashMap() + private var coverageCell: CoverageGroupCell? = null private val timer = Timer(REFRESH_MS) { tick() } init { @@ -125,9 +136,16 @@ class TestoReportsAction( } val gone = cells.keys - announced.mapTo(HashSet()) { it.path } gone.forEach { path -> cells.remove(path)?.let { remove(it) } } - cells.values.forEach { it.refresh() } - isVisible = cells.isNotEmpty() + + val coverage = reports.coverage() + val cell = when { + coverage.isEmpty() -> coverageCell?.let { remove(it); coverageCell = null; null } + else -> coverageCell ?: CoverageGroupCell().also { add(it); coverageCell = it } + } + cell?.refresh(coverage) + + isVisible = cells.isNotEmpty() || coverageCell != null // Re-laid out only when the row changed shape — this runs twice a second. val width = preferredSize.width @@ -150,6 +168,7 @@ class TestoReportsAction( // A fresh cell has no tooltip yet, so the first refresh must go through however little has changed. private var refreshed = false private var hovered = false + private val resolving = AtomicBoolean() // Asked for per paint: a font set once on a raw JComponent outlives a zoom (no UI delegate reinstalls it). override fun getFont(): Font = UIUtil.getLabelFont() @@ -185,8 +204,19 @@ class TestoReportsAction( fun refresh() { // Not while the run is going: a report is announced as Testo starts writing it, over the path the // previous run wrote to — a check now would offer that run's file. - val finished = reports.runFinished - val found = if (finished) resolveReport(ref, project, mapToLocal, reports.runStartedAt) else null + if (!reports.runFinished) { + applyResolved(null, false) + return + } + val cellRef = ref + resolveReportOffEdt( + project, reports, resolving, + resolve = { startedAt -> resolveReport(cellRef, project, mapToLocal, startedAt) }, + apply = { applyResolved(it, true) }, + ) + } + + private fun applyResolved(found: Path?, finished: Boolean) { maybeAutoOpen(found, finished) val willOpen = !finished && TestoReportAutoOpen.decide(project, reports, ref).isNotEmpty() // Tooltip and repaint only on a real change: this runs twice a second. @@ -324,6 +354,152 @@ class TestoReportsAction( OpenReportGroup(TestoBundle.message(key), icon, way, { ref }, project, reports, ::openOrArm) } + /** + * The run's coverage reports as one button: a click applies every checked report as a single merged bundle + * ([applyTestoCoverage]), the arrow opens a checkbox per report. Toggling a checkbox while Testo coverage is + * showing recomposes the bundle live; unchecking the last one closes it. + */ + private inner class CoverageGroupCell : JComponent() { + private var refs: List = emptyList() + private var located: Map = emptyMap() + private var runWasFinished = false + private var refreshed = false + private var hovered = false + private val resolving = AtomicBoolean() + + override fun getFont(): Font = UIUtil.getLabelFont() + + init { + isOpaque = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { hovered = true; repaint() } + override fun mouseExited(e: MouseEvent) { hovered = false; repaint() } + override fun mouseClicked(e: MouseEvent) { + if (e.x >= width - arrowZone()) showMenu() else applyChecked() + } + }) + } + + private fun text(): String = TestoBundle.message("testo.coverage.group.text") + + private fun arrowZone(): Int = ARROW.iconWidth + GAP + PADDING + + fun refresh(coverage: List) { + refs = coverage + if (!reports.runFinished) { + applyResolved(emptyMap(), false) + return + } + resolveReportOffEdt( + project, reports, resolving, + resolve = { startedAt -> + val found = LinkedHashMap() + for (ref in coverage) { + resolveCoverageDataFile(ref, project, mapToLocal, startedAt)?.let { found[ref.path] = it } + } + found + }, + apply = { applyResolved(it, true) }, + ) + } + + private fun applyResolved(found: Map, finished: Boolean) { + if (refreshed && found == located && finished == runWasFinished) return + refreshed = true + located = found + runWasFinished = finished + toolTipText = when { + found.isNotEmpty() -> TestoBundle.message("testo.coverage.action.description") + finished -> TestoBundle.message("testo.coverage.action.description.pending") + else -> TestoBundle.message("testo.coverage.action.description.running") + } + repaint() + } + + /** The checked reports that are actually on disk — what a click applies and a toggle recomposes. */ + private fun checkedReports(): List = refs.mapNotNull { ref -> + val path = located[ref.path] ?: return@mapNotNull null + if (!reports.isCoverageChecked(ref.path)) return@mapNotNull null + TestoCoverageReport(ref.name, ref.coverageFormat, path) + } + + private fun applyChecked() { + val checked = checkedReports() + if (checked.isNotEmpty()) applyTestoCoverage(project, checked) + } + + private fun onToggled() { + if (!isTestoCoverageActive(project)) return + val checked = checkedReports() + if (checked.isEmpty()) closeTestoCoverage(project) else applyTestoCoverage(project, checked) + } + + private fun showMenu() { + val group = DefaultActionGroup(refs.map { CoverageReportToggle(it) }) + JBPopupFactory.getInstance() + .createActionGroupPopup( + null, + group, + DataManager.getInstance().getDataContext(this), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ActionPlaces.TOOLBAR, + ) + .showUnderneathOf(this) + } + + private inner class CoverageReportToggle(private val ref: TestoReportRef) : + ToggleAction(ref.name ?: ref.format), DumbAware { + + init { + // Checking off several reports is one gesture — the menu must survive each click. + templatePresentation.keepPopupOnPerform = KeepPopupOnPerform.Always + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun isSelected(e: AnActionEvent): Boolean = reports.isCoverageChecked(ref.path) + + override fun setSelected(e: AnActionEvent, state: Boolean) { + reports.setCoverageChecked(ref.path, state) + onToggled() + } + } + + override fun getPreferredSize(): Dimension { + val metrics = getFontMetrics(font) + val width = + PADDING + COVERAGE_ICON.iconWidth + GAP + metrics.stringWidth(text()) + GAP + ARROW.iconWidth + PADDING + val height = maxOf(COVERAGE_ICON.iconHeight, metrics.height, JBUI.scale(16)) + JBUI.scale(4) + return Dimension(width, height) + } + + override fun getMinimumSize(): Dimension = preferredSize + override fun getMaximumSize(): Dimension = preferredSize + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + GraphicsUtil.setupAAPainting(g2) + if (hovered) { + g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val arc = JBUI.scale(6) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } + val icon = if (located.isNotEmpty()) COVERAGE_ICON else COVERAGE_PENDING_ICON + icon.paintIcon(this, g2, PADDING, (height - icon.iconHeight) / 2) + g2.font = font + g2.color = UIUtil.getLabelForeground() + val metrics = g2.fontMetrics + g2.drawString(text(), PADDING + COVERAGE_ICON.iconWidth + GAP, (height - metrics.height) / 2 + metrics.ascent) + ARROW.paintIcon(this, g2, width - PADDING - ARROW.iconWidth, (height - ARROW.iconHeight) / 2) + } finally { + g2.dispose() + } + } + } + private companion object { private const val REFRESH_MS = 500 @@ -334,6 +510,12 @@ class TestoReportsAction( private val READY_ICON: Icon = IconUtil.colorize(ICON, JBColor(0x3574F0, 0x548AF7)) private val SCHEDULED_ICON: Icon = IconUtil.colorize(ICON, JBColor(0x59A869, 0x499C54)) + // Coverage cell: the normal coverage icon once the report is on disk, greyed while it is still pending. + // The Coverage tool window's own icon — the button opens exactly that. (The platform spells it + // `ToolWindowCoverage`; there is no `Toolwindows.Coverage`.) + private val COVERAGE_ICON: Icon = AllIcons.Toolwindows.ToolWindowCoverage + private val COVERAGE_PENDING_ICON: Icon = IconLoader.getDisabledIcon(COVERAGE_ICON) + // Read at paint time, never cached: the scale changes with the monitor the IDE was dragged to. private val PADDING get() = JBUI.scale(5) private val GAP get() = JBUI.scale(4) @@ -446,6 +628,24 @@ private class CopyReportPathAction( // Via toUri(), not browse(File): the latter percent-encodes Windows separators and no browser resolves the result. private fun browseReport(path: Path) = BrowserUtil.browse(path.toUri()) +/** + * The coverage data file this run wrote — the report file itself for clover/cobertura, or `/index.xml` for + * coverage-xml (a directory the platform's file provider cannot consume) — or `null` while there is none yet. + */ +internal fun resolveCoverageDataFile( + ref: TestoReportRef, + project: Project, + mapToLocal: (String) -> String?, + writtenAfter: Long, +): Path? { + val coverageXml = ref.coverageFormat == CoverageFormat.COVERAGE_XML + return reportPathCandidates(ref, project.basePath) { runCatching { mapToLocal(it) }.getOrNull() } + .asSequence() + .mapNotNull { runCatching { Path.of(it) }.getOrNull() } + .map { if (coverageXml && !it.fileName?.toString().equals("index.xml", ignoreCase = true)) it.resolve("index.xml") else it } + .firstOrNull { isReportOf(it, writtenAfter) } +} + /** The announced report as a local file this run wrote, or `null` while there is none. Touches the filesystem. */ internal fun resolveReport( ref: TestoReportRef, @@ -463,3 +663,29 @@ internal fun resolveReport( internal fun isReportOf(path: Path, writtenAfter: Long): Boolean = runCatching { Files.isRegularFile(path) && Files.getLastModifiedTime(path).toMillis() >= writtenAfter }.getOrDefault(false) + +/** + * Resolves a report path off the EDT (the PHP path mapper hits the file index, forbidden on the EDT) and applies it + * back on the EDT — but only if no rerun started meanwhile, which would auto-open the previous run's report over the + * new run and mark the new one already opened. [busy] drops overlapping ticks (this runs off a twice-a-second timer). + */ +private fun resolveReportOffEdt( + project: Project, + reports: TestoReportStore, + busy: AtomicBoolean, + resolve: (startedAt: Long) -> T, + apply: (T) -> Unit, +) { + if (!busy.compareAndSet(false, true)) return + val startedAt = reports.runStartedAt + ApplicationManager.getApplication().executeOnPooledThread { + val found = resolve(startedAt) + ApplicationManager.getApplication().invokeLater( + { + busy.set(false) + if (reports.runStartedAt == startedAt && reports.runFinished) apply(found) + }, + ModalityState.any(), + ) { project.isDisposed } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportStore.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportStore.kt index ad14de43..4ab75202 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportStore.kt @@ -1,5 +1,6 @@ package com.github.xepozz.testo.tests.console +import com.github.xepozz.testo.coverage.format.CoverageFormat import java.nio.file.Path /** @@ -20,6 +21,11 @@ data class TestoReportRef( /** Whether the button can show this report as a page; the rest (data documents, coverage) is kept, not offered. */ val isViewable: Boolean get() = VIEWABLE_FORMATS.any { format.equals(it, ignoreCase = true) } + /** A coverage report — clover / cobertura / coverage-xml — that the "Show coverage" button can apply without a rerun. */ + val coverageFormat: CoverageFormat? get() = CoverageFormat.fromId(format) + + val isCoverage: Boolean get() = coverageFormat != null + companion object { const val FORMAT_HTML: String = "html" @@ -68,6 +74,10 @@ class TestoReportStore { // Reports clicked back off for this run alone — one flag over every way; the standing choices stay checked. private val autoOpenMuted = HashSet() + // Coverage reports unchecked in the grouped Coverage button's dropdown. Checked is the default, so only the + // exceptions are kept; they survive reruns of the same console — the choice is about the report, not the run. + private val coverageUnchecked = HashSet() + /** * Whether the process that writes these reports has exited. Nothing is looked for on disk before it has: a report * is announced when Testo *starts* writing it, over the same path every run, so an earlier check finds the @@ -86,9 +96,14 @@ class TestoReportStore { var runStartedAt: Long = 0 private set + /** A replay pins the clock to the original run, so its captured report copies pass the mtime-vs-start gate. */ + @Volatile + var startedAtOverride: Long? = null + fun noteRunStarted(now: Long = System.currentTimeMillis()) { runFinished = false - runStartedAt = now - now % 1000 + val at = startedAtOverride ?: now + runStartedAt = at - at % 1000 // Arms and mutes belong to the run they were clicked in. synchronized(autoOpenThisRun) { autoOpenThisRun.clear() @@ -133,6 +148,12 @@ class TestoReportStore { fun isAutoOpenMuted(key: String): Boolean = synchronized(autoOpenThisRun) { key in autoOpenMuted } + fun isCoverageChecked(path: String): Boolean = synchronized(coverageUnchecked) { path !in coverageUnchecked } + + fun setCoverageChecked(path: String, checked: Boolean) { + synchronized(coverageUnchecked) { if (checked) coverageUnchecked.remove(path) else coverageUnchecked.add(path) } + } + fun clear() { runFinished = false runStartedAt = 0 @@ -141,12 +162,15 @@ class TestoReportStore { autoOpenThisRun.clear() autoOpenMuted.clear() } + synchronized(coverageUnchecked) { coverageUnchecked.clear() } } fun all(): List = synchronized(reports) { reports.values.toList() } fun viewable(): List = all().filter { it.isViewable } + fun coverage(): List = all().filter { it.isCoverage } + fun primary(): TestoReportRef? = viewable().lastOrNull() } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoRunTimings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoRunTimings.kt index c49d527b..11fce9c9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoRunTimings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoRunTimings.kt @@ -33,6 +33,16 @@ class TestoRunTimings { get() = if (testsMs > 0 && summedTestsMs > 0) summedTestsMs.toDouble() / testsMs else null } + /** The four marks of a run, as the archive stores them. `0` is "never happened". */ + data class Marks( + val startedAt: Long = 0, + val firstTestAt: Long = 0, + val lastTestAt: Long = 0, + val finishedAt: Long = 0, + ) { + val isEmpty: Boolean get() = startedAt == 0L + } + private val lock = Any() private val durationByKey = HashMap() @@ -42,15 +52,19 @@ class TestoRunTimings { private var lastTestAt: Long? = null private var finishedAt: Long? = null + /** Set by [restore]: the marks belong to a run that already happened, so nothing may move them. */ + private var frozen = false + fun noteStart(at: Long = System.currentTimeMillis()) { synchronized(lock) { + if (frozen) return startedAt = at finishedAt = null } } fun noteTestStarted(at: Long = System.currentTimeMillis()) { - synchronized(lock) { if (firstTestAt == null) firstTestAt = at } + synchronized(lock) { if (!frozen && firstTestAt == null) firstTestAt = at } } /** @@ -59,14 +73,37 @@ class TestoRunTimings { */ fun noteTestFinished(key: String, durationMs: Long?, at: Long = System.currentTimeMillis()) { synchronized(lock) { - lastTestAt = at + // The duration is the test's own reported figure, not a wall-clock reading, so it is right even on a + // replay — only the mark is refused there. + if (!frozen) lastTestAt = at durationMs?.let { durationByKey[key] = it } } } /** The end of the run. The first caller wins: testing finishing and the process exiting are the same moment. */ fun noteFinish(at: Long = System.currentTimeMillis()) { - synchronized(lock) { if (finishedAt == null) finishedAt = at } + synchronized(lock) { if (!frozen && finishedAt == null) finishedAt = at } + } + + fun marks(): Marks = synchronized(lock) { + Marks(startedAt ?: 0, firstTestAt ?: 0, lastTestAt ?: 0, finishedAt ?: 0) + } + + /** + * Pin the clock to an archived run's marks and stop accepting new ones. + * + * A replay re-reports the whole run through the same converter, so every mark would otherwise be restamped with + * today's clock — the toolbar would show how long the *replay* took, and (since the replay can finish before the + * toolbar is even wired) often show it counting forever. + */ + fun restore(marks: Marks) { + synchronized(lock) { + frozen = true + startedAt = marks.startedAt.takeIf { it > 0 } + firstTestAt = marks.firstTestAt.takeIf { it > 0 } + lastTestAt = marks.lastTestAt.takeIf { it > 0 } + finishedAt = marks.finishedAt.takeIf { it > 0 } + } } fun isFinished(): Boolean = synchronized(lock) { finishedAt != null } @@ -90,6 +127,8 @@ class TestoRunTimings { fun clear() { synchronized(lock) { + // A frozen clock shows a run that is over; the results form announcing a fresh session must not wipe it. + if (frozen) return durationByKey.clear() startedAt = null firstTestAt = null diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTestTreeDecorator.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTestTreeDecorator.kt index fd508ccf..d076b3c1 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTestTreeDecorator.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTestTreeDecorator.kt @@ -31,8 +31,7 @@ object TestoTestTreeDecorator { private val LOG = logger() /** - * @param describe a node's description, by the key the converter filed it under. Not `SMTestProxy.metainfo`, - * where the platform puts it: [TestoChannelHistory] overwrites that field with the channel output. + * @param describe a node's description, by the key the converter filed it under. */ fun install( console: SMTRunnerConsoleView, diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTreeToolbarActions.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTreeToolbarActions.kt new file mode 100644 index 00000000..f7d4a10e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTreeToolbarActions.kt @@ -0,0 +1,95 @@ +package com.github.xepozz.testo.tests.console + +import com.github.xepozz.testo.TestoBundle +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.project.DumbAware +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.tree.TreeUtil +import java.awt.Container +import javax.swing.JComponent +import javax.swing.JTree +import javax.swing.tree.TreePath + +/** + * Expand/Collapse for whichever tree the toolbar belongs to — the test results tree and the Coverage view both. + * + * The platform puts its own pair inside the test toolbar's overflow ("burger") group, which is two clicks away from a + * thing used constantly; these sit on the visible row. They are added through + * [com.github.xepozz.testo.tests.TestoConsoleProperties.createImportActions] (the toolbar's one open seam) and through + * `CoverageViewExtension.createExtraToolbarActions()`. + * + * Neither action can hold its tree — both toolbars are built before the view is — so it is found at click time: up the + * component chain from the button, searching each ancestor's subtree. The first hit is the tree the toolbar sits above. + */ +internal fun findToolbarTree(e: AnActionEvent): JTree? { + var ancestor: Container? = e.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT) as? Container + while (ancestor != null) { + (ancestor as? JComponent)?.let { UIUtil.findComponentOfType(it, JTree::class.java) }?.let { return it } + ancestor = ancestor.parent + } + return null +} + +/** Expands the selected subtrees, or everything when nothing is selected. */ +internal class TestoTreeExpandAction : AnAction( + TestoBundle.message("testo.tree.expand"), + null, + AllIcons.Actions.Expandall, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + val tree = findToolbarTree(e) ?: return + val selection = tree.selectionPaths + if (selection.isNullOrEmpty()) { + TreeUtil.expandAll(tree) + } else { + selection.forEach { expandSubtree(tree, it) } + } + } + + // Breadth-first with a cap: the model builds children on demand, and a runaway expand must not freeze the EDT. + private fun expandSubtree(tree: JTree, root: TreePath) { + val queue = ArrayDeque() + queue += root + var visited = 0 + while (queue.isNotEmpty() && visited < NODE_CAP) { + val path = queue.removeFirst() + visited++ + val node = path.lastPathComponent + val count = tree.model.getChildCount(node) + if (count == 0) continue + tree.expandPath(path) + for (i in 0 until count) { + queue += path.pathByAddingChild(tree.model.getChild(node, i)) + } + } + } + + private companion object { + private const val NODE_CAP = 10_000 + } +} + +/** Collapses the selected subtrees, or everything when nothing is selected. */ +internal class TestoTreeCollapseAction : AnAction( + TestoBundle.message("testo.tree.collapse"), + null, + AllIcons.Actions.Collapseall, +), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + val tree = findToolbarTree(e) ?: return + val selection = tree.selectionPaths + if (selection.isNullOrEmpty()) { + TreeUtil.collapseAll(tree, 1) + } else { + selection.forEach { tree.collapsePath(it) } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt index 0bb890f6..de31fddf 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoDebugRunner.kt @@ -12,6 +12,7 @@ import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil import com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView import com.intellij.execution.ui.RunContentDescriptor import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project import com.intellij.util.SmartList import com.intellij.xdebugger.XDebugProcess import com.intellij.xdebugger.XDebugProcessStarter @@ -79,15 +80,17 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi // The run path wires the channel tabs via TestoConsoleAugmenter (an ExecutionListener), but its // descriptor lookup misses the debug session, so install them directly here while we hold the console. TestoConsoleAugmenter.installChannels(project, console, properties, processHandler) + // Same reason for the run archive: the augmenter's processTerminated never finds this session. + processHandler.addProcessListener(object : com.intellij.execution.process.ProcessAdapter() { + override fun processTerminated(event: com.intellij.execution.process.ProcessEvent) { + com.github.xepozz.testo.runs.TestoRunArchiver.finalizeRun(project, properties) + } + }) - val debugSession = XDebuggerManager.getInstance(project).startSession(env, object : XDebugProcessStarter() { + val starter = object : XDebugProcessStarter() { override fun start(session: XDebugSession): XDebugProcess { onSessionStart(session, debugServer, sessionId, connectionsManager, project, interpreter, processHandler) val driver = debugExtension.debugDriver - - // The rerun-failed action rides the SM test console's own toolbar (added by the framework from our - // TestoConsoleProperties), so the debug session needs no extra restart-action wiring here. Pushing - // them onto the session toolbar would require the internal XDebugSessionImpl.addRestartActions. return PhpDebugProcessFactory.forPhpTests( session, sessionId, @@ -97,12 +100,38 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi pathProcessor, ) } - }) + } + val descriptor = startDebugDescriptor(project, env, starter) processHandler.startNotify() - return debugSession.runContentDescriptor + return descriptor } catch (e: ExecutionException) { debugServer.unregisterSessionHandler(sessionId) throw e } } + + // 2026.2+: the session builder's result carries the descriptor without XDebugSession.getRunContentDescriptor(), + // which is deprecated there and logs a "split debugger" error. newSessionBuilder is absent on 2025.2, so it is + // reached by reflection (methods resolved off the public interfaces, never the internal impl); the 2025.2 fallback + // is the classic API, whose descriptor accessor is safe on a platform with no split debugger. + private fun startDebugDescriptor( + project: Project, + env: ExecutionEnvironment, + starter: XDebugProcessStarter, + ): RunContentDescriptor? { + val manager = XDebuggerManager.getInstance(project) + val newSessionBuilder = runCatching { + XDebuggerManager::class.java.getMethod("newSessionBuilder", XDebugProcessStarter::class.java) + }.getOrNull() + if (newSessionBuilder != null) { + val builderClass = Class.forName("com.intellij.xdebugger.XDebugSessionBuilder") + val resultClass = Class.forName("com.intellij.xdebugger.XSessionStartedResult") + val builder = newSessionBuilder.invoke(manager, starter) + val withEnv = builderClass.getMethod("environment", ExecutionEnvironment::class.java).invoke(builder, env) + val result = builderClass.getMethod("startSession").invoke(withEnv) + return resultClass.getMethod("getRunContentDescriptor").invoke(result) as RunContentDescriptor? + } + @Suppress("DEPRECATION") + return manager.startSession(env, starter).runContentDescriptor + } } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportFlags.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportFlags.kt new file mode 100644 index 00000000..1248e2a6 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportFlags.kt @@ -0,0 +1,36 @@ +package com.github.xepozz.testo.tests.run + +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import java.nio.file.Path + +/** + * The `--log-html` / `--log-junit` reports written into an IDE-managed folder on every run, uniform with how the + * Coverage run points its `--coverage-*` flags at an IDE-controlled path: Testo writes there, announces the report, + * and the run archive copies it into history. One stable location per run configuration, overwritten each run — the + * announcement's mtime-vs-run-start gate ignores a stale file a stopped run left behind. + */ +internal object TestoReportFlags { + /** HTML as a single self-contained `.html` file: one artifact to archive, and it opens over `file://` in a tab. */ + fun htmlReportFile(project: Project, configurationName: String): Path = + reportDir(project, configurationName).resolve("report.html") + + fun junitReportFile(project: Project, configurationName: String): Path = + reportDir(project, configurationName).resolve("junit.xml") + + /** The `--log-*` tokens for the enabled reports, each pointing at the given local path. Pure. */ + fun reportFlagArguments(logHtml: Boolean, logJunit: Boolean, htmlPath: String, junitPath: String): List = + buildList { + if (logHtml) add("--log-html=$htmlPath") + if (logJunit) add("--log-junit=$junitPath") + } + + private fun reportDir(project: Project, configurationName: String): Path = Path.of( + PathManager.getSystemPath(), + "testo", + "reports", + project.locationHash, + FileUtil.sanitizeFileName(configurationName).ifEmpty { "run" }, + ) +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index 220ad172..1bdd1bdb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt @@ -31,6 +31,7 @@ import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationHandler import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationSettings import com.jetbrains.php.testFramework.run.PhpTestRunnerConfigurationEditor import com.jetbrains.php.testFramework.run.PhpTestRunnerSettings +import java.nio.file.Files class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : PhpTestRunConfiguration( project, @@ -60,7 +61,11 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P val quoted = groups.joinToString(", ") { "'$it'" } return if (groups.size == 1) "Group $quoted" else "Groups $quoted" } - if (runner.suite.isNotEmpty()) return "Suite '${runner.suite}'" + val suites = runner.suites + if (suites.isNotEmpty()) { + val quoted = suites.joinToString(", ") { "'$it'" } + return if (suites.size == 1) "Suite $quoted" else "Suites $quoted" + } if (runner.testoType.isNotEmpty()) return "Type '${runner.testoType}'" } @@ -113,7 +118,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P return runner.groups.isNotEmpty() || runner.excludeGroups.isNotEmpty() - || runner.suite.isNotEmpty() + || runner.suites.isNotEmpty() || runner.testoType.isNotEmpty() || runner.rerunFilters.isNotEmpty() } @@ -132,7 +137,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P override fun getConfigurationEditor(): SettingsEditor { val editor = super.getConfigurationEditor() as PhpTestRunConfigurationEditor - editor.setRunnerOptionsDocumentation("https://github.com/testo/testo") + editor.setRunnerOptionsDocumentation("https://php-testo.github.io/docs/guide/cli-reference") return TestoTestRunConfigurationEditor(editor, this) } @@ -172,6 +177,7 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P command.setWorkingDir(workingDirectory) myHandler.prepareArguments(arguments, testoSettings) + addReportFlags(arguments, interpreter) myHandler.prepareCommand(project, command, executablePath, null, testoSettings.runnerSettings.command) command.importCommandLineSettings(settings.commandLineSettings, workingDirectory) @@ -190,6 +196,26 @@ class TestoRunConfiguration(project: Project, factory: ConfigurationFactory) : P return command } + /** + * Adds `--log-html` / `--log-junit` for the checked reports, pointed at an IDE-managed folder ([TestoReportFlags]). + * Local interpreters only: a remote one would write these to a host path it never maps back, so there the reports + * are left to whatever testo.php configures — no worse than before the flags existed. + */ + private fun addReportFlags(arguments: MutableList, interpreter: PhpInterpreter) { + if (interpreter.isRemote) return + val runner = testoSettings.runnerSettings + if (!runner.logHtml && !runner.logJunit) return + + val htmlPath = TestoReportFlags.htmlReportFile(project, name) + val junitPath = TestoReportFlags.junitReportFile(project, name) + // Testo's report writers create the parent themselves, but a missing directory is the one avoidable failure + // between here and a written report, so make sure of it. + runCatching { Files.createDirectories(htmlPath.parent) } + arguments.addAll( + TestoReportFlags.reportFlagArguments(runner.logHtml, runner.logJunit, htmlPath.toString(), junitPath.toString()) + ) + } + override fun createTestConsoleProperties(executor: Executor): SMTRunnerConsoleProperties { val manager = PhpRemoteInterpreterManager.getInstance() diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt index 6216c71e..c31ef39c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationHandler.kt @@ -36,28 +36,21 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { arguments.add("--type") arguments.add(runner.testoType) } - if (runner.suite.isNotEmpty()) { + for (suite in runner.suites) { arguments.add("--suite") - arguments.add(runner.suite) + arguments.add(suite) } - // Testo takes `--group`/`--exclude-group` repeatedly (OR logic), one name per flag — that is how a - // `#[Group('db', 'slow')]` run reaches the CLI. + // Testo takes `--group` repeatedly (OR logic), one name per flag — that is how a `#[Group('db', 'slow')]` run + // reaches the CLI. Exclusion is the same flag with a `!` prefix; the CLI has no --exclude-group at all. for (group in runner.groups) { arguments.add("--group") arguments.add(group) } for (group in runner.excludeGroups) { - arguments.add("--exclude-group") - arguments.add(group) - } - if (runner.repeat > 0) { - arguments.add("--repeat") - arguments.add(runner.repeat.toString()) - } - if (runner.parallel > 0) { - arguments.add("--parallel") - arguments.add(runner.parallel.toString()) + arguments.add("--group") + arguments.add(if (group.startsWith("!")) group else "!$group") } + // No --parallel until Testo's CLI takes it; then 1 = no flag, 0 = bare --parallel (auto), >1 = --parallel N. for (filter in runner.rerunFilters) { arguments.add("--filter") arguments.add(filter) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt index 3a9771b5..5fc7d562 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfigurationProducer.kt @@ -102,7 +102,7 @@ class TestoRunConfigurationProducer : PhpTestConfigurationProducer`, or [COVERAGE_LEVEL_AUTO] to leave it to testo.php. */ + @Attribute("coverage_level") + var coverageLevel: String = COVERAGE_LEVEL_AUTO, + + /** + * Extra CLI arguments a Coverage run adds and an ordinary run does not. Benchmarks are excluded by default: + * they run the same code many times over, which says nothing about coverage and costs the whole run's time. + */ + @Attribute("coverage_options") + var coverageOptions: String = DEFAULT_COVERAGE_OPTIONS, + + // Reports written into an IDE-managed folder on every run (`--log-html` / `--log-junit`) and copied into history. + // HTML is on by default (it opens in a tab); JUnit is off — it exists for external tooling (mutation testing). + @Attribute("log_html") + var logHtml: Boolean = true, + + @Attribute("log_junit") + var logJunit: Boolean = false, ) : PhpTestRunnerSettings() { + /** Suite names to run, one `--suite` flag each (Testo ORs them). A name is opaque — spaces and all. */ + @get:XCollection(propertyElementName = "suites", style = XCollection.Style.v2) + var suites: MutableList = mutableListOf() + /** Group names to run, one `--group` flag each. A name is opaque: whatever the `#[Group]` attribute spells. */ @get:XCollection(propertyElementName = "groups", style = XCollection.Style.v2) var groups: MutableList = mutableListOf() @@ -48,6 +77,10 @@ class TestoRunnerSettings( @get:Attribute("exclude_group") var legacyExcludeGroup: String = "" + /** The single-suite persisted form of [suites]. Never split: a suite name may hold anything, commas included. */ + @get:Attribute("suite") + var legacySuite: String = "" + // Set only on a "Rerun Failed Tests" clone, never persisted to the saved configuration. @Transient var rerunFilters: List = emptyList() @@ -62,9 +95,20 @@ class TestoRunnerSettings( excludeGroups = parseNames(legacyExcludeGroup).toMutableList() legacyExcludeGroup = "" } + if (legacySuite.isNotEmpty()) { + suites = mutableListOf(legacySuite) + legacySuite = "" + } } companion object Companion { + const val DEFAULT_COVERAGE_OPTIONS = "--type=!bench" + + /** No `--coverage-level` flag at all: the level configured in testo.php stands. */ + const val COVERAGE_LEVEL_AUTO = "auto" + + val COVERAGE_LEVELS: List = listOf(COVERAGE_LEVEL_AUTO, "line", "branch", "path") + /** * Reads the comma-separated text of a Group field into names, dropping blanks. The comma lives in the editor * (a single text field cannot hold a list otherwise) and in the legacy persisted form — never in the model, @@ -99,14 +143,21 @@ class TestoRunnerSettings( runnerSettings.coverageEngine = settings.coverageEngine runnerSettings.parallelTestingEnabled = settings.parallelTestingEnabled runnerSettings.command = settings.command - runnerSettings.suite = settings.suite + runnerSettings.suites = settings.suites.toMutableList() runnerSettings.groups = settings.groups.toMutableList() runnerSettings.excludeGroups = settings.excludeGroups.toMutableList() runnerSettings.legacyGroup = settings.legacyGroup runnerSettings.legacyExcludeGroup = settings.legacyExcludeGroup - runnerSettings.repeat = settings.repeat + runnerSettings.legacySuite = settings.legacySuite runnerSettings.parallel = settings.parallel runnerSettings.testoType = settings.testoType + runnerSettings.coverageClover = settings.coverageClover + runnerSettings.coverageCobertura = settings.coverageCobertura + runnerSettings.coverageXml = settings.coverageXml + runnerSettings.coverageLevel = settings.coverageLevel + runnerSettings.coverageOptions = settings.coverageOptions + runnerSettings.logHtml = settings.logHtml + runnerSettings.logJunit = settings.logJunit runnerSettings.migrateLegacyNames() } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt new file mode 100644 index 00000000..c3bfa9b4 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt @@ -0,0 +1,144 @@ +package com.github.xepozz.testo.tests.run + +import com.github.xepozz.testo.TestoBundle +import com.intellij.icons.AllIcons +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.IconButton +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.InplaceButton +import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.WrapLayout +import java.awt.Component +import java.awt.FlowLayout +import javax.swing.JPanel +import javax.swing.SwingUtilities +import javax.swing.border.CompoundBorder + +/** + * A list of names shown as removable tags. A suite or group name is opaque to the toolchain — whatever the CLI is + * handed is what it selects — so a free-form field had no separator it could safely own; tags remove the question. + */ +class TestoTagsField( + private val emptyLabel: String, + private val addTooltip: String, + /** What the add button offers; null puts an inline field there instead, where Enter adds what was typed. */ + private val suggestions: (() -> List)? = null, +) : JPanel(WrapLayout(FlowLayout.LEFT, JBUI.scale(4), JBUI.scale(2))) { + + private val listeners = mutableListOf<() -> Unit>() + + /** One instance, re-added on every rebuild: a fresh field would lose the focus after each Enter. */ + private val input: JBTextField? = if (suggestions == null) inlineInput() else null + + var names: List = emptyList() + set(value) { + field = value.distinct() + rebuild() + } + + init { + isOpaque = false + rebuild() + } + + fun addChangeListener(listener: () -> Unit) { + listeners.add(listener) + } + + private fun set(value: List) { + names = value + listeners.forEach { it() } + } + + private fun rebuild() { + removeAll() + if (names.isEmpty() && input == null) add(JBLabel(emptyLabel).apply { foreground = JBColor.GRAY }) + names.forEach { add(chip(it)) } + add(input ?: addButton()) + revalidate() + repaint() + } + + private fun chip(name: String): Component = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(2), 0)).apply { + isOpaque = false + border = CompoundBorder( + RoundedLineBorder(JBColor.border(), JBUI.scale(12)), + JBUI.Borders.empty(0, 6, 0, 2), + ) + add(JBLabel(name)) + add( + InplaceButton( + IconButton( + TestoBundle.message("testo.tags.remove", name), + AllIcons.Actions.Close, + AllIcons.Actions.CloseHovered, + ), + ) { set(names - name) }, + ) + } + + private fun addButton(): Component { + lateinit var button: InplaceButton + button = InplaceButton(IconButton(addTooltip, AllIcons.General.Add)) { showSuggestions(button) } + return button + } + + private fun inlineInput(): JBTextField = JBTextField(INPUT_COLUMNS).apply { + emptyText.text = addTooltip + addActionListener { + val name = text.trim() + if (name.isEmpty()) return@addActionListener + text = "" + set(names + name) + SwingUtilities.invokeLater { requestFocusInWindow() } + } + } + + private fun showSuggestions(anchor: Component) { + val provider = suggestions + // The suggestions come off a file-based index (getContainingFiles per key) — a slow operation that would + // freeze the EDT on a large project. Compute it under a modal progress so it runs on a pooled thread. + val known = if (provider == null) emptyList() else + ProgressManager.getInstance().runProcessWithProgressSynchronously, RuntimeException>( + { provider.invoke() }, + TestoBundle.message("testo.tags.loading"), + true, + null, + ).filterNot { it in names } + val rows = known.map { Row(it) } + Row(null) + + JBPopupFactory.getInstance() + .createPopupChooserBuilder(rows) + .setTitle(addTooltip) + .setRenderer(SimpleListCellRenderer.create { label, row, _ -> + label.text = row.name ?: TestoBundle.message("testo.tags.custom") + label.icon = if (row.name == null) AllIcons.General.Add else null + }) + .setItemChosenCallback { row -> + val name = row.name ?: askForName() + if (!name.isNullOrBlank()) set(names + name.trim()) + } + .createPopup() + .showUnderneathOf(anchor) + } + + private fun askForName(): String? = Messages.showInputDialog( + this, + TestoBundle.message("testo.tags.custom.prompt"), + addTooltip, + null, + ) + + /** A suggestion, or — with no name — the row that asks for one the index has never seen. */ + private class Row(val name: String?) + + private companion object { + const val INPUT_COLUMNS = 14 + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index 70569828..61a5de31 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt @@ -1,18 +1,31 @@ package com.github.xepozz.testo.tests.run +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.index.TestoGroupsIndex import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.ui.ComboBox import com.intellij.ui.DocumentAdapter import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.RowLayout import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import com.intellij.uiDesigner.core.GridConstraints +import com.intellij.uiDesigner.core.GridLayoutManager +import com.jetbrains.php.PhpBundle import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import com.jetbrains.php.testFramework.run.PhpTestRunConfigurationEditor +import java.awt.Component +import java.awt.Container +import java.awt.Insets import java.lang.reflect.InvocationTargetException import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel import javax.swing.JSpinner import javax.swing.SpinnerNumberModel import javax.swing.event.DocumentEvent @@ -21,12 +34,23 @@ class TestoTestRunConfigurationEditor( private val parentEditor: PhpTestRunConfigurationEditor, val configuration: TestoRunConfiguration ) : SettingsEditor() { - private val commandField = ComboBox(arrayOf("run")).apply { isEditable = true } - private val suiteField = JBTextField() - private val groupField = JBTextField() - private val excludeGroupField = JBTextField() - private val repeatField = JSpinner(SpinnerNumberModel(0, 0, 10000, 1)) - private val parallelField = JSpinner(SpinnerNumberModel(0, 0, 64, 1)) + private val suiteField = TestoTagsField( + TestoBundle.message("testo.tags.suites.empty"), + TestoBundle.message("testo.tags.suites.add"), + ) + private val groupField = TestoTagsField( + TestoBundle.message("testo.tags.groups.empty"), + TestoBundle.message("testo.tags.groups.add"), + ) { TestoGroupsIndex.allGroups(configuration.project) } + private val excludeGroupField = TestoTagsField( + TestoBundle.message("testo.tags.groups.empty"), + TestoBundle.message("testo.tags.groups.exclude.add"), + ) { TestoGroupsIndex.allGroups(configuration.project) } + // Held disabled until Testo grows the flag; 1 is "no --parallel at all", so a parked field changes no run. + private val parallelField = JSpinner(SpinnerNumberModel(1, 0, 64, 1)).apply { + isEnabled = false + toolTipText = "Not supported by Testo yet" + } private val coverageEngineField = ComboBox(SUPPORTED_COVERAGE_ENGINES.toTypedArray()).apply { renderer = SimpleListCellRenderer.create("") { engine -> when (engine) { @@ -36,6 +60,15 @@ class TestoTestRunConfigurationEditor( } } } + private val htmlReportBox = JBCheckBox("HTML") + private val junitReportBox = JBCheckBox("JUnit") + private val coverageCloverBox = JBCheckBox("Clover") + private val coverageCoberturaBox = JBCheckBox("Cobertura") + private val coverageXmlBox = JBCheckBox("coverage-xml") + private val coverageLevelField = ComboBox(TestoRunnerSettings.COVERAGE_LEVELS.toTypedArray()) + private val coverageOptionsField = JBTextField() + + private val parallelInjected = injectParallelRow() private val myMainPanel = panel { row { @@ -43,16 +76,16 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) }.layout(RowLayout.LABEL_ALIGNED) - group("Testo Options") { + if (!parallelInjected) { row { - label("Command") + label(PARALLEL_LABEL) .gap(RightGap.COLUMNS) - cell(commandField) - .align(AlignX.FILL) + cell(parallelField) } .layout(RowLayout.PARENT_GRID) - .rowComment("Subcommand to execute (default: run)") + } + group("Filter") { row { label("Suite") .gap(RightGap.COLUMNS) @@ -60,7 +93,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--suite=") + .rowComment("One --suite= per tag; type a name and press Enter") row { label("Group") @@ -69,7 +102,7 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--group= (comma-separated for several; prefix a name with ! to exclude)") + .rowComment("One --group= per tag; names come from the #[Group] attributes of the project") row { label("Exclude group") @@ -78,32 +111,56 @@ class TestoTestRunConfigurationEditor( .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--exclude-group= (comma-separated for several)") + .rowComment("One --group=! per tag: the CLI reads the ! prefix as an exclusion") + } + group("Reports") { row { - label("Repeat") + label("Write") .gap(RightGap.COLUMNS) - cell(repeatField) + cell(htmlReportBox) + cell(junitReportBox) } .layout(RowLayout.PARENT_GRID) - .rowComment("--repeat= (0 = disabled)") + .rowComment("--log-html / --log-junit into an IDE-managed folder, kept in the run history. HTML opens in a tab; JUnit is for external tooling") + } + group("Coverage") { row { - label("Parallel") + label("Preferred engine") .gap(RightGap.COLUMNS) - cell(parallelField) + cell(coverageEngineField) + .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("--parallel= (0 = disabled)") + .rowComment("Engine used to collect code coverage") row { - label("Preferred coverage engine") + label("Level") .gap(RightGap.COLUMNS) - cell(coverageEngineField) + cell(coverageLevelField) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("--coverage-level=; auto leaves it to testo.php, or collects branches when Xdebug and Cobertura are both on. Branch and path need Xdebug") + + row { + label("Reports") + .gap(RightGap.COLUMNS) + cell(coverageCloverBox) + cell(coverageCoberturaBox) + cell(coverageXmlBox) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("Reports a Coverage run requests: --coverage-clover / --coverage-cobertura / --coverage-xml; all are applied together") + + row { + label("Additional options") + .gap(RightGap.COLUMNS) + cell(coverageOptionsField) .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("Engine used to collect code coverage") + .rowComment("Arguments added to Coverage runs only, e.g. --coverage-level=branch. The default keeps benchmarks out of coverage") } } @@ -113,38 +170,53 @@ class TestoTestRunConfigurationEditor( override fun textChanged(e: DocumentEvent) = listener() } - commandField.addActionListener { listener() } - suiteField.document.addDocumentListener(documentAdapter) - groupField.document.addDocumentListener(documentAdapter) - excludeGroupField.document.addDocumentListener(documentAdapter) - repeatField.addChangeListener { listener() } + suiteField.addChangeListener(listener) + groupField.addChangeListener(listener) + excludeGroupField.addChangeListener(listener) + coverageOptionsField.document.addDocumentListener(documentAdapter) parallelField.addChangeListener { listener() } + htmlReportBox.addActionListener { listener() } + junitReportBox.addActionListener { listener() } coverageEngineField.addActionListener { listener() } + coverageCloverBox.addActionListener { listener() } + coverageCoberturaBox.addActionListener { listener() } + coverageXmlBox.addActionListener { listener() } + coverageLevelField.addActionListener { listener() } } override fun createEditor(): JComponent = myMainPanel override fun isSpecificallyModified(): Boolean { val runner = configuration.testoSettings.runnerSettings - return commandField.selectedItem != runner.command - || suiteField.text != runner.suite - || TestoRunnerSettings.parseNames(groupField.text) != runner.groups - || TestoRunnerSettings.parseNames(excludeGroupField.text) != runner.excludeGroups - || (repeatField.value as Int) != runner.repeat + return suiteField.names != runner.suites + || groupField.names != runner.groups + || excludeGroupField.names != runner.excludeGroups || (parallelField.value as Int) != runner.parallel + || htmlReportBox.isSelected != runner.logHtml + || junitReportBox.isSelected != runner.logJunit || coverageEngineField.selectedItem != runner.coverageEngine + || coverageCloverBox.isSelected != runner.coverageClover + || coverageCoberturaBox.isSelected != runner.coverageCobertura + || coverageXmlBox.isSelected != runner.coverageXml + || coverageLevelField.selectedItem != runner.coverageLevel + || coverageOptionsField.text != runner.coverageOptions || parentEditor.isSpecificallyModified } override fun resetEditorFrom(testoRunConfiguration: TestoRunConfiguration) { val runnerSettings = testoRunConfiguration.testoSettings.runnerSettings - commandField.selectedItem = runnerSettings.command - suiteField.text = runnerSettings.suite - groupField.text = TestoRunnerSettings.formatNames(runnerSettings.groups) - excludeGroupField.text = TestoRunnerSettings.formatNames(runnerSettings.excludeGroups) - repeatField.value = runnerSettings.repeat + suiteField.names = runnerSettings.suites + groupField.names = runnerSettings.groups + excludeGroupField.names = runnerSettings.excludeGroups parallelField.value = runnerSettings.parallel + htmlReportBox.isSelected = runnerSettings.logHtml + junitReportBox.isSelected = runnerSettings.logJunit coverageEngineField.selectedItem = runnerSettings.coverageEngine + coverageCloverBox.isSelected = runnerSettings.coverageClover + coverageCoberturaBox.isSelected = runnerSettings.coverageCobertura + coverageXmlBox.isSelected = runnerSettings.coverageXml + coverageLevelField.selectedItem = runnerSettings.coverageLevel + coverageOptionsField.text = runnerSettings.coverageOptions parentEditor.javaClass.declaredMethods.find { it.name == "resetEditorFrom" && it.parameterCount == 1 }?.let { it.isAccessible = true @@ -166,18 +238,65 @@ class TestoTestRunConfigurationEditor( } ?: parentEditor.applyTo(testoRunConfiguration) val runnerSettings = testoRunConfiguration.testoSettings.runnerSettings - runnerSettings.command = commandField.selectedItem as? String ?: "run" - runnerSettings.suite = suiteField.text - // A single text field cannot hold a list, so the comma is the editor's own separator: names are parsed here - // and the model below this line never sees one. - runnerSettings.groups = TestoRunnerSettings.parseNames(groupField.text).toMutableList() - runnerSettings.excludeGroups = TestoRunnerSettings.parseNames(excludeGroupField.text).toMutableList() - runnerSettings.repeat = repeatField.value as? Int ?: 0 - runnerSettings.parallel = parallelField.value as? Int ?: 0 + runnerSettings.suites = suiteField.names.toMutableList() + runnerSettings.groups = groupField.names.toMutableList() + runnerSettings.excludeGroups = excludeGroupField.names.toMutableList() + runnerSettings.parallel = parallelField.value as? Int ?: 1 + runnerSettings.logHtml = htmlReportBox.isSelected + runnerSettings.logJunit = junitReportBox.isSelected runnerSettings.coverageEngine = coverageEngineField.selectedItem as? CoverageEngine ?: CoverageEngine.XDEBUG + runnerSettings.coverageClover = coverageCloverBox.isSelected + runnerSettings.coverageCobertura = coverageCoberturaBox.isSelected + runnerSettings.coverageXml = coverageXmlBox.isSelected + runnerSettings.coverageLevel = coverageLevelField.selectedItem as? String + ?: TestoRunnerSettings.COVERAGE_LEVEL_AUTO + runnerSettings.coverageOptions = coverageOptionsField.text } + /** + * Puts Parallel into the PHP editor's own *Test Runner options* row, where the rest of the runner's flags are. + * That row is a one-row `GridLayoutManager` form with no seam to extend — the editor exposes the whole panel and + * nothing smaller — so it is found by its label and rebuilt with a second row; re-adding the children with the + * constraints they already had keeps the label column shared. Anything unexpected falls back to a row of our own. + */ + private fun injectParallelRow(): Boolean = runCatching { + val optionsLabel = findRunnerOptionsLabel(parentEditor.component) ?: return false + val row = optionsLabel.parent as? JPanel ?: return false + val layout = row.layout as? GridLayoutManager ?: return false + if (layout.rowCount != 1) return false + + val existing = row.components.map { it to layout.getConstraintsForComponent(it) } + row.removeAll() + row.layout = GridLayoutManager(2, layout.columnCount, Insets(0, 0, 0, 0), -1, -1) + existing.forEach { (component, constraints) -> row.add(component, constraints) } + row.add(JBLabel(PARALLEL_LABEL), labelConstraints(1, 0)) + row.add(parallelField, labelConstraints(1, 1)) + true + }.getOrDefault(false) + + // The bundle string carries the mnemonic marker (`Test Runner &options:`), which the form strips into a + // displayedMnemonic — so the rendered label never equals the raw message. + private fun findRunnerOptionsLabel(component: Component): JLabel? { + val text = UIUtil.removeMnemonic(PhpBundle.message("php.test.framework.field.test.runner.options")) + return when { + component is JLabel && component.text == text -> component + component is Container -> component.components.firstNotNullOfOrNull { findRunnerOptionsLabel(it) } + else -> null + } + } + + private fun labelConstraints(row: Int, column: Int) = GridConstraints( + row, column, 1, 1, + GridConstraints.ANCHOR_WEST, + GridConstraints.FILL_NONE, + GridConstraints.SIZEPOLICY_FIXED, + GridConstraints.SIZEPOLICY_FIXED, + null, null, null, + ) + companion object { val SUPPORTED_COVERAGE_ENGINES: List = listOf(CoverageEngine.XDEBUG, CoverageEngine.PCOV) + + private const val PARALLEL_LABEL = "Parallel (0 = auto)" } } diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCodeVisionGroupSettings.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCodeVisionGroupSettings.kt new file mode 100644 index 00000000..fef36849 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCodeVisionGroupSettings.kt @@ -0,0 +1,22 @@ +package com.github.xepozz.testo.ui + +import com.github.xepozz.testo.TestoBundle +import com.intellij.codeInsight.codeVision.settings.CodeVisionGroupSettingProvider + +/** + * Names and descriptions for our Code Vision lenses in Settings | Editor | Inlay Hints | Code Vision. Without a + * [CodeVisionGroupSettingProvider] the platform falls back to `codeLens..name`/`.description` keys in its own + * bundle, which we don't own — so the entries showed up blank. `groupId` must equal each provider's `id` (a + * [com.intellij.codeInsight.codeVision.CodeVisionProvider]'s `groupId` defaults to its `id`). + */ +class TestoHistoryCodeVisionGroupSettingProvider : CodeVisionGroupSettingProvider { + override val groupId: String = "testo.history" + override val groupName: String get() = TestoBundle.message("testo.codeVision.history.name") + override val description: String get() = TestoBundle.message("testo.codeVision.history.description") +} + +class TestoCoverageByTestCodeVisionGroupSettingProvider : CodeVisionGroupSettingProvider { + override val groupId: String = "testo.coverage.byTest" + override val groupName: String get() = TestoBundle.message("testo.coverage.byTest.name") + override val description: String get() = TestoBundle.message("testo.coverage.byTest.settings.description") +} diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt new file mode 100644 index 00000000..e1dc227c --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -0,0 +1,131 @@ +package com.github.xepozz.testo.ui + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.TestoIcons +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.coverage.perTest.TEST_ID_ORDER +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher +import com.github.xepozz.testo.coverage.perTest.TestoTestIdentityMapper +import com.github.xepozz.testo.coverage.perTest.shortTestLabel +import com.github.xepozz.testo.coverage.perTest.testsCoveringElement +import com.intellij.codeInsight.codeVision.CodeVisionAnchorKind +import com.intellij.icons.AllIcons +import com.intellij.codeInsight.codeVision.CodeVisionEntry +import com.intellij.codeInsight.codeVision.CodeVisionRelativeOrdering +import com.intellij.codeInsight.codeVision.ui.model.ClickableTextCodeVisionEntry +import com.intellij.codeInsight.hints.InlayHintsUtils +import com.intellij.codeInsight.hints.codeVision.CodeVisionProviderBase +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.pom.Navigatable +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.SyntaxTraverser +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.awt.RelativePoint +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.jetbrains.php.lang.psi.PhpFile +import com.jetbrains.php.lang.psi.elements.Function +import java.awt.event.MouseEvent +import javax.swing.Icon + +/** + * Code Vision lens on any PHP method/function that Testo's per-test coverage recorded as covered, reading the + * persistent [TestoCoverageByTestIndex]. Shows "N covering tests"; clicking lists them and navigates to the chosen one. + * + * This is the public-API answer to "how many tests cover this": the native `CoverageEngine.getTestsForLine` + * gutter is `@ApiStatus.Internal` and cannot be used by a third-party plugin, so the same data drives our own lens. + * The lens is empty (hidden) until a `coverage-xml` coverage run has populated the index. + */ +class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { + + override val id: String = "testo.coverage.byTest" + + override val name: String = TestoBundle.message("testo.coverage.byTest.name") + + override val relativeOrderings: List = + listOf(CodeVisionRelativeOrdering.CodeVisionRelativeOrderingFirst) + + override val defaultAnchor: CodeVisionAnchorKind get() = CodeVisionAnchorKind.Default + + override fun acceptsFile(file: PsiFile): Boolean = file is PhpFile + + override fun acceptsElement(element: PsiElement): Boolean = element is Function + + override fun getHint(element: PsiElement, file: PsiFile): String? { + val count = testsCoveringElement(element as? Function ?: return null).size + return when (count) { + 0 -> null + 1 -> TestoBundle.message("testo.coverage.byTest.hint.one") + else -> TestoBundle.message("testo.coverage.byTest.hint.many", count) + } + } + + override fun handleClick(editor: Editor, element: PsiElement, event: MouseEvent?) { + val function = element as? Function ?: return + val project = function.project + val tests = testsCoveringElement(function).sortedWith(TEST_ID_ORDER) + if (tests.isEmpty()) return + + val mapper = TestoTestIdentityMapper.getInstance() + // A run-all action first (like the *Run covering tests* gutter), then one navigable row per test. + val rows = buildList { + add(Row(null, TestoBundle.message("testo.coverage.editor.popup.run.all", tests.size), AllIcons.Actions.RunAll)) + tests.forEach { add(Row(it, shortTestLabel(it), AllIcons.Nodes.Method)) } + } + val popup = JBPopupFactory.getInstance() + .createPopupChooserBuilder(rows) + .setTitle( + if (tests.size == 1) TestoBundle.message("testo.coverage.byTest.chooser.one") + else TestoBundle.message("testo.coverage.byTest.chooser.many", tests.size) + ) + .setRenderer(SimpleListCellRenderer.create { label, row, _ -> + label.text = row.label + label.icon = row.icon + }) + .setItemChosenCallback { row -> + val test = row.test + if (test == null) { + TestoCoveringTestsLauncher.run(project, tests, TestoCoveringTestsLauncher.runName(function.name, tests.size)) + } else { + (mapper.resolve(test, project) as? Navigatable)?.takeIf { it.canNavigate() }?.navigate(true) + } + } + .createPopup() + if (event != null) popup.show(RelativePoint(event)) else popup.showInBestPositionFor(editor) + } + + private class Row(val test: TestId?, val label: String, val icon: Icon) + + // Mirror CodeVisionProviderBase's traversal but decorate the entry with the Testo icon and a tooltip (as the + // "Show history" lens does), and route the click through handleClick. + override fun computeForEditor(editor: Editor, file: PsiFile): List> { + if (file.project.isDefault || !acceptsFile(file)) return emptyList() + + val lenses = ArrayList>() + for (element in SyntaxTraverser.psiTraverser(file)) { + if (!acceptsElement(element)) continue + if (!InlayHintsUtils.isFirstInLine(element)) continue + val hint = getHint(element, file) ?: continue + + val pointer = SmartPointerManager.createPointer(element) + val onClick: (MouseEvent?, Editor) -> Unit = { event, clickEditor -> + pointer.element?.let { handleClick(clickEditor, it, event) } + } + val range = InlayHintsUtils.getTextRangeWithoutLeadingCommentsAndWhitespaces(element) + lenses.add( + range to ClickableTextCodeVisionEntry( + hint, + id, + onClick, + TestoIcons.TESTO, + hint, + TestoBundle.message("testo.coverage.byTest.tooltip"), + ) + ) + } + return lenses + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt index 7877b721..cdc6357a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt @@ -3,34 +3,33 @@ package com.github.xepozz.testo.ui import com.github.xepozz.testo.TestoIcons import com.github.xepozz.testo.isTestoExecutable import com.github.xepozz.testo.isTestoFile +import com.github.xepozz.testo.runs.replayNewestRun +import com.github.xepozz.testo.runs.showRunHistoryForTest import com.github.xepozz.testo.tests.TestoTestRunLineMarkerProvider +import com.github.xepozz.testo.tests.console.TestoHistoryIndex import com.intellij.codeInsight.codeVision.CodeVisionAnchorKind import com.intellij.codeInsight.codeVision.CodeVisionEntry import com.intellij.codeInsight.codeVision.CodeVisionRelativeOrdering import com.intellij.codeInsight.codeVision.ui.model.ClickableTextCodeVisionEntry import com.intellij.codeInsight.hints.InlayHintsUtils import com.intellij.codeInsight.hints.codeVision.CodeVisionProviderBase -import com.intellij.execution.TestStateStorage -import com.intellij.execution.testframework.sm.TestHistoryConfiguration import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange -import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.SmartPointerManager import com.intellij.psi.SyntaxTraverser import com.jetbrains.php.lang.psi.elements.Function import java.awt.event.MouseEvent -import java.io.File /** * Code Vision lens shown next to every Testo test method/function in the PHP editor, * right where the green gutter run icons live. * - * v1: the lens reads "Show history" and is shown only for tests that already have a stored - * run result. Clicking it re-opens the latest Testo test-run history session in the Run - * tool window. The pass/total (N/M) count is intentionally NOT computed yet — see [historyHint]. + * The lens reads "Show history" and is shown only for tests the run archive + * ([com.github.xepozz.testo.runs.TestoRunStore]) holds; clicking it pops up the archived runs containing that test to + * replay one into a full Testo run tab. The pass/total (N/M) count is intentionally NOT computed yet — see [historyHint]. */ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { @@ -54,19 +53,16 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { override fun getHint(element: PsiElement, file: PsiFile): String? { val function = element as? Function ?: return null val url = TestoTestRunLineMarkerProvider.getLocationHint(function) - // Show the lens only when a saved run actually contains this test. (The last status survives in - // TestStateStorage even after the run XML is pruned, so storage alone would show a lens that opens nothing.) - if (!com.github.xepozz.testo.tests.console.TestoHistoryIndex.contains(file.project, url)) return null + if (!TestoHistoryIndex.contains(file.project, url)) return null return historyHint(url) } /** - * The lens label for a test that has stored history. + * The lens label for a test that has an archived run. * - * v1 returns the plain "Show history" action label. This is the single hook to enable the - * N/M (passed/total) count later: read aggregated results for [url] (e.g. via - * [TestStateStorage] / the imported SMTRunner tree) and return something like - * "$passed/$total passed — Show history". Returning null hides the lens. + * v1 returns the plain "Show history" action label. This is the single hook to enable the N/M (passed/total) + * count later: the archive already carries each run's per-status tally, so this could read the newest run holding + * [url] and return something like "$passed/$total passed — Show history". Returning null hides the lens. */ @Suppress("UNUSED_PARAMETER") private fun historyHint(url: String): String? = "Show history" @@ -74,8 +70,7 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { override fun handleClick(editor: Editor, element: PsiElement, event: MouseEvent?) { val function = element as? Function ?: return openLatestHistory(element.project) val url = TestoTestRunLineMarkerProvider.getLocationHint(function) - // Open the most recent run that actually contains this test (not just the globally latest) and select its node. - com.github.xepozz.testo.tests.console.openTestoHistoryForTest(element.project, url) + showRunHistoryForTest(element.project, url, editor, event) } /** @@ -104,7 +99,7 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { onClick, TestoIcons.TESTO, hint, - "Open the latest test run history", + "Replay the newest archived run containing this test", ) ) } @@ -112,30 +107,11 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { } companion object { - /** - * Re-open the most recent Testo test-run history session in the Run tool window. - * - * Mirrors [com.intellij.execution.testframework.sm.runner.history.actions.ImportTestsGroup]: - * resolve every recorded history file under the project history root, keep the existing - * ones, and import the most recently modified XML. openTestoHistory recreates the run - * configuration from the XML and opens the SM test tree tab (on our own console properties). - */ - fun openLatestHistory(project: Project) { - val historyRoot = TestStateStorage.getTestHistoryRoot(project) - val latest = TestHistoryConfiguration.getInstance(project).files - .map { File(historyRoot, it) } - .filter { it.exists() } - .maxByOrNull { it.lastModified() } - ?: return // No history yet — getHint already hid the lens, so this is just defensive. - - val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(latest) ?: return - // Build the imported console on our own properties so its toolbar matches a live run (see openTestoHistory). - com.github.xepozz.testo.tests.console.openTestoHistory(project, virtualFile) - } + /** Re-open the most recent archived Testo run in the Run tool window. */ + fun openLatestHistory(project: Project) = replayNewestRun(project) } } -// Refresh note: this provider is DaemonBound, so lenses recompute with the daemon. To refresh -// immediately after a run finishes (so the lens appears as soon as the first result is stored), -// subscribe to SMTRunnerEventsListener.TEST_STATUS / run completion and call -// DaemonCodeAnalyzer.getInstance(project).restart(). Skipped in v1 to keep the change small. +// Refresh note: this provider is DaemonBound, so lenses recompute with the daemon — which a test run never triggers +// (it touches no PHP source). TestoRunArchiver forces the pass through TestoHistoryIndex.refreshLens the moment a run +// is archived, which is what makes a just-run test's lens appear without an IDE restart. diff --git a/src/main/resources/META-INF/coverage.xml b/src/main/resources/META-INF/coverage.xml index 2bd203ae..e07e1126 100644 --- a/src/main/resources/META-INF/coverage.xml +++ b/src/main/resources/META-INF/coverage.xml @@ -1,6 +1,11 @@ + + + + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index b3bc8db1..2e784526 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -40,6 +40,13 @@ + + + + @@ -78,6 +85,8 @@ implementation="com.github.xepozz.testo.tests.console.TestoRepeatedFrameFolding"/> + @@ -150,6 +159,9 @@ class="com.github.xepozz.testo.tests.actions.TestoRerunStyleMirrorAction"/> + + () TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -113,7 +113,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { fun testPrepareArguments_withSuite() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.suite = "unit" + settings.runnerSettings.suites = mutableListOf("unit") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) @@ -178,7 +178,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(listOf("--exclude-group", "slow", "--exclude-group", "flaky"), arguments) + assertEquals(listOf("--group", "!slow", "--group", "!flaky"), arguments) } fun testPrepareArguments_withExcludeGroup() { @@ -188,71 +188,61 @@ class TestoRunConfigurationHandlerTest : TestCase() { TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(2, arguments.size) - assertEquals("--exclude-group", arguments[0]) - assertEquals("slow", arguments[1]) + // Testo has no --exclude-group: exclusion is --group with a `!` prefix. + assertEquals(listOf("--group", "!slow"), arguments) } - fun testPrepareArguments_withRepeat() { + fun testPrepareArguments_excludedGroupIsNotPrefixedTwice() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.repeat = 3 + settings.runnerSettings.excludeGroups = mutableListOf("!slow") val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(2, arguments.size) - assertEquals("--repeat", arguments[0]) - assertEquals("3", arguments[1]) + assertEquals(listOf("--group", "!slow"), arguments) } - fun testPrepareArguments_withParallel() { + fun testPrepareArguments_parallelNeverEmitted() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.parallel = 8 val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(2, arguments.size) - assertEquals("--parallel", arguments[0]) - assertEquals("8", arguments[1]) + assertTrue("Testo's CLI has no --parallel; a legacy value must not break the run", arguments.isEmpty()) } - fun testPrepareArguments_zeroRepeatAndParallel_skipped() { + /** The coverage-only options belong to the Coverage runner alone and must never reach an ordinary run. */ + fun testPrepareArguments_coverageOptionsStayOutOfAnOrdinaryRun() { val settings = TestoRunConfigurationSettings() - settings.runnerSettings.repeat = 0 - settings.runnerSettings.parallel = 0 + settings.runnerSettings.coverageOptions = "--type=!bench" val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertTrue("Zero repeat/parallel should not add arguments", arguments.isEmpty()) + assertTrue(arguments.isEmpty()) } fun testPrepareArguments_allOptions() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.testoType = "bench" - settings.runnerSettings.suite = "integration" + settings.runnerSettings.suites = mutableListOf("integration") settings.runnerSettings.groups = mutableListOf("db") settings.runnerSettings.excludeGroups = mutableListOf("slow") - settings.runnerSettings.repeat = 2 settings.runnerSettings.parallel = 4 val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(12, arguments.size) + assertEquals(8, arguments.size) assertTrue(arguments.contains("--type")) assertTrue(arguments.contains("bench")) assertTrue(arguments.contains("--suite")) assertTrue(arguments.contains("integration")) assertTrue(arguments.contains("--group")) assertTrue(arguments.contains("db")) - assertTrue(arguments.contains("--exclude-group")) - assertTrue(arguments.contains("slow")) - assertTrue(arguments.contains("--repeat")) - assertTrue(arguments.contains("2")) - assertTrue(arguments.contains("--parallel")) - assertTrue(arguments.contains("4")) + assertTrue(arguments.contains("!slow")) + assertFalse(arguments.contains("--parallel")) } fun testPrepareArguments_withSingleRerunFilter() { @@ -311,21 +301,18 @@ class TestoRunConfigurationHandlerTest : TestCase() { fun testPrepareArguments_orderIsCorrect() { val settings = TestoRunConfigurationSettings() settings.runnerSettings.testoType = "bench" - settings.runnerSettings.suite = "unit" + settings.runnerSettings.suites = mutableListOf("unit") settings.runnerSettings.groups = mutableListOf("fast") - settings.runnerSettings.parallel = 2 val arguments = mutableListOf() TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - // type comes first, then suite, then group, then parallel + // type comes first, then suite, then group assertEquals("--type", arguments[0]) assertEquals("bench", arguments[1]) assertEquals("--suite", arguments[2]) assertEquals("unit", arguments[3]) assertEquals("--group", arguments[4]) assertEquals("fast", arguments[5]) - assertEquals("--parallel", arguments[6]) - assertEquals("2", arguments[7]) } } diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunTimingsTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunTimingsTest.kt index f7f4700c..9a79d57c 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunTimingsTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunTimingsTest.kt @@ -121,6 +121,43 @@ class TestoRunTimingsTest { assertEquals(0, spans.totalMs) } + @Test + fun marksAreReadBackAsRecorded() { + val timings = TestoRunTimings() + timings.noteStart(at = 5) + timings.noteTestStarted(at = 6) + timings.noteTestFinished("a", durationMs = 1, at = 7) + timings.noteFinish(at = 8) + + assertEquals(TestoRunTimings.Marks(5, 6, 7, 8), timings.marks()) + } + + @Test + fun restoredMarksOutliveEverythingAReplayReports() { + val timings = TestoRunTimings() + timings.restore(TestoRunTimings.Marks(startedAt = 1_000, firstTestAt = 1_400, lastTestAt = 2_100, finishedAt = 2_600)) + // A replay re-reports the whole run with today's clock, and the results form announces a fresh session. + timings.noteStart(at = 9_000) + timings.noteTestStarted(at = 9_100) + timings.noteTestFinished("a", durationMs = 300, at = 9_200) + timings.noteFinish(at = 9_300) + timings.clear() + + val spans = timings.snapshot(now = 50_000) + assertTrue(spans.finished) + assertEquals(1_600, spans.totalMs) + assertEquals(400, spans.startupMs) + assertEquals(700, spans.testsMs) + // The duration is the test's own figure, so it still counts — it is no reading of the replay's clock. + assertEquals(300, spans.summedTestsMs) + } + + @Test + fun anArchiveWithoutMarksIsRecognizedAsEmpty() { + assertTrue(TestoRunTimings.Marks().isEmpty) + assertFalse(TestoRunTimings.Marks(startedAt = 1).isEmpty) + } + @Test fun clearForgetsTheWholeRun() { val timings = TestoRunTimings() diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt index 6a6be988..96d6feca 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt @@ -71,6 +71,17 @@ class TestoRunnerSettingsSerializationTest : TestCase() { assertEquals("", restored.legacyExcludeGroup) } + /** A suite name is never split: unlike the group field it never had a separator, and may hold anything. */ + fun testLegacySingleSuiteBecomesAOneItemList() { + val restored = deserialize("""""") + + restored.migrateLegacyNames() + + assertEquals(listOf("Unit, slow"), restored.suites) + assertEquals("", restored.legacySuite) + assertFalse("The legacy attribute is gone after a save", serialize(restored).contains("suite=\"")) + } + fun testMigrationIsIdempotentAndKeepsExistingLists() { val settings = TestoRunnerSettings().apply { groups = mutableListOf("db") } @@ -90,6 +101,24 @@ class TestoRunnerSettingsSerializationTest : TestCase() { assertFalse("The legacy attribute is gone after a save: $xml", xml.contains("group=\"")) } + /** A configuration saved before the field existed reads back with the default, benchmarks excluded and all. */ + fun testCoverageOptionsDefaultSurvivesAnOlderConfiguration() { + val restored = deserialize("""""") + + assertEquals("--type=!bench", restored.coverageOptions) + assertEquals("auto", restored.coverageLevel) + assertFalse("The defaults must stay out of the XML", serialize(restored).contains("coverage_")) + } + + fun testCoverageOptionsRoundTrip() { + val settings = TestoRunnerSettings(coverageLevel = "branch", coverageOptions = "--filter x") + + val restored = deserialize(serialize(settings)) + + assertEquals("branch", restored.coverageLevel) + assertEquals("--filter x", restored.coverageOptions) + } + fun testUnknownElementsDoNotBreakDeserialization() { // Forward compatibility: an XML written by a newer plugin must not blow up the older one. val restored = deserialize("""""") diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt index f0fa6ef8..bbd1c17d 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsTest.kt @@ -12,12 +12,13 @@ class TestoRunnerSettingsTest : TestCase() { assertEquals(-1, settings.dataSetIndex) assertFalse(settings.parallelTestingEnabled) assertEquals("run", settings.command) - assertEquals("", settings.suite) + assertTrue(settings.suites.isEmpty()) assertTrue(settings.groups.isEmpty()) assertTrue(settings.excludeGroups.isEmpty()) - assertEquals(0, settings.repeat) - assertEquals(0, settings.parallel) + assertEquals(1, settings.parallel) assertEquals("", settings.testoType) + assertEquals("auto", settings.coverageLevel) + assertEquals("--type=!bench", settings.coverageOptions) assertTrue("rerunFilters defaults to empty", settings.rerunFilters.isEmpty()) } @@ -27,11 +28,10 @@ class TestoRunnerSettingsTest : TestCase() { dataSetIndex = 5, parallelTestingEnabled = true, command = "list", - suite = "unit", - repeat = 3, parallel = 4, testoType = "bench", ).apply { + suites = mutableListOf("unit") groups = mutableListOf("fast") excludeGroups = mutableListOf("slow") } @@ -39,10 +39,9 @@ class TestoRunnerSettingsTest : TestCase() { assertEquals(5, settings.dataSetIndex) assertTrue(settings.parallelTestingEnabled) assertEquals("list", settings.command) - assertEquals("unit", settings.suite) + assertEquals(listOf("unit"), settings.suites) assertEquals(listOf("fast"), settings.groups) assertEquals(listOf("slow"), settings.excludeGroups) - assertEquals(3, settings.repeat) assertEquals(4, settings.parallel) assertEquals("bench", settings.testoType) } @@ -71,11 +70,10 @@ class TestoRunnerSettingsTest : TestCase() { assertEquals(-1, result.dataSetIndex) assertFalse(result.parallelTestingEnabled) assertEquals("run", result.command) - assertEquals("", result.suite) + assertTrue(result.suites.isEmpty()) assertTrue(result.groups.isEmpty()) assertTrue(result.excludeGroups.isEmpty()) - assertEquals(0, result.repeat) - assertEquals(0, result.parallel) + assertEquals(1, result.parallel) assertEquals("", result.testoType) } @@ -85,11 +83,12 @@ class TestoRunnerSettingsTest : TestCase() { dataSetIndex = 7, parallelTestingEnabled = true, command = "debug", - suite = "integration", - repeat = 5, parallel = 8, testoType = "inline", + coverageLevel = "branch", + coverageOptions = "--type=!bench", ) + source.suites = mutableListOf("integration") source.groups = mutableListOf("database") source.excludeGroups = mutableListOf("slow") source.scope = PhpTestRunnerSettings.Scope.Method @@ -108,12 +107,13 @@ class TestoRunnerSettingsTest : TestCase() { assertEquals(7, result.dataSetIndex) assertTrue(result.parallelTestingEnabled) assertEquals("debug", result.command) - assertEquals("integration", result.suite) + assertEquals(listOf("integration"), result.suites) assertEquals(listOf("database"), result.groups) assertEquals(listOf("slow"), result.excludeGroups) - assertEquals(5, result.repeat) assertEquals(8, result.parallel) assertEquals("inline", result.testoType) + assertEquals("branch", result.coverageLevel) + assertEquals("--type=!bench", result.coverageOptions) // rerunFilters is @Transient and intentionally NOT copied — stays empty on the result. assertTrue("rerunFilters is not copied (transient)", result.rerunFilters.isEmpty()) } diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt index beecd028..0818c5ed 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt @@ -1,36 +1,119 @@ package com.github.xepozz.testo.coverage +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.tests.run.TestoRunnerSettings +import com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine.CoverageEngine import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test +import java.nio.file.Path /** - * Pure-logic tests for [TestoCoverageProgramRunner.createCoverageArguments] — the CLI-flag mapping that decides between - * `--coverage-clover=` and the bare `--coverage`. The override is protected, so the test extends the runner to - * reach it (no platform state is needed to call the pure String? -> List mapping). Also pins the public - * runner/executor id constants referenced by plugin.xml. + * Pure-logic tests for the Coverage run's CLI-flag mapping: which formats the settings enable, the local path each one + * writes to, and the flag spelling. The methods are on the runner, so the test extends it (no platform state needed). + * Also pins the public runner/executor id constants referenced by plugin.xml. */ class TestoCoverageArgumentsTest : TestoCoverageProgramRunner() { + private val base = "/tmp/report@cfg.xml" + + @Test + fun defaultsRequestCoberturaAndCoverageXmlButNotClover() { + val flags = coverageFlagLocalPaths(TestoRunnerSettings(), base) + assertEquals( + listOf( + CoverageFormat.COBERTURA to "/tmp/report@cfg-cobertura.xml", + CoverageFormat.COVERAGE_XML to "/tmp/report@cfg-coverage-xml", + ), + flags, + ) + } + + @Test + fun everyFormatEnabledYieldsThreeFlags() { + val settings = TestoRunnerSettings(coverageClover = true) + val flags = coverageFlagLocalPaths(settings, base) + assertEquals( + listOf("--coverage-clover=/tmp/report@cfg-clover.xml"), + flags.filter { it.first == CoverageFormat.CLOVER }.map { coverageFlagFor(it.first, it.second) }, + ) + assertEquals(3, flags.size) + } + + @Test + fun noBasePathMeansNoFlags() { + assertTrue(coverageFlagLocalPaths(TestoRunnerSettings(), null).isEmpty()) + assertTrue(coverageFlagLocalPaths(TestoRunnerSettings(), "").isEmpty()) + } + + @Test + fun everyFormatDisabledMeansNoFlags() { + val settings = TestoRunnerSettings(coverageCobertura = false, coverageXml = false) + assertTrue(coverageFlagLocalPaths(settings, base).isEmpty()) + } + + @Test + fun pathWithSpacesIsKeptVerbatimInSingleFlag() { + assertEquals( + "--coverage-cobertura=/path with space/r-cobertura.xml", + coverageFlagFor(CoverageFormat.COBERTURA, "/path with space/r-cobertura.xml"), + ) + } + + @Test + fun flagDataFilesPointAtIndexXmlForCoverageXml() { + val files = flagLocalDataFiles( + listOf( + CoverageFormat.COBERTURA to "/tmp/r-cobertura.xml", + CoverageFormat.COVERAGE_XML to "/tmp/r-coverage-xml", + ) + ) + assertEquals(Path.of("/tmp/r-cobertura.xml"), files[0]) + assertEquals(Path.of("/tmp/r-coverage-xml/index.xml"), files[1]) + } + @Test - fun nonEmptyPathProducesCloverFlag() { - assertEquals(listOf("--coverage-clover=/tmp/report@cfg.xml"), createCoverageArguments("/tmp/report@cfg.xml")) + fun coverageOnlyOptionsDefaultToExcludingBenchmarks() { + // The default engine/report (Xdebug + Cobertura) turns auto into branch, so the level leads the options. + assertEquals(listOf("--coverage-level=branch", "--type=!bench"), extraCoverageArguments(TestoRunnerSettings())) + } + + @Test + fun coverageOnlyOptionsAreSplitLikeACommandLine() { + val settings = TestoRunnerSettings(coverageOptions = """--type=!bench --filter "a b"""", coverageCobertura = false) + + assertEquals(listOf("--type=!bench", "--filter", "a b"), extraCoverageArguments(settings)) } @Test - fun nullPathFallsBackToBareCoverage() { - assertEquals(listOf("--coverage"), createCoverageArguments(null)) + fun emptyCoverageOnlyOptionsAddNothing() { + val settings = TestoRunnerSettings(coverageOptions = " ", coverageLevel = "auto", coverageCobertura = false) + assertTrue(extraCoverageArguments(settings).isEmpty()) } @Test - fun emptyPathFallsBackToBareCoverage() { - assertEquals(listOf("--coverage"), createCoverageArguments("")) + fun autoWithXdebugAndCoberturaCollectsBranches() { + assertEquals("branch", resolveCoverageLevel(TestoRunnerSettings())) } @Test - fun pathWithSpacesIsKeptVerbatimInSingleArgument() { - val args = createCoverageArguments("/path with space/r.xml") - assertEquals(1, args.size) - assertEquals("--coverage-clover=/path with space/r.xml", args[0]) + fun autoWithoutCoberturaSendsNoLevelFlag() { + val settings = TestoRunnerSettings(coverageCobertura = false) + assertEquals(null, resolveCoverageLevel(settings)) + assertTrue(extraCoverageArguments(settings).none { it.startsWith("--coverage-level") }) + } + + @Test + fun autoWithPcovSendsNoLevelFlag() { + val settings = TestoRunnerSettings(coverageEngine = CoverageEngine.PCOV) + assertEquals(null, resolveCoverageLevel(settings)) + } + + @Test + fun chosenLevelWinsOverTheAutoBranchDefault() { + val settings = TestoRunnerSettings(coverageLevel = "line") + + assertEquals(listOf("--coverage-level=line", "--type=!bench"), extraCoverageArguments(settings)) } @Test diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageDedupeTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageDedupeTest.kt new file mode 100644 index 00000000..a6e461da --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageDedupeTest.kt @@ -0,0 +1,45 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys +import com.github.xepozz.testo.tests.console.TestoReportRef +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.file.Path + +/** Pure tests for the one-report-per-format pick the coverage auto-apply makes (flag-written beats configured). */ +class TestoCoverageDedupeTest { + + private fun ref(format: String, path: String) = TestoReportRef(format, path, null, null, null) + + @Test + fun flagReportBeatsConfiguredOneOfTheSameFormat() { + val configured = ref("clover", "/app/runtime/clover.xml") to Path.of("/app/runtime/clover.xml") + val flagged = ref("clover", "/ide/cov/r-clover.xml") to Path.of("/ide/cov/r-clover.xml") + val chosen = dedupeCoverageByFormat( + listOf(flagged, configured), // announce order must not matter + setOf(TestoCoverageKeys.normalize("/ide/cov/r-clover.xml")), + ) + assertEquals(listOf(flagged), chosen) + } + + @Test + fun withoutAFlagTheLastAnnouncedWins() { + val first = ref("cobertura", "/a.xml") to Path.of("/a.xml") + val second = ref("cobertura", "/b.xml") to Path.of("/b.xml") + assertEquals(listOf(second), dedupeCoverageByFormat(listOf(first, second), emptySet())) + } + + @Test + fun formatsAreIndependent() { + val clover = ref("clover", "/c.xml") to Path.of("/c.xml") + val xml = ref("coverage-xml", "/x") to Path.of("/x/index.xml") + val chosen = dedupeCoverageByFormat(listOf(clover, xml), emptySet()) + assertEquals(setOf(clover, xml), chosen.toSet()) + } + + @Test + fun nonCoverageFormatsAreDropped() { + val html = ref("html", "/report") to Path.of("/report/index.html") + assertEquals(emptyList(), dedupeCoverageByFormat(listOf(html), emptySet())) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt new file mode 100644 index 00000000..2b906738 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt @@ -0,0 +1,106 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.format.BranchCoverage +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.format.FileCoverage +import com.github.xepozz.testo.coverage.format.LineCoverage +import com.github.xepozz.testo.coverage.format.ParsedReport +import com.github.xepozz.testo.coverage.format.parseCoverageReport +import com.intellij.rt.coverage.data.ProjectData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.nio.file.Path + +/** + * Pure tests for [toProjectData]: the platform coverage model built from a parsed report. `com.intellij.rt.coverage.data` + * types need no IDE fixture, so line hits and the branch → jump/switch status mapping are asserted directly. + * `LineData.getStatus()`: 0 = uncovered, 1 = partial, 2 = full. + */ +class TestoCoverageProjectDataTest { + + private val interceptor = "D:/git/testo/testo/plugin/data/src/Internal/DataProviderInterceptor.php" + + @Test + fun buildsLineHitsFromClover() { + val data = parseCoverageReport(Path.of("src/test/testData/coverage/clover.xml"), CoverageFormat.CLOVER).toProjectData() + val cls = data.getClassData(interceptor) + assertEquals(1, cls.getLineData(40).hits) + assertEquals(0, cls.getLineData(70).hits) + assertEquals(0, cls.getLineData(70).status) // uncovered -> red + assertEquals(2, cls.getLineData(40).status) // covered, no branch -> full + } + + @Test + fun coberturaPartialBranchLineIsPartial() { + val data = parseCoverageReport(Path.of("src/test/testData/coverage/cobertura.xml"), CoverageFormat.COBERTURA).toProjectData() + val line = data.getClassData(interceptor).getLineData(68) // condition-coverage="75% (3/4)" + assertEquals(1, line.status) // partial -> yellow + assertEquals(4, line.branchData.totalBranches) + assertEquals(3, line.branchData.coveredBranches) + } + + @Test + fun branchMappingCoversJumpsAndSwitches() { + val report = ParsedReport( + CoverageFormat.COBERTURA, + listOf( + FileCoverage( + "/x.php", + listOf( + LineCoverage(10, hits = 1, branch = BranchCoverage(2, 2)), // full two-way + LineCoverage(11, hits = 1, branch = BranchCoverage(4, 4)), // full n-way + LineCoverage(12, hits = 1, branch = BranchCoverage(1, 2)), // partial two-way + LineCoverage(13, hits = 0, branch = BranchCoverage(0, 2)), // unexecuted line + ), + ), + ), + hasBranches = true, + perTest = null, + ) + val cls = report.toProjectData().getClassData("/x.php") + + assertEquals(2, cls.getLineData(10).status) + assertEquals(BranchPair(2, 2), cls.getLineData(10).branchData.let { BranchPair(it.totalBranches, it.coveredBranches) }) + assertEquals(2, cls.getLineData(11).status) + assertEquals(BranchPair(4, 4), cls.getLineData(11).branchData.let { BranchPair(it.totalBranches, it.coveredBranches) }) + assertEquals(1, cls.getLineData(12).status) + assertEquals(BranchPair(2, 1), cls.getLineData(12).branchData.let { BranchPair(it.totalBranches, it.coveredBranches) }) + assertEquals(0, cls.getLineData(13).status) // hits==0 wins over branch data -> uncovered + } + + /** + * Several checked reports become one bundle, and the platform merges their `ProjectData`s — line by line, into + * fresh `LineData`. Branch data only survives that because ours is written through `fillArrays`, which is what the + * merge reads. + */ + @Test + fun branchesSurviveTheMergeOfSeveralReports() { + val clover = parseCoverageReport(Path.of("src/test/testData/coverage/clover.xml"), CoverageFormat.CLOVER) + val cobertura = parseCoverageReport(Path.of("src/test/testData/coverage/cobertura.xml"), CoverageFormat.COBERTURA) + val merged = ProjectData() + merged.merge(clover.toProjectData()) + merged.merge(cobertura.toProjectData()) + + val line = merged.getClassData(interceptor).getLineData(68) + assertEquals(4, line.branchData.totalBranches) + assertEquals(3, line.branchData.coveredBranches) + } + + /** coverage-xml lists files it recorded no covered line for; a `ClassData` without lines makes the platform NPE. */ + @Test + fun fileWithoutExecutableLinesGetsNoClassData() { + val report = ParsedReport( + CoverageFormat.COVERAGE_XML, + listOf(FileCoverage("/empty.php", emptyList()), FileCoverage("/x.php", listOf(LineCoverage(3, hits = 1)))), + hasBranches = false, + perTest = null, + ) + val data = report.toProjectData() + + assertNull(data.getClassData("/empty.php")) + assertEquals(setOf("/x.php"), data.classes.keys) + } + + private data class BranchPair(val total: Int, val covered: Int) +} diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRendererTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRendererTest.kt new file mode 100644 index 00000000..a26ef86d --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRendererTest.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.testo.coverage.editor + +import com.github.xepozz.testo.coverage.format.BranchCoverage +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.format.FileCoverage +import com.github.xepozz.testo.coverage.format.LineCoverage +import com.github.xepozz.testo.coverage.format.ParsedReport +import com.github.xepozz.testo.coverage.toProjectData +import com.intellij.rt.coverage.data.LineData +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Pure tests for the gutter popup/tooltip headline, over [LineData] built the way the runner builds it. */ +class TestoCoverageGutterRendererTest { + + private fun lineData(vararg lines: LineCoverage): Map { + val report = ParsedReport(CoverageFormat.COBERTURA, listOf(FileCoverage("/x.php", lines.toList())), true, null) + val cls = report.toProjectData().getClassData("/x.php") + return lines.associate { it.line to cls.getLineData(it.line) } + } + + @Test + fun statusAlone() { + val lines = lineData(LineCoverage(1, hits = 1), LineCoverage(2, hits = 0)) + assertEquals("Line covered", coverageLineStatusText(lines.getValue(1))) + assertEquals("Line not covered", coverageLineStatusText(lines.getValue(2))) + } + + @Test + fun hitsAppendedWhenAboveOne() { + val lines = lineData(LineCoverage(1, hits = 5)) + assertEquals("Line covered, 5 hits", coverageLineStatusText(lines.getValue(1))) + } + + @Test + fun branchTallyAppended() { + val lines = lineData( + LineCoverage(1, hits = 1, branch = BranchCoverage(1, 2)), + LineCoverage(2, hits = 1, branch = BranchCoverage(2, 2)), + ) + assertEquals("Line partially covered, branches 1/2", coverageLineStatusText(lines.getValue(1))) + assertEquals("Line covered, branches 2/2", coverageLineStatusText(lines.getValue(2))) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt new file mode 100644 index 00000000..fc65800e --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt @@ -0,0 +1,160 @@ +package com.github.xepozz.testo.coverage.format + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Path + +/** + * Pure-logic tests for the three coverage parsers and format detection, run against the real trimmed Testo reports in + * `src/test/testData/coverage/` — no platform fixture. All three formats resolve the same source file to one path. + */ +class CoverageParserTest { + + private val dir = Path.of("src/test/testData/coverage") + + private val interceptor = "D:/git/testo/testo/plugin/data/src/Internal/DataProviderInterceptor.php" + private val multipleResult = "D:/git/testo/testo/plugin/data/src/MultipleResult.php" + private val dataCross = "D:/git/testo/testo/plugin/data/src/DataCross.php" + + private fun ParsedReport.file(path: String) = files.first { it.filePath == path } + private fun FileCoverage.hits(line: Int) = lines.first { it.line == line }.hits + private fun FileCoverage.branch(line: Int) = lines.first { it.line == line }.branch + + // ---- Clover --------------------------------------------------------------------------------------------------- + + @Test + fun cloverParsesLineHitsAndUncoveredLines() { + val report = parseCoverageReport(dir.resolve("clover.xml"), CoverageFormat.CLOVER) + + assertEquals(CoverageFormat.CLOVER, report.format) + assertFalse(report.hasBranches) + assertNull(report.perTest) + assertEquals(setOf(interceptor, multipleResult, dataCross), report.files.map { it.filePath }.toSet()) + + val f = report.file(interceptor) + assertEquals(1, f.hits(40)) + assertEquals(0, f.hits(70)) // count="0" -> uncovered + assertNull(f.branch(40)) // Clover carries no branch data + + assertEquals(listOf(1, 1), report.file(multipleResult).lines.map { it.hits }) + assertEquals(listOf(0, 0), report.file(dataCross).lines.map { it.hits }) + } + + // ---- Cobertura ------------------------------------------------------------------------------------------------ + + @Test + fun coberturaResolvesRelativePathsAgainstSource() { + val report = parseCoverageReport(dir.resolve("cobertura.xml"), CoverageFormat.COBERTURA) + assertEquals(setOf(interceptor, multipleResult, dataCross), report.files.map { it.filePath }.toSet()) + } + + @Test + fun coberturaParsesLineHitsAndBranchConditions() { + val report = parseCoverageReport(dir.resolve("cobertura.xml"), CoverageFormat.COBERTURA) + + assertEquals(CoverageFormat.COBERTURA, report.format) + assertTrue(report.hasBranches) + assertNull(report.perTest) + + val f = report.file(interceptor) + assertEquals(1, f.hits(40)) + assertEquals(0, f.hits(70)) + assertNull(f.branch(70)) // plain line, no branch attribute + assertEquals(BranchCoverage(3, 4), f.branch(68)) + assertEquals(BranchCoverage(1, 2), f.branch(69)) + assertEquals(BranchCoverage(2, 4), f.branch(81)) + assertEquals(BranchCoverage(0, 4), f.branch(282)) + + assertTrue(report.file(multipleResult).lines.all { it.branch == null }) + } + + @Test + fun coberturaBranchFlagTracksAnyBranchLine() { + val report = CoberturaCoverageParser.parse(dir.resolve("cobertura.xml")) + assertTrue(report.hasBranches) + } + + // ---- coverage-xml --------------------------------------------------------------------------------------------- + + @Test + fun coverageXmlBuildsPerTestIndexBothDirections() { + val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.COVERAGE_XML) + + assertEquals(CoverageFormat.COVERAGE_XML, report.format) + assertFalse(report.hasBranches) + assertNotNull(report.perTest) + val perTest = report.perTest!! + + val test = TestId("Tests\\Data\\Unit\\Internal\\DataProviderInterceptorTest", "collectsResultsFromAllProviders") + assertEquals(setOf(test), perTest.byLine[SourceLine(interceptor, 40)]) + assertEquals(setOf(test), perTest.byLine[SourceLine(multipleResult, 25)]) + + val covered = perTest.byTest.getValue(test) + assertTrue(SourceLine(interceptor, 40) in covered) + assertTrue(SourceLine(multipleResult, 25) in covered) + assertTrue(SourceLine(multipleResult, 30) in covered) + } + + @Test + fun coverageXmlEmitsOnlyExecutedLinesAndKeepsEmptyFiles() { + val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.COVERAGE_XML) + + assertEquals(setOf(interceptor, multipleResult, dataCross), report.files.map { it.filePath }.toSet()) + assertTrue(report.file(interceptor).lines.all { it.hits == 1 }) // overlay: executed lines only + assertTrue(report.file(dataCross).lines.isEmpty()) // empty + assertNull(report.perTest!!.byLine[SourceLine(interceptor, 70)]) // uncovered line absent from the overlay + } + + /** `` is the only place the format says how many executable lines a file has — see [LineTotals]. */ + @Test + fun coverageXmlReadsPerFileLineTotals() { + val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.COVERAGE_XML) + + assertEquals(LineTotals(124, 58), report.file(interceptor).totals) + assertEquals(LineTotals(2, 2), report.file(multipleResult).totals) + assertEquals(LineTotals(2, 0), report.file(dataCross).totals) // no covered line, still 2 executable ones + } + + @Test + fun cloverAndCoberturaCarryNoTotals() { + assertTrue(parseCoverageReport(dir.resolve("clover.xml")).files.all { it.totals == null }) + assertTrue(parseCoverageReport(dir.resolve("cobertura.xml")).files.all { it.totals == null }) + } + + @Test + fun coverageXmlAcceptsIndexFileDirectly() { + val viaDir = parseCoverageReport(dir.resolve("coverage-xml")) + val viaFile = parseCoverageReport(dir.resolve("coverage-xml/index.xml")) + assertEquals(viaDir.files.map { it.filePath }.toSet(), viaFile.files.map { it.filePath }.toSet()) + } + + // ---- Detection ------------------------------------------------------------------------------------------------ + + @Test + fun detectsEachFormat() { + assertEquals(CoverageFormat.CLOVER, detectCoverageFormat(dir.resolve("clover.xml"))) + assertEquals(CoverageFormat.COBERTURA, detectCoverageFormat(dir.resolve("cobertura.xml"))) + assertEquals(CoverageFormat.COVERAGE_XML, detectCoverageFormat(dir.resolve("coverage-xml"))) + assertEquals(CoverageFormat.COVERAGE_XML, detectCoverageFormat(dir.resolve("coverage-xml/index.xml"))) + } + + @Test + fun parseWithoutFormatSniffs() { + assertEquals(CoverageFormat.CLOVER, parseCoverageReport(dir.resolve("clover.xml")).format) + assertEquals(CoverageFormat.COBERTURA, parseCoverageReport(dir.resolve("cobertura.xml")).format) + } + + // ---- TestId --------------------------------------------------------------------------------------------------- + + @Test + fun testIdSplitsOnLastDoubleColon() { + assertEquals(TestId("Tests\\Foo\\BarTest", "doesThing"), TestId.parse("Tests\\Foo\\BarTest::doesThing")) + assertNull(TestId.parse("noSeparator")) + assertNull(TestId.parse("::orphan")) + assertNull(TestId.parse("Class::")) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt new file mode 100644 index 00000000..e42d8483 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt @@ -0,0 +1,92 @@ +package com.github.xepozz.testo.coverage.perTest + +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.coverage.format.parseCoverageReport +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Path + +/** + * Pure tests for the per-test read model and the identity mapper's selector — the parts that need no PSI. Built from + * the real coverage-xml fixture; `resolve`/`toLocationHint` (which go through PhpIndex) are covered separately. + */ +class TestoPerTestCoverageTest { + + private val interceptor = "D:/git/testo/testo/plugin/data/src/Internal/DataProviderInterceptor.php" + private val multipleResult = "D:/git/testo/testo/plugin/data/src/MultipleResult.php" + private val test = TestId("Tests\\Data\\Unit\\Internal\\DataProviderInterceptorTest", "collectsResultsFromAllProviders") + + private fun data() = TestoCoverageByTestData.of( + parseCoverageReport(Path.of("src/test/testData/coverage/coverage-xml"), CoverageFormat.COVERAGE_XML).perTest, + ) + + @Test + fun testsCoveringLineAndRange() { + val data = data() + assertEquals(setOf(test), data.testsCoveringLine(interceptor, 40)) + assertEquals(emptySet(), data.testsCoveringLine(interceptor, 70)) // uncovered -> not in overlay + assertTrue(test in data.testsCoveringRange(interceptor, 30..60)) + assertTrue(test in data.testsCoveringRange(multipleResult, 25..30)) + assertEquals(emptySet(), data.testsCoveringRange(interceptor, 300..320)) + } + + @Test + fun linesOfTestAndAllTests() { + val data = data() + assertEquals(setOf(test), data.allTests()) + val lines = data.linesOfTest(test) + assertTrue(SourceRef(TestoCoverageKeys.normalize(interceptor), 40) in lines) + assertTrue(SourceRef(TestoCoverageKeys.normalize(multipleResult), 25) in lines) + } + + @Test + fun lookupIgnoresPathSpellingAndSlashes() { + val data = data() + // A backslash spelling normalizes to the same key. + assertEquals(setOf(test), data.testsCoveringLine(interceptor.replace('/', '\\'), 40)) + } + + @Test + fun emptyDataForNullOverlay() { + assertEquals(emptySet(), TestoCoverageByTestData.of(null).allTests()) + } + + @Test + fun testsByFileGroupsDistinctTestsUnderNormalizedKeys() { + val byFile = data().testsByFile() + assertEquals(setOf(test), byFile[TestoCoverageKeys.normalize(interceptor)]) + assertEquals(setOf(test), byFile[TestoCoverageKeys.normalize(multipleResult)]) + assertTrue(TestoCoverageByTestData.of(null).testsByFile().isEmpty()) + } + + @Test + fun testsUnderTakesAFileAloneAndADirectoryWhole() { + val data = data() + assertEquals(setOf(test), data.testsUnder(interceptor, isDirectory = false)) + // A directory is the union over everything beneath it, however deep — and its own key is not a file key. + assertEquals(setOf(test), data.testsUnder("D:/git/testo/testo/plugin/data", isDirectory = true)) + assertEquals(emptySet(), data.testsUnder("D:/git/testo/testo/plugin/data", isDirectory = false)) + assertEquals(emptySet(), data.testsUnder("D:/git/testo/testo/plugin/other", isDirectory = true)) + // A prefix that is not a path segment must not match: ".../data" may not swallow ".../database". + assertEquals(emptySet(), data.testsUnder("D:/git/testo/testo/plugin/dat", isDirectory = true)) + } + + @Test + fun filterSelectorHasLeadingBackslashOnce() { + val mapper = TestoTestIdentityMapper.getInstance() + assertEquals( + "\\Tests\\Data\\Unit\\Internal\\DataProviderInterceptorTest::collectsResultsFromAllProviders", + mapper.toFilterSelector(test), + ) + assertEquals("\\Already\\Prefixed::m", mapper.toFilterSelector(TestId("\\Already\\Prefixed", "m"))) + } + + @Test + fun keysNormalizeSlashesAndAreIdempotent() { + val once = TestoCoverageKeys.normalize("D:\\git\\Testo\\Foo.php") + assertEquals(once, TestoCoverageKeys.normalize(once)) + assertTrue('\\' !in once) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunArchiveTest.kt b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunArchiveTest.kt new file mode 100644 index 00000000..617d4cdb --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunArchiveTest.kt @@ -0,0 +1,94 @@ +package com.github.xepozz.testo.runs + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.io.path.readText + +/** Pure tests for exporting a run directory as one file and reading it back — no IDE, just the archive layout. */ +class TestoRunArchiveTest { + + @get:Rule + val temp = TemporaryFolder() + + @Test + fun aRunSurvivesTheRoundTripWithItsReports() { + val run = temp.newFolder("run").toPath() + Files.writeString(run.resolve(TestoRunRecording.MANIFEST_FILE), """{"v":3,"configurationName":"All tests"}""") + Files.writeString(run.resolve(TestoRunRecording.OUTPUT_FILE), "{\"s\":1,\"t\":\"##teamcity[x]\\n\"}\n") + Files.createDirectories(run.resolve("reports/coverage-xml")) + Files.writeString(run.resolve("reports/cobertura.xml"), "") + Files.writeString(run.resolve("reports/coverage-xml/index.xml"), "") + + val zip = temp.newFolder("out").toPath().resolve("export.zip") + zipRunDirectory(run, zip) + val restored = temp.newFolder("restored").toPath() + unzipRunDirectory(zip, restored) + + assertEquals( + """{"v":3,"configurationName":"All tests"}""", + restored.resolve(TestoRunRecording.MANIFEST_FILE).readText(), + ) + assertEquals("", restored.resolve("reports/cobertura.xml").readText()) + assertEquals("", restored.resolve("reports/coverage-xml/index.xml").readText()) + } + + @Test + fun entriesPointingOutsideTheTargetAreSkipped() { + val zip = temp.newFolder("evil").toPath().resolve("evil.zip") + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { out -> + out.putNextEntry(ZipEntry("../escaped.txt")) + out.write("nope".toByteArray(StandardCharsets.UTF_8)) + out.closeEntry() + out.putNextEntry(ZipEntry(TestoRunRecording.MANIFEST_FILE)) + out.write("{}".toByteArray(StandardCharsets.UTF_8)) + out.closeEntry() + } + Files.createDirectories(zip.parent) + Files.write(zip, bytes.toByteArray()) + + val target = temp.newFolder("target").toPath().resolve("run") + unzipRunDirectory(zip, target) + + assertTrue(Files.exists(target.resolve(TestoRunRecording.MANIFEST_FILE))) + assertFalse(Files.exists(target.parent.resolve("escaped.txt"))) + } + + @Test + fun anIndexEntryMeansTheWholeDirectoryIsTheReport() { + // Both of Testo's report layouts announce their entry file; `index.` is what tells a directory report apart. + assertTrue(isDirectoryReport(Path.of("/app/var/report/index.html"))) + assertTrue(isDirectoryReport(Path.of("/app/var/coverage-xml/index.xml"))) + assertFalse(isDirectoryReport(Path.of("/app/var/report.html"))) + assertFalse(isDirectoryReport(Path.of("/app/var/clover.xml"))) + } + + @Test + fun aCapturedReportIsNamedAfterItsFormat() { + assertEquals("html", capturedReportName("html", Path.of("/app/var/report/index.html"))) + assertEquals("html.html", capturedReportName("html", Path.of("/app/var/report.html"))) + assertEquals("cobertura.xml", capturedReportName("cobertura", Path.of("/app/var/cobertura.xml"))) + assertEquals("junit", capturedReportName("junit", Path.of("/app/var/junit"))) + } + + @Test + fun theExportedNameIsReadableAndFileSystemSafe() { + val manifest = TestoRunManifest(configurationName = "All tests: unit / db", startedAt = 1700000000000) + assertEquals("All-tests-unit-db-1700000000000", exportFileName(manifest)) + } + + @Test + fun anUnnamedRunStillGetsAName() { + assertEquals("testo-run-42", exportFileName(TestoRunManifest(startedAt = 42))) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryPresentationTest.kt b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryPresentationTest.kt new file mode 100644 index 00000000..b550321e --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryPresentationTest.kt @@ -0,0 +1,43 @@ +package com.github.xepozz.testo.runs + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Pure tests for how an archived run is presented in the history chooser: its kind (icon) and its result line. */ +class TestoRunHistoryPresentationTest { + + private fun manifest(vararg statuses: Pair) = + TestoRunManifest(statuses = statuses.toMap()) + + @Test + fun executorIdDecidesTheRunKind() { + assertEquals(TestoRunKind.COVERAGE, runKindOf("Coverage")) + assertEquals(TestoRunKind.DEBUG, runKindOf("Debug")) + assertEquals(TestoRunKind.RUN, runKindOf("Run")) + } + + @Test + fun anArchiveWithoutAnExecutorReadsAsAPlainRun() { + assertEquals(TestoRunKind.RUN, runKindOf("")) + assertEquals(TestoRunKind.RUN, runKindOf(null)) + } + + @Test + fun failuresAreCountedAcrossEveryProblemStatus() { + // error and aborted are failures too; risky, flaky and skipped are not. + val summary = runResultSummary( + manifest("passed" to 100, "failed" to 40, "error" to 1, "aborted" to 1, "risky" to 2, "skipped" to 1) + ) + assertEquals("145 total, 42 failed", summary) + } + + @Test + fun aCleanRunSaysSo() { + assertEquals("12 total, all passed", runResultSummary(manifest("passed" to 10, "skipped" to 2))) + } + + @Test + fun aRunThatReportedNoTestsHasNoTally() { + assertEquals("no tests", runResultSummary(manifest())) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt new file mode 100644 index 00000000..7a8daa3b --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt @@ -0,0 +1,130 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.tests.console.TestoRunTimings +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.charset.StandardCharsets +import java.nio.file.Files + +/** + * Pure tests for the run-archive pieces that need no IDE: the JSONL output framing survives a round trip (including + * teamcity messages, newlines and quotes), and the manifest serde tolerates junk. The store itself is a project + * service; its read half is exercised here through the same Gson framing the recording writes. + */ +class TestoRunStoreTest { + + @get:Rule + val temp = TemporaryFolder() + + private val gson = Gson() + + @Test + fun outputFramingRoundTripsChunksInOrder() { + val dir = temp.newFolder("run").toPath() + val recording = TestoRunRecording(dir, "cfg", "Run", 123L) + val chunks = listOf( + TestoRunRecording.STDOUT to "##teamcity[testStarted name='a' nodeId='1' parentNodeId='0']\n", + TestoRunRecording.STDERR to "PHP Warning: 'quoted' and\nmultiline\n", + TestoRunRecording.SYSTEM to "process exited", + TestoRunRecording.STDOUT to "plain tail with unicode — ✓\n", + ) + chunks.forEach { (s, t) -> recording.appendChunk(s, t) } + recording.closeOutput() + + val replayed = ArrayList>() + Files.newBufferedReader(dir.resolve(TestoRunRecording.OUTPUT_FILE), StandardCharsets.UTF_8).useLines { lines -> + for (line in lines) { + val chunk = gson.fromJson(line, TestoRunRecording.Chunk::class.java) + replayed += chunk.s to chunk.t + } + } + assertEquals(chunks, replayed) + } + + @Test + fun appendAfterCloseIsIgnored() { + val dir = temp.newFolder("closed").toPath() + val recording = TestoRunRecording(dir, "cfg", "Run", 1L) + recording.appendChunk(TestoRunRecording.STDOUT, "kept") + recording.closeOutput() + recording.appendChunk(TestoRunRecording.STDOUT, "dropped") + + val lines = Files.readAllLines(dir.resolve(TestoRunRecording.OUTPUT_FILE)) + assertEquals(1, lines.size) + } + + @Test + fun locationsAreNormalizedDedupedAndWrittenOnePerLine() { + val dir = temp.newFolder("locations").toPath() + val recording = TestoRunRecording(dir, "cfg", "Run", 1L) + listOf( + "php_qn://D:/app/OrderTest.php::\\App\\OrderTest::testPay", + "php_qn://D:/app/OrderTest.php::\\App\\OrderTest::testPay#2", + "php_qn://D:/app/OrderTest.php::\\App\\OrderTest::testPay with data set #3", + "php_qn://D:/app/OrderTest.php::\\App\\OrderTest", + ).forEach { recording.noteLocation(it) } + recording.writeLocations() + + assertEquals( + listOf( + "php_qn://D:/app/OrderTest.php::\\App\\OrderTest::testPay", + "php_qn://D:/app/OrderTest.php::\\App\\OrderTest", + ), + Files.readAllLines(dir.resolve(TestoRunRecording.TESTS_FILE)), + ) + } + + @Test + fun runLocationKeyUnifiesSeparatorsSoPsiAndTestoHintsMatch() { + // The lens builds its hint from PSI through the local path processor (OS-native `\` path on Windows); Testo + // emits the same location with `/`. Both must reduce to one key, or the history lens never matches on Windows. + val fromPsi = "php_qn://D:\\git\\app\\OrderTest.php::\\App\\OrderTest::testPay" + val fromTesto = "php_qn://D:/git/app/OrderTest.php::\\App\\OrderTest::testPay" + assertEquals(runLocationKey(fromTesto), runLocationKey(fromPsi)) + // Dataset coordinates are still dropped, so a method key answers for its data sets. + assertEquals(runLocationKey(fromPsi), runLocationKey("$fromTesto with data set #3")) + } + + @Test + fun manifestRoundTripsThroughJson() { + val manifest = TestoRunManifest( + configurationName = "All tests", + executorId = "Coverage", + commandLine = "php bin/testo run -q -n --teamcity", + configuration = "", + startedAt = 1000, + finishedAt = 2000, + timings = TestoRunTimings.Marks(1010, 1100, 1900, 1990), + statuses = mapOf("passed" to 103, "failed" to 42), + reports = listOf( + StoredReport("cobertura", "Cobertura coverage", "/app/r.xml", "r.xml", "reports/cobertura.xml"), + StoredReport("html", "HTML report", "/app/report", null, null), + ), + ) + val parsed = gson.fromJson(gson.toJson(manifest), TestoRunManifest::class.java) + assertEquals(manifest, parsed) + } + + @Test + fun theOlderSpellingOfALockedRunStillReads() { + // The choice was called "pinned" before it was called "locked"; archives written then must not lose it. + val parsed = gson.fromJson("""{"v":3,"retention":"PINNED"}""", TestoRunManifest::class.java) + assertEquals(RunRetention.LOCKED, parsed.retention) + } + + @Test + fun malformedManifestParsesToNullNotThrow() { + assertNull(runCatching { gson.fromJson("{not json", TestoRunManifest::class.java) }.getOrNull()) + } + + @Test + fun finishGuardAdmitsExactlyOneFinalizer() { + val recording = TestoRunRecording(temp.newFolder("g").toPath(), "cfg", "Run", 1L) + assertEquals(true, recording.tryBeginFinish()) + assertEquals(false, recording.tryBeginFinish()) + } +} diff --git a/src/test/testData/coverage/clover.xml b/src/test/testData/coverage/clover.xml new file mode 100644 index 00000000..f279d202 --- /dev/null +++ b/src/test/testData/coverage/clover.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/testData/coverage/cobertura.xml b/src/test/testData/coverage/cobertura.xml new file mode 100644 index 00000000..50d1a8ce --- /dev/null +++ b/src/test/testData/coverage/cobertura.xml @@ -0,0 +1,158 @@ + + + + + D:/git/testo/testo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/testData/coverage/coverage-xml/index.xml b/src/test/testData/coverage/coverage-xml/index.xml new file mode 100644 index 00000000..c6be4f7e --- /dev/null +++ b/src/test/testData/coverage/coverage-xml/index.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/testData/coverage/coverage-xml/plugin/data/src/DataCross.php.xml b/src/test/testData/coverage/coverage-xml/plugin/data/src/DataCross.php.xml new file mode 100644 index 00000000..8fccc463 --- /dev/null +++ b/src/test/testData/coverage/coverage-xml/plugin/data/src/DataCross.php.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test/testData/coverage/coverage-xml/plugin/data/src/Internal/DataProviderInterceptor.php.xml b/src/test/testData/coverage/coverage-xml/plugin/data/src/Internal/DataProviderInterceptor.php.xml new file mode 100644 index 00000000..e2736c3b --- /dev/null +++ b/src/test/testData/coverage/coverage-xml/plugin/data/src/Internal/DataProviderInterceptor.php.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/testData/coverage/coverage-xml/plugin/data/src/MultipleResult.php.xml b/src/test/testData/coverage/coverage-xml/plugin/data/src/MultipleResult.php.xml new file mode 100644 index 00000000..b9b819f0 --- /dev/null +++ b/src/test/testData/coverage/coverage-xml/plugin/data/src/MultipleResult.php.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + +