From 7583249ff88b8d8ed0625bfcb90ac8635da29625 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 14:28:18 +0400 Subject: [PATCH 01/41] fix(console): hop the channel icon inlay to the EDT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(report): resolve report paths off the EDT Both console additions ran platform calls on the wrong thread. The channel aggregate tab appends live output from the process-reader thread, where performWhenNoDeferredOutput asserts EDT. The report toolbar polls from a Swing timer on the EDT, where getLocalPath hits the file index — a slow operation the platform forbids there. The report resolve now runs on a pool thread and drops its result if a rerun started meanwhile, so a stale apply cannot auto-open the previous run's report. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++++ .../testo/tests/console/TestoChannelsUi.kt | 13 +++++--- .../testo/tests/console/TestoReportAction.kt | 30 ++++++++++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbd010..a738dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +### Fixed + +- 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: report paths now resolve off the UI thread. + ## [2026.5.262] - 2026-08-12 ### Added 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 124f5a0..465fa4c 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 @@ -1073,13 +1073,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? { 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 a41f4a2..07f5774 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 @@ -15,6 +15,8 @@ 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.project.Project @@ -150,6 +152,8 @@ 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 + // A background resolve is in flight; ticks skip while it is, so a slow resolve doesn't stack up tasks. + private var resolving = false // 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() @@ -186,7 +190,31 @@ class TestoReportsAction( // 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 (!finished) { + applyResolved(null, false) + return + } + // resolveReport goes through the PHP path mapper, whose getLocalPath hits the file index — a slow operation + // forbidden on the EDT, and this runs off a Swing timer on the EDT. Resolve on a pool thread, apply on the EDT. + if (resolving) return + resolving = true + val startedAt = reports.runStartedAt + val cellRef = ref + ApplicationManager.getApplication().executeOnPooledThread { + val found = resolveReport(cellRef, project, mapToLocal, startedAt) + ApplicationManager.getApplication().invokeLater( + { + resolving = false + // A rerun may have started while this resolved; applying then would auto-open the previous + // run's report and mark the new run as already opened. Drop it — the next tick sees the run. + if (reports.runStartedAt == startedAt && reports.runFinished) applyResolved(found, true) + }, + ModalityState.any(), + ) { project.isDisposed } + } + } + + 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. From 7ecd8f85aa26cb5e87fa5303e2d9d3de125b603b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 14:51:11 +0400 Subject: [PATCH 02/41] chore(report): drop redundant comments from the EDT resolve fix Assisted-By: Claude Opus 4.8 (1M context) --- .../com/github/xepozz/testo/tests/console/TestoReportAction.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 07f5774..578bbf4 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 @@ -152,7 +152,6 @@ 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 - // A background resolve is in flight; ticks skip while it is, so a slow resolve doesn't stack up tasks. private var resolving = false // Asked for per paint: a font set once on a raw JComponent outlives a zoom (no UI delegate reinstalls it). @@ -195,7 +194,7 @@ class TestoReportsAction( return } // resolveReport goes through the PHP path mapper, whose getLocalPath hits the file index — a slow operation - // forbidden on the EDT, and this runs off a Swing timer on the EDT. Resolve on a pool thread, apply on the EDT. + // forbidden on the EDT, and this runs off a Swing timer on the EDT. if (resolving) return resolving = true val startedAt = reports.runStartedAt From 19445c845911dbedc68a6d5ac29953a1cdf8d30d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 17:40:51 +0400 Subject: [PATCH 03/41] test(coverage): add real Testo coverage report fixtures Captured from a Testo v0.10.40 run over plugin/data at Path level (a fiber-free scope run with --group=!async, so XDebug branch collection does not segfault on fibers). One run yields all three formats over the same code, so line/branch/per-test data stay consistent across them: clover (line coverage, incl. uncovered count="0"), cobertura (branch condition-coverage with both m==2 and m==4), and phpunit-xml coverage-xml (per-test ). These back the upcoming coverage parser unit tests. Assisted-By: Claude Opus 4.8 (1M context) --- src/test/testData/coverage/clover.xml | 143 ++++++++++++++ src/test/testData/coverage/cobertura.xml | 158 +++++++++++++++ .../testData/coverage/coverage-xml/index.xml | 25 +++ .../plugin/data/src/DataCross.php.xml | 9 + .../Internal/DataProviderInterceptor.php.xml | 184 ++++++++++++++++++ .../plugin/data/src/MultipleResult.php.xml | 16 ++ 6 files changed, 535 insertions(+) create mode 100644 src/test/testData/coverage/clover.xml create mode 100644 src/test/testData/coverage/cobertura.xml create mode 100644 src/test/testData/coverage/coverage-xml/index.xml create mode 100644 src/test/testData/coverage/coverage-xml/plugin/data/src/DataCross.php.xml create mode 100644 src/test/testData/coverage/coverage-xml/plugin/data/src/Internal/DataProviderInterceptor.php.xml create mode 100644 src/test/testData/coverage/coverage-xml/plugin/data/src/MultipleResult.php.xml diff --git a/src/test/testData/coverage/clover.xml b/src/test/testData/coverage/clover.xml new file mode 100644 index 0000000..f279d20 --- /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 0000000..50d1a8c --- /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 0000000..c6be4f7 --- /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 0000000..8fccc46 --- /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 0000000..e2736c3 --- /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 0000000..b9b819f --- /dev/null +++ b/src/test/testData/coverage/coverage-xml/plugin/data/src/MultipleResult.php.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + From 86e4ebf4549c56315740ddaa50bf24f9c65cc134 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 18:06:17 +0400 Subject: [PATCH 04/41] feat(coverage): add format-neutral coverage report parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test(coverage): unit-test the parsers against real Testo fixtures Foundation for Testo's own coverage engine (replacing the internal com.intellij.php.coverage inheritance that fails plugin verification on 262). The parsers turn the three writer formats — clover, cobertura, phpunit-xml — into one format-neutral ParsedReport, deferring the platform ProjectData/JumpData/SwitchData construction and path normalization to the runner, so the parsing logic tests without an IDE fixture. DTD/entity loading is disabled since Cobertura's DOCTYPE points at a remote coverage-04.dtd. Assisted-By: Claude Opus 4.8 (1M context) --- .../coverage/format/CloverCoverageParser.kt | 26 ++++ .../format/CoberturaCoverageParser.kt | 40 +++++ .../testo/coverage/format/CoverageModel.kt | 64 ++++++++ .../testo/coverage/format/CoverageXml.kt | 30 ++++ .../format/PhpUnitXmlCoverageParser.kt | 50 ++++++ .../coverage/format/TestoCoverageParser.kt | 43 ++++++ .../coverage/format/CoverageParserTest.kt | 145 ++++++++++++++++++ 7 files changed, 398 insertions(+) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/format/CloverCoverageParser.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXml.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/format/PhpUnitXmlCoverageParser.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/format/TestoCoverageParser.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt 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 0000000..528f4a0 --- /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. See report-formats §1. + */ +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 0000000..7a38b60 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt @@ -0,0 +1,40 @@ +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)"`. + * See report-formats §2. + */ +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() } + for (lineEl in classEl.descendants("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 0000000..eed13a5 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt @@ -0,0 +1,64 @@ +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). See `docs/coverage/report-formats.md`. + */ +enum class CoverageFormat(val id: String) { + CLOVER("clover"), + COBERTURA("cobertura"), + PHPUNIT_XML("phpunit-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 (see arch §14.2). */ +data class BranchCoverage(val covered: Int, val total: Int) + +data class LineCoverage(val line: Int, val hits: Int, val branch: BranchCoverage? = null) + +/** One source file's coverage. [filePath] is the resolved absolute path, forward-slashed, ready to normalize (arch §6). */ +data class FileCoverage(val filePath: String, val lines: List) + +/** + * 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 (arch §4.1, Phase 2). */ +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 0000000..c5bc245 --- /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/PhpUnitXmlCoverageParser.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/PhpUnitXmlCoverageParser.kt new file mode 100644 index 0000000..654ce0b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/PhpUnitXmlCoverageParser.kt @@ -0,0 +1,50 @@ +package com.github.xepozz.testo.coverage.format + +import java.nio.file.Files +import java.nio.file.Path + +/** + * coverage-xml (PHPUnit-style): a directory — an `index.xml` overview plus one XML per source file. Only executed + * lines that have covering tests are emitted (a per-test overlay, no uncovered lines). Source file for a `` + * entry is `/`; the per-file XML sits at `/`. See report-formats §3. + */ +object PhpUnitXmlCoverageParser : TestoCoverageParser { + override val format = CoverageFormat.PHPUNIT_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 path = "$source/${href.removeSuffix(".xml")}" + 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) + } + + val perTest = PerTestCoverage(byTest.mapValues { it.value.toSet() }, byLine.mapValues { it.value.toSet() }) + return ParsedReport(format, files, hasBranches = false, perTest = perTest) + } +} 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 0000000..10f774d --- /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.PHPUNIT_XML + if (reportPath.fileName?.toString().equals("index.xml", ignoreCase = true)) return CoverageFormat.PHPUNIT_XML + val root = try { + readXmlRoot(reportPath) + } catch (_: Exception) { + return null + } + return when { + root.tagName == "phpunit" -> CoverageFormat.PHPUNIT_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.PHPUNIT_XML -> PhpUnitXmlCoverageParser + } + return parser.parse(reportPath) +} 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 0000000..22971ca --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt @@ -0,0 +1,145 @@ +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 coberturaWithoutBranchLinesReportsNoBranches() { + // MultipleResult + DataCross classes alone carry no branch="true" line. + val report = CoberturaCoverageParser.parse(dir.resolve("cobertura.xml")) + assertTrue(report.hasBranches) // the interceptor class does; sanity that the flag tracks any branch line + } + + // ---- coverage-xml (phpunit-xml) ------------------------------------------------------------------------------- + + @Test + fun phpUnitXmlBuildsPerTestIndexBothDirections() { + val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.PHPUNIT_XML) + + assertEquals(CoverageFormat.PHPUNIT_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 phpUnitXmlEmitsOnlyExecutedLinesAndKeepsEmptyFiles() { + val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.PHPUNIT_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 + } + + @Test + fun phpUnitXmlAcceptsIndexFileDirectly() { + 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.PHPUNIT_XML, detectCoverageFormat(dir.resolve("coverage-xml"))) + assertEquals(CoverageFormat.PHPUNIT_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::")) + } +} From 814a0286b0290f25673c2cb2ef04ee208bac79c7 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 19:32:24 +0400 Subject: [PATCH 05/41] feat(coverage): reimplement coverage on public platform API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(coverage): resolve 262 binary incompatibility from internal php.coverage Replaces the coverage engine's inheritance from com.intellij.php.coverage — a content module declared visibility="internal" and closed to third-party plugins — with Testo's own five classes on 100% public API: TestoCoverageRunner (CoverageRunner), TestoCoverageEngine (CoverageEngine), TestoCoverageSuite (BaseCoverageSuite), TestoCoverageAnnotator (RemappingCoverageAnnotator) and TestoCoverageProgramRunner (GenericProgramRunner), plus a ParsedReport -> ProjectData builder. This clears the six verifier problems (unresolved PhpCoverageRunner/PhpCoverageSuite/PhpUnitCoverageRunner and PhpUnitCoverageEngine.createCoverageSuite) that made the plugin binary-incompatible with 2026.2. The program runner reproduces the internal PhpCoverageRunner Xdebug flow (resolve the IDE-managed report path, build the command, attach the platform so it loads coverage on process termination) on public PHP execution API alone. loadCoverageData overrides the File overload — the common denominator across 252 and 262, since 262's Path overload only delegates to it. Branch data maps condition-coverage to JumpData/SwitchData and calls fillArrays, since getStatus/getBranchData read the array fields the builder lists must be swapped into. The internal com.intellij.rt.coverage.data.* comes from the intellij.platform.coverage.agent bundled module, now requested for both variants. verifyPlugin passes clean on 252 and 262. Assisted-By: Claude Opus 4.8 (1M context) --- gradle.properties | 4 +- .../testo/coverage/TestoCoverageAnnotator.kt | 19 +++ .../testo/coverage/TestoCoverageEngine.kt | 108 +++++++++++++----- .../coverage/TestoCoverageProgramRunner.kt | 106 ++++++++++++----- .../coverage/TestoCoverageProjectData.kt | 52 +++++++++ .../testo/coverage/TestoCoverageRunner.kt | 55 +++++++++ src/main/resources/META-INF/coverage.xml | 2 + .../coverage/TestoCoverageProjectDataTest.kt | 71 ++++++++++++ 8 files changed, 359 insertions(+), 58 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt diff --git a/gradle.properties b/gradle.properties index 65b3393..a5d9216 100644 --- a/gradle.properties +++ b/gradle.properties @@ -44,8 +44,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/TestoCoverageAnnotator.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt new file mode 100644 index 0000000..aefb238 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt @@ -0,0 +1,19 @@ +package com.github.xepozz.testo.coverage + +import com.intellij.coverage.BaseCoverageAnnotator +import com.intellij.coverage.RemappingCoverageAnnotator +import com.intellij.openapi.project.Project + +/** + * File-path-keyed coverage annotation with remote-interpreter path remapping, mirroring PHP's `PhpCoverageAnnotator`. + * [RemappingCoverageAnnotator] does the whole job; we only suppress the "0%" line string so a file with no covered + * lines shows nothing instead of a misleading zero. + */ +class TestoCoverageAnnotator(project: Project) : RemappingCoverageAnnotator(project) { + override fun getLinesCoverageInformationString(info: BaseCoverageAnnotator.FileCoverageInfo): String? = + if (info.totalLineCount != 0 && info.coveredLineCount != 0) super.getLinesCoverageInformationString(info) else null + + companion object { + fun getInstance(project: Project): TestoCoverageAnnotator = project.getService(TestoCoverageAnnotator::class.java) + } +} 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 d20c752..aec4d27 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,108 @@ package com.github.xepozz.testo.coverage +import com.github.xepozz.testo.coverage.format.CoverageFormat +import com.github.xepozz.testo.coverage.format.PerTestCoverage 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.coverage.view.DirectoryCoverageViewExtension 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), whether it holds branch data, and the coverage-xml per-test overlay for later features (arch §7). + * 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 + var perTest: PerTestCoverage? = null + 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, perTest: PerTestCoverage?) { + this.branchCoverage = hasBranches + this.perTest = perTest + } + + 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 isApplicableTo(conf: RunConfigurationBase<*>): Boolean = conf is TestoRunConfiguration - override fun createCoverageEnabledConfiguration(conf: RunConfigurationBase<*>) = + 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 + + override fun getQualifiedNames(sourceFile: PsiFile): Set = + sourceFile.virtualFile?.canonicalPath?.let { setOf(it) } ?: emptySet() + + override fun createCoverageViewExtension(project: Project, suiteBundle: CoverageSuitesBundle): CoverageViewExtension = + DirectoryCoverageViewExtension(project, getCoverageAnnotator(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 679b9b0..fbf0ce2 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,101 @@ package com.github.xepozz.testo.coverage +import com.github.xepozz.testo.coverage.format.CoverageFormat import com.github.xepozz.testo.tests.run.TestoRunConfiguration +import com.intellij.coverage.CoverageHelper +import com.intellij.coverage.CoverageRunnerData +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.ConfigurationInfoProvider 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("Coverage is not supported for the selected run 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 + val coverageConfiguration = CoverageEnabledConfiguration.getOrCreate(runConfiguration) + val localCoverage = coverageConfiguration.coverageFilePath + val targetCoverage = localCoverage?.takeIf { it.isNotEmpty() }?.let { toTargetPath(runConfiguration, interpreter, it) } val command = createTestoCoverageCommand( runConfiguration, interpreter, - coverageArguments, + createCoverageArguments(targetCoverage), localCoverage, targetCoverage, ) - 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 + CoverageHelper.attachToProcess(runConfiguration, executionResult.processHandler, env.runnerSettings) + return RunContentBuilder(executionResult, env).showRunContent(env.contentToReuse) + } + + // Kept as clover by default; the format→flag map covers the other writers for the "Show coverage" path (arch §5). + fun createCoverageArguments(targetCoverage: String?): List = + coverageArgumentsFor(CoverageFormat.CLOVER, targetCoverage) + + fun coverageArgumentsFor(format: CoverageFormat, targetCoverage: String?): List { + if (targetCoverage.isNullOrEmpty()) return listOf("--coverage") + return when (format) { + CoverageFormat.CLOVER -> listOf("--coverage-clover=$targetCoverage") + CoverageFormat.COBERTURA -> listOf("--coverage-cobertura=$targetCoverage") + CoverageFormat.PHPUNIT_XML -> listOf("--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 +115,23 @@ 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)), + ) + } + } + + // Local interpreter: the report path is the same on both sides. Remote: map it into the execution environment. + 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 0000000..357693b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt @@ -0,0 +1,52 @@ +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, mirroring how PHP's own `PhpCloverXMLOutputParser` populates + * a [ProjectData]: one `ClassData` per file keyed by the forward-slashed source path (the annotator normalizes and, on + * Windows, lower-cases both sides itself, so no further normalization here), lines laid out in a number-indexed array. + * + * 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. See `docs/coverage/report-formats.md` §2 and architecture §14.2. + */ +fun ParsedReport.toProjectData(): ProjectData { + val projectData = ProjectData() + for (file in files) { + val classData = projectData.getOrCreateClassData(file.filePath) + val executable = file.lines.filter { it.line >= 0 } + if (executable.isEmpty()) continue + 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 0000000..b96391f --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt @@ -0,0 +1,55 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.coverage.format.CoverageParseException +import com.github.xepozz.testo.coverage.format.parseCoverageReport +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 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?.applyParsed(report.hasBranches, report.perTest) + SuccessCoverageLoadingResult(report.toProjectData()) + } catch (e: CoverageParseException) { + LOG.warn("Failed to load Testo coverage from $sessionDataFile", e) + reporter.reportError(e) + FailedCoverageLoadingResult(e, true) + } 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/resources/META-INF/coverage.xml b/src/main/resources/META-INF/coverage.xml index 2bd203a..271ba80 100644 --- a/src/main/resources/META-INF/coverage.xml +++ b/src/main/resources/META-INF/coverage.xml @@ -1,6 +1,8 @@ + + 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 0000000..9a6a0f6 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt @@ -0,0 +1,71 @@ +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 org.junit.Assert.assertEquals +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 + } + + private data class BranchPair(val total: Int, val covered: Int) +} From 40074d2514fe9edbe9555f679c2d146dd81e7eb1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 19:41:37 +0400 Subject: [PATCH 06/41] refactor(coverage): drop the php252/php262 coverage source split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two build variants existed in source only for the coverage typealiases, which the public-API rewrite made dead: nothing imports PhpCoverageRunner/PhpCoverageSuite/PhpUnitCoverageEngine/PhpUnitCoverageRunner any longer. Removes both PhpCoverageApi.kt files and the srcDir("src/php$phpApi/kotlin") wiring. phpApi stays — it still selects the platform version, since/until range and per-platform modules — but the two artifacts are now identical in source. Assisted-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 16 ++++++++-------- build.gradle.kts | 4 ++-- .../xepozz/testo/coverage/PhpCoverageApi.kt | 6 ------ .../xepozz/testo/coverage/PhpCoverageApi.kt | 6 ------ 4 files changed, 10 insertions(+), 22 deletions(-) delete mode 100644 src/php252/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt delete mode 100644 src/php262/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt diff --git a/CLAUDE.md b/CLAUDE.md index 7f6e7de..23b4a9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,20 +40,20 @@ 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`. 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 diff --git a/build.gradle.kts b/build.gradle.kts index 85f5ab2..2344218 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/src/php252/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt b/src/php252/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt deleted file mode 100644 index 4fde2f0..0000000 --- a/src/php252/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.xepozz.testo.coverage - -typealias PhpCoverageRunner = com.jetbrains.php.phpunit.coverage.PhpCoverageRunner -typealias PhpCoverageSuite = com.jetbrains.php.phpunit.coverage.PhpCoverageSuite -typealias PhpUnitCoverageEngine = com.jetbrains.php.phpunit.coverage.PhpUnitCoverageEngine -typealias PhpUnitCoverageRunner = com.jetbrains.php.phpunit.coverage.PhpUnitCoverageRunner diff --git a/src/php262/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt b/src/php262/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt deleted file mode 100644 index a94b8a3..0000000 --- a/src/php262/kotlin/com/github/xepozz/testo/coverage/PhpCoverageApi.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.xepozz.testo.coverage - -typealias PhpCoverageRunner = com.intellij.php.coverage.PhpCoverageRunner -typealias PhpCoverageSuite = com.intellij.php.coverage.PhpCoverageSuite -typealias PhpUnitCoverageEngine = com.intellij.php.coverage.PhpUnitCoverageEngine -typealias PhpUnitCoverageRunner = com.intellij.php.coverage.PhpUnitCoverageRunner From 9677e06aaf82eeb6b73ddf4e1194954737908a95 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 20:02:17 +0400 Subject: [PATCH 07/41] feat(coverage): per-test index, identity mapper and "covering tests" lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-test coverage layer from coverage-xml: TestoCoverageByTestData (which tests touched which lines, both directions, keyed by a normalized file path) backed by a project-service TestoCoverageByTestIndex that TestoCoverageRunner fills whenever a phpunit-xml report carries per-test data. TestoTestIdentityMapper maps a coverage TestId to a --filter selector (pure, for the future TIA rerun), a php_qn:// hint and its PSI, all in one place. Surfaces it as TestoCoverageByTestCodeVisionProvider — a code-vision lens showing "N covering tests" on any covered PHP method/function, click to list and navigate. The native CoverageEngine.getTestsForLine gutter is not an option: every per-test CoverageEngine hook is @ApiStatus.Internal and fails the verifier for a third-party plugin, so the same data drives our own lens instead. Index is in-memory for now (cross-restart persistence is deferred). Assisted-By: Claude Opus 4.8 (1M context) --- .../testo/coverage/TestoCoverageRunner.kt | 2 + .../perTest/TestoCoverageByTestData.kt | 59 +++++++++ .../perTest/TestoCoverageByTestIndex.kt | 31 +++++ .../coverage/perTest/TestoCoverageKeys.kt | 15 +++ .../perTest/TestoTestIdentityMapper.kt | 40 ++++++ .../TestoCoverageByTestCodeVisionProvider.kt | 120 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 2 + .../perTest/TestoPerTestCoverageTest.kt | 72 +++++++++++ 8 files changed, 341 insertions(+) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageKeys.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt index b96391f..eb70b09 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt @@ -2,6 +2,7 @@ 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 @@ -36,6 +37,7 @@ class TestoCoverageRunner : CoverageRunner() { return try { val report = parseCoverageReport(sessionDataFile.toPath(), suite?.format) suite?.applyParsed(report.hasBranches, report.perTest) + suite?.project?.let { TestoCoverageByTestIndex.getInstance(it).update(report.perTest) } SuccessCoverageLoadingResult(report.toProjectData()) } catch (e: CoverageParseException) { LOG.warn("Failed to load Testo coverage from $sessionDataFile", e) 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 0000000..b7a9740 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt @@ -0,0 +1,59 @@ +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. The + * substrate for "how many tests cover this" (arch §9) and the TIA seam (§8). 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 + + companion object { + val EMPTY: TestoCoverageByTestData = MapCoverageByTestData(emptyMap(), emptyMap()) + + fun of(perTest: PerTestCoverage?): TestoCoverageByTestData = + if (perTest == null) EMPTY else MapCoverageByTestData.from(perTest) + } +} + +internal class MapCoverageByTestData( + private val byLine: Map>, + private val byTest: Map>, +) : TestoCoverageByTestData { + + 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 + + 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 0000000..173d0a1 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt @@ -0,0 +1,31 @@ +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 (the "how many tests cover this" lens, arch §9; + * later TIA, §8) can read it with no active coverage session. Populated by [com.github.xepozz.testo.coverage. + * TestoCoverageRunner] when a `phpunit-xml` report is loaded. + * + * In-memory for now — it survives until the IDE closes or the next coverage-xml run replaces it. Cross-restart + * persistence keyed by report mtime is the open item in architecture §14.4. + */ +@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 0000000..052e8c3 --- /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/TestoTestIdentityMapper.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt new file mode 100644 index 0000000..6785abd --- /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 navigation (§9) and a future TIA rerun (§8) 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/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt new file mode 100644 index 0000000..5ec5929 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -0,0 +1,120 @@ +package com.github.xepozz.testo.ui + +import com.github.xepozz.testo.TestoIcons +import com.github.xepozz.testo.coverage.format.TestId +import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoTestIdentityMapper +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.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.pom.Navigatable +import com.intellij.psi.PsiDocumentManager +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 + +/** + * 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" (arch §9): 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 `phpunit-xml` coverage run has populated the index. + */ +class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { + + override val id: String = "testo.coverage.byTest" + + override val name: String = "Testo tests covering code" + + 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 = coveringTests(element as? Function ?: return null, file).size + return when (count) { + 0 -> null + 1 -> "1 covering test" + else -> "$count covering tests" + } + } + + override fun handleClick(editor: Editor, element: PsiElement, event: MouseEvent?) { + val function = element as? Function ?: return + val project = function.project + val tests = coveringTests(function, function.containingFile).sortedBy { "${it.fqcn}::${it.method}" } + if (tests.isEmpty()) return + + val mapper = TestoTestIdentityMapper.getInstance() + val popup = JBPopupFactory.getInstance() + .createPopupChooserBuilder(tests) + .setTitle(if (tests.size == 1) "1 Covering Test" else "${tests.size} Covering Tests") + .setRenderer(SimpleListCellRenderer.create("") { "${it.fqcn.trimStart('\\')}::${it.method}" }) + .setItemChosenCallback { id -> + (mapper.resolve(id, project) as? Navigatable)?.takeIf { it.canNavigate() }?.navigate(true) + } + .createPopup() + if (event != null) popup.show(RelativePoint(event)) else popup.showInBestPositionFor(editor) + } + + private fun coveringTests(function: Function, file: PsiFile): Set { + val virtualFile = file.virtualFile ?: return emptySet() + val project = file.project + val data = TestoCoverageByTestIndex.getInstance(project).data() + val document = PsiDocumentManager.getInstance(project).getDocument(file) ?: return emptySet() + val range = function.textRange + // 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 data.testsCoveringRange(virtualFile.path, first..last) + } + + // 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, + "Show the Testo tests that cover this declaration", + ) + ) + } + return lenses + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index b3bc8db..1cf8db5 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -40,6 +40,8 @@ + 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 0000000..3364bac --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt @@ -0,0 +1,72 @@ +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.PHPUNIT_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 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) + } +} From ec8d6d6a5b00a4519bcfb9705a423999c0efbe4e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 20:47:11 +0400 Subject: [PATCH 08/41] feat(coverage): "Show coverage" button to apply a report without a rerun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testo announces coverage reports (clover / cobertura / phpunit-xml) the same way it announces the HTML report, on any ordinary run with a writer configured. TestoReportRef gains coverageFormat / isCoverage, and the report toolbar draws a lean "Show coverage" cell beside the HTML ones — greyed until the file is on disk, then applying it on click with no process launch: addCoverageSuite through our runner yields a TestoCoverageSuite, its format is set, and coverageGathered hands it to the platform, which reads it via TestoCoverageRunner and paints the annotation. resolveCoverageDataFile points the platform's file provider at /index.xml for phpunit-xml (a directory it cannot consume) and at the report file otherwise. DefaultCoverageFileProvider is built from a File, not a Path — the Path ctor exists only on 262. Assisted-By: Claude Opus 4.8 (1M context) --- .../testo/coverage/TestoCoverageActivation.kt | 40 +++++++ .../testo/tests/console/TestoReportAction.kt | 108 +++++++++++++++++- .../testo/tests/console/TestoReportStore.kt | 8 ++ .../resources/messages/TestoBundle.properties | 4 + .../xepozz/testo/TestoReportStoreTest.kt | 15 +++ 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt 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 0000000..1227f57 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt @@ -0,0 +1,40 @@ +package com.github.xepozz.testo.coverage + +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.DefaultCoverageFileProvider +import com.intellij.openapi.project.Project +import java.nio.file.Files +import java.nio.file.Path + +/** + * Loads an already-written Testo coverage report into the IDE with no process launch (architecture §10): build a suite + * bound to the report through our runner, tell it the format, and hand it to the platform, which reads the file via + * [TestoCoverageRunner.loadCoverageData], opens the Coverage tool window and applies [TestoCoverageAnnotator]. + * + * Returns false when the coverage module is absent (the runner is registered only by `coverage.xml`); the format falls + * back to sniffing when unknown. Call on the EDT — `coverageGathered` opens UI. + */ +fun applyTestoCoverage(project: Project, name: String?, format: CoverageFormat?, dataFile: Path): Boolean { + val runner = CoverageRunner.getInstance(TestoCoverageRunner::class.java) ?: return false + val manager = CoverageDataManager.getInstance(project) + val timestamp = runCatching { Files.getLastModifiedTime(dataFile).toMillis() }.getOrDefault(0L) + val suite = manager.addCoverageSuite( + name ?: "Testo coverage", + // The File ctor is the one present on both 252 and 262 — Path was added only on 262. + DefaultCoverageFileProvider(dataFile.toFile()), + null, + timestamp, + null, + runner, + false, + false, + ) ?: return false + // A TestoCoverageSuite carries the format the runner reads; if the platform handed back something else the runner + // sniffs the file instead, so either way the load succeeds. + (suite as? TestoCoverageSuite)?.format = format ?: detectCoverageFormat(dataFile) ?: CoverageFormat.CLOVER + manager.coverageGathered(suite) + return true +} 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 578bbf4..510072d 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,8 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.applyTestoCoverage +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 @@ -19,6 +21,7 @@ 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 @@ -65,6 +68,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 val coverageCells = LinkedHashMap() private val timer = Timer(REFRESH_MS) { tick() } init { @@ -127,9 +131,17 @@ 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() + coverage.forEach { ref -> + coverageCells.getOrPut(ref.path) { CoverageReportCell(ref).also { add(it) } }.ref = ref + } + val coverageGone = coverageCells.keys - coverage.mapTo(HashSet()) { it.path } + coverageGone.forEach { path -> coverageCells.remove(path)?.let { remove(it) } } + coverageCells.values.forEach { it.refresh() } + + isVisible = cells.isNotEmpty() || coverageCells.isNotEmpty() // Re-laid out only when the row changed shape — this runs twice a second. val width = preferredSize.width @@ -351,6 +363,76 @@ class TestoReportsAction( OpenReportGroup(TestoBundle.message(key), icon, way, { ref }, project, reports, ::openOrArm) } + /** "Show coverage": applies an already-written coverage report to the editor with no rerun — no dropdown needed. */ + private inner class CoverageReportCell(ref: TestoReportRef) : JComponent() { + var ref: TestoReportRef = ref + private var located: Path? = null + private var runWasFinished = false + private var refreshed = false + private var hovered = false + + 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) { + located?.let { applyTestoCoverage(project, ref.name, ref.coverageFormat, it) } + } + }) + } + + private fun text(): String = ref.name ?: TestoBundle.message("testo.coverage.action.text") + + fun refresh() { + val finished = reports.runFinished + val found = if (finished) resolveCoverageDataFile(ref, project, mapToLocal, reports.runStartedAt) else null + if (refreshed && found == located && finished == runWasFinished) return + refreshed = true + located = found + runWasFinished = finished + toolTipText = when { + found != null -> TestoBundle.message("testo.coverage.action.description") + finished -> TestoBundle.message("testo.coverage.action.description.pending") + else -> TestoBundle.message("testo.coverage.action.description.running") + } + repaint() + } + + override fun getPreferredSize(): Dimension { + val metrics = getFontMetrics(font) + val width = PADDING + COVERAGE_ICON.iconWidth + GAP + metrics.stringWidth(text()) + 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 != null) 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) + } finally { + g2.dispose() + } + } + } + private companion object { private const val REFRESH_MS = 500 @@ -361,6 +443,10 @@ 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. + private val COVERAGE_ICON: Icon = AllIcons.General.RunWithCoverage + 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) @@ -473,6 +559,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 + * phpunit-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 phpunit = ref.coverageFormat == CoverageFormat.PHPUNIT_XML + return reportPathCandidates(ref, project.basePath) { runCatching { mapToLocal(it) }.getOrNull() } + .asSequence() + .mapNotNull { runCatching { Path.of(it) }.getOrNull() } + .map { if (phpunit && !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, 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 ad14de4..ae5d0ab 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 / phpunit-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" @@ -147,6 +153,8 @@ class TestoReportStore { fun viewable(): List = all().filter { it.isViewable } + fun coverage(): List = all().filter { it.isCoverage } + fun primary(): TestoReportRef? = viewable().lastOrNull() } diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 8d433ca..cd1e92b 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -57,6 +57,10 @@ testo.report.autoopen.application=Always Open in Every Project testo.report.copy.path=Copy Report Path testo.report.editor.name=Testo Report testo.report.webview.unavailable=This IDE runs without JCEF, so the report cannot be shown here. Use "Open in Browser" instead. +testo.coverage.action.text=Show coverage +testo.coverage.action.description=Apply this coverage report to the editor +testo.coverage.action.description.pending=Testo announced this coverage report but did not write it in this run +testo.coverage.action.description.running=Waiting for the test run to finish notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt index 95a588c..f45e120 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -38,6 +38,21 @@ class TestoReportStoreTest { assertEquals("Testo HTML report", ref.name) assertEquals("1", ref.schemaVersion) assertTrue(ref.isViewable) + assertFalse(ref.isCoverage) + } + + @Test + fun coverageFormatsAreRecognizedAndNotViewable() { + fun ref(format: String) = TestoReportRef.fromAttributes(mapOf("format" to format, "path" to "/tmp/r"))!! + + for (format in listOf("clover", "cobertura", "phpunit-xml")) { + val ref = ref(format) + assertTrue(format, ref.isCoverage) + assertFalse(format, ref.isViewable) + assertEquals(format, com.github.xepozz.testo.coverage.format.CoverageFormat.fromId(format), ref.coverageFormat) + } + assertFalse(ref("html").isCoverage) + assertNull(ref("html").coverageFormat) } @Test From ca0cf7ce203ee766e22c38f22d58678d93a7a526 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 21:04:23 +0400 Subject: [PATCH 09/41] fix(coverage): don't persist applied-coverage suites into workspace.xml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Show coverage" built the suite through addCoverageSuite, which registers it with CoverageDataManager and persists it to workspace.xml. On reload the platform's readDataFileProviderAttribute falls back to Path.of(systemPath, absolutePath) when the saved report path no longer exists, throwing InvalidPathException on Windows at the second drive letter — which breaks the whole CoverageDataManager and every subsequent coverage run. Testo report paths are arbitrary project files that get overwritten or removed between runs, so this was a live landmine. Build the suite through the engine and hand it straight to coverageGathered instead: it shows the same annotation and updates the per-test index, but is never added to the persisted suite set. The report is a transient view anyway. The run path is unaffected — its data file lives under the IDE-managed system dir and round-trips safely. Assisted-By: Claude Opus 4.8 (1M context) --- .../testo/coverage/TestoCoverageActivation.kt | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt index 1227f57..437bedb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt @@ -11,9 +11,15 @@ import java.nio.file.Path /** * Loads an already-written Testo coverage report into the IDE with no process launch (architecture §10): build a suite - * bound to the report through our runner, tell it the format, and hand it to the platform, which reads the file via + * bound to the report through our engine, tell it the format, and hand it to the platform, which reads the file via * [TestoCoverageRunner.loadCoverageData], opens the Coverage tool window and applies [TestoCoverageAnnotator]. * + * The suite is **not** registered with [CoverageDataManager] (no `addCoverageSuite`/`addExternalCoverageSuite`): those + * persist it 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 gathered-but-unregistered + * suite shows the same annotation, updates the per-test index, and never persists — the report is transient anyway. + * * Returns false when the coverage module is absent (the runner is registered only by `coverage.xml`); the format falls * back to sniffing when unknown. Call on the EDT — `coverageGathered` opens UI. */ @@ -21,20 +27,12 @@ fun applyTestoCoverage(project: Project, name: String?, format: CoverageFormat?, val runner = CoverageRunner.getInstance(TestoCoverageRunner::class.java) ?: return false val manager = CoverageDataManager.getInstance(project) val timestamp = runCatching { Files.getLastModifiedTime(dataFile).toMillis() }.getOrDefault(0L) - val suite = manager.addCoverageSuite( - name ?: "Testo coverage", - // The File ctor is the one present on both 252 and 262 — Path was added only on 262. - DefaultCoverageFileProvider(dataFile.toFile()), - null, - timestamp, - null, - runner, - false, - false, - ) ?: return false - // A TestoCoverageSuite carries the format the runner reads; if the platform handed back something else the runner - // sniffs the file instead, so either way the load succeeds. - (suite as? TestoCoverageSuite)?.format = format ?: detectCoverageFormat(dataFile) ?: CoverageFormat.CLOVER + // The File ctor is the one present on both 252 and 262 — Path was added only on 262. + val provider = DefaultCoverageFileProvider(dataFile.toFile()) + val suite = TestoCoverageEngine.INSTANCE + .createCoverageSuite(name ?: "Testo coverage", project, runner, provider, timestamp) as? TestoCoverageSuite + ?: return false + suite.format = format ?: detectCoverageFormat(dataFile) ?: CoverageFormat.CLOVER manager.coverageGathered(suite) return true } From 89aa2fdd9784aa84bdf585d611788fa25c9e1598 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 21:09:43 +0400 Subject: [PATCH 10/41] fix(coverage): resolve the coverage report off the EDT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Show coverage" cell resolved its data file synchronously on the Swing-timer EDT — resolveCoverageDataFile goes through the PHP path mapper and touches the filesystem, forbidden on the EDT. Mirror the ReportCell fix on this branch's base: resolve on a pooled thread and apply the result back on the EDT, guarded so overlapping ticks and a disposed project are dropped. Assisted-By: Claude Opus 4.8 (1M context) --- .../testo/tests/console/TestoReportAction.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 510072d..04cf178 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 @@ -370,6 +370,7 @@ class TestoReportsAction( private var runWasFinished = false private var refreshed = false private var hovered = false + private var resolving = false override fun getFont(): Font = UIUtil.getLabelFont() @@ -389,7 +390,29 @@ class TestoReportsAction( fun refresh() { val finished = reports.runFinished - val found = if (finished) resolveCoverageDataFile(ref, project, mapToLocal, reports.runStartedAt) else null + if (!finished) { + applyResolved(null, false) + return + } + // resolveCoverageDataFile goes through the PHP path mapper and touches the filesystem — forbidden on the + // EDT, and this runs off a Swing timer on the EDT. Resolve on a pooled thread, apply back on the EDT. + if (resolving) return + resolving = true + val startedAt = reports.runStartedAt + val cellRef = ref + ApplicationManager.getApplication().executeOnPooledThread { + val found = resolveCoverageDataFile(cellRef, project, mapToLocal, startedAt) + ApplicationManager.getApplication().invokeLater( + { + resolving = false + if (reports.runStartedAt == startedAt && reports.runFinished) applyResolved(found, true) + }, + ModalityState.any(), + ) { project.isDisposed } + } + } + + private fun applyResolved(found: Path?, finished: Boolean) { if (refreshed && found == located && finished == runWasFinished) return refreshed = true located = found From 9b2a6290f17071e4f2955763f5f77cef68e4dce7 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 21:09:44 +0400 Subject: [PATCH 11/41] =?UTF-8?q?docs(build):=20correct=20the=20two-varian?= =?UTF-8?q?t=20rationale=20=E2=80=94=20module=20topology,=20not=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gradle.properties header still blamed the PHP coverage package move and the src/php typealias split for the two build variants. Both are gone: coverage runs on public API and the source split was removed. The variants now exist only because 2026.2 split smRunner/testRunner/ui.jcef out of the monolith, and those module names do not resolve on 252. Assisted-By: Claude Opus 4.8 (1M context) --- gradle.properties | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index a5d9216..b9b7dc2 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 From 6fd98fed9a2e2ca130f0d71a7f35f8a2f3bf8c4c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 14 Aug 2026 21:31:37 +0400 Subject: [PATCH 12/41] feat(coverage): recover from stale coverage suites that block coverage init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A coverage suite persisted in workspace.xml whose report file is gone makes the platform's CoverageDataSuitesManager throw InvalidPathException while loading — Path.of(systemPath, absolutePath) on Windows — and the whole coverage subsystem fails to init. It fires before any plugin code, on the first coverage use, and the offending suite is usually another plugin's (PhpUnit's PhpCoverage), so the load itself is out of reach. Catch the failure where it surfaces on a Testo coverage run — the tests have already launched — and, instead of failing the run with an internal error, show a notification offering to drop the persisted CoverageDataManager component from workspace.xml (it only remembers past result files). The cleanup takes an IDE restart to reload, since the failed component is not written back on exit. isStaleCoverageFailure matches the InvalidPathException or the wrapping component error; it is unit-tested. Assisted-By: Claude Opus 4.8 (1M context) --- .../coverage/TestoCoverageProgramRunner.kt | 10 ++- .../testo/coverage/TestoStaleCoverageGuard.kt | 75 +++++++++++++++++++ .../resources/messages/TestoBundle.properties | 3 + .../coverage/TestoStaleCoverageGuardTest.kt | 34 +++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt 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 fbf0ce2..559af0c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -69,7 +69,15 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { val profileState = runConfiguration.getState(env, command, null) ?: return null val executionResult = profileState.execute(env.executor, this) ?: return null - CoverageHelper.attachToProcess(runConfiguration, executionResult.processHandler, env.runnerSettings) + try { + CoverageHelper.attachToProcess(runConfiguration, executionResult.processHandler, env.runnerSettings) + } catch (e: Throwable) { + // A stale coverage suite (often another plugin's, e.g. PhpUnit's) whose file is gone breaks the platform's + // coverage init before our code runs. The tests already launched — surface a one-click cleanup instead of + // failing the run with an internal error. + if (!TestoStaleCoverageGuard.isStaleCoverageFailure(e)) throw e + TestoStaleCoverageGuard.notifyStaleCoverage(runConfiguration.project) + } return RunContentBuilder(executionResult, env).showRunContent(env.contentToReuse) } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt new file mode 100644 index 0000000..925bf31 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt @@ -0,0 +1,75 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.TestoBundle +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +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.vfs.VfsUtil +import java.nio.file.InvalidPathException + +/** + * A stale coverage suite in `workspace.xml` — a report whose absolute file no longer exists — makes the platform's + * `CoverageDataSuitesManager` throw while loading: on Windows `Path.of(systemPath, absolutePath)` hits the second drive + * letter and raises [InvalidPathException], and the whole coverage subsystem fails to init. It fires before any plugin + * code, on the first coverage use, and the offending suite is often another plugin's (e.g. PhpUnit's `PhpCoverage`), + * so we cannot stop the load. We instead recognise the failure when it surfaces on a Testo run and offer to drop the + * persisted coverage component — which the platform re-reads only on restart. + */ +object TestoStaleCoverageGuard { + private const val COVERAGE_COMPONENT = "com.intellij.coverage.CoverageDataManagerImpl" + private val LOG = Logger.getInstance(TestoStaleCoverageGuard::class.java) + + /** The signatures of the stale-suite load failure: the `InvalidPathException` itself, or the wrapping component error. */ + fun isStaleCoverageFailure(t: Throwable): Boolean { + var cause: Throwable? = t + while (cause != null) { + if (cause is InvalidPathException) return true + val message = cause.message.orEmpty() + if ("CoverageDataSuitesManager" in message || COVERAGE_COMPONENT in message) return true + cause = cause.cause + } + return false + } + + fun notifyStaleCoverage(project: Project) { + val notification = NotificationGroupManager.getInstance().getNotificationGroup("Testo") + ?.createNotification( + TestoBundle.message("testo.coverage.stale.title"), + TestoBundle.message("testo.coverage.stale.text"), + NotificationType.WARNING, + ) ?: return + notification.addAction(NotificationAction.createSimple(TestoBundle.message("testo.coverage.stale.cleanup")) { + if (removeCoverageComponent(project)) { + notification.expire() + ApplicationManager.getApplication().restart() + } + }) + notification.notify(project) + } + + /** + * Drops the whole `CoverageDataManagerImpl` component from `workspace.xml` — it only remembers past coverage-result + * files, so nothing of value is lost. The edit takes effect on the next start: the failed component is not written + * back on exit, so restarting re-reads the cleaned file. + */ + private fun removeCoverageComponent(project: Project): Boolean { + val workspace = project.workspaceFile ?: return false + return try { + val root = workspace.inputStream.use { JDOMUtil.load(it) } + val component = root.getChildren("component") + .firstOrNull { it.getAttributeValue("name") == COVERAGE_COMPONENT } + ?: return false + root.removeContent(component) + val text = JDOMUtil.write(root) + ApplicationManager.getApplication().runWriteAction { VfsUtil.saveText(workspace, text) } + true + } catch (e: Exception) { + LOG.warn("Failed to clean stale coverage data from ${workspace.path}", e) + false + } + } +} diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index cd1e92b..75dd8a5 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -61,6 +61,9 @@ testo.coverage.action.text=Show coverage testo.coverage.action.description=Apply this coverage report to the editor testo.coverage.action.description.pending=Testo announced this coverage report but did not write it in this run testo.coverage.action.description.running=Waiting for the test run to finish +testo.coverage.stale.title=Coverage could not be loaded +testo.coverage.stale.text=A coverage report saved by an earlier run is missing on disk and blocks the IDE from loading coverage data. The tests ran, but coverage is not shown. +testo.coverage.stale.cleanup=Remove stale coverage data and restart notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt new file mode 100644 index 0000000..4a3638d --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt @@ -0,0 +1,34 @@ +package com.github.xepozz.testo.coverage + +import com.intellij.diagnostic.PluginException +import com.intellij.openapi.extensions.PluginId +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.nio.file.InvalidPathException + +/** Pure detection of the stale-coverage load failure — no platform fixture. */ +class TestoStaleCoverageGuardTest { + + @Test + fun detectsInvalidPathExceptionInCauseChain() { + val root = RuntimeException("wrapper", IllegalStateException("mid", InvalidPathException("C:\\a\\D:\\b", "Illegal char"))) + assertTrue(TestoStaleCoverageGuard.isStaleCoverageFailure(root)) + } + + @Test + fun detectsCoverageComponentInMessage() { + val e = PluginException( + "Cannot init component state (componentName=com.intellij.coverage.CoverageDataManagerImpl, componentClass=CoverageDataSuitesManager)", + PluginId.getId("com.intellij"), + ) + assertTrue(TestoStaleCoverageGuard.isStaleCoverageFailure(e)) + } + + @Test + fun ignoresUnrelatedFailures() { + assertFalse(TestoStaleCoverageGuard.isStaleCoverageFailure(IOException("disk full"))) + assertFalse(TestoStaleCoverageGuard.isStaleCoverageFailure(RuntimeException("something else"))) + } +} From ace47c3c4b4cd2e9ae6b27d789c835475f90a03a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 14:06:55 +0400 Subject: [PATCH 13/41] fix(coverage): fill the coverage panel from the report, not the platform cache feat(coverage): add a Branches, % column for reports that carry branch data refactor(coverage): drop the stale-coverage guard the platform never lets fire The inherited annotator caches are filled by a walk that descends into a file only when the engine claims it through `coverageProjectViewStatisticsApplicableTo`, which is `@ApiStatus.Internal` and defaults to false. Staying on public API therefore left every lookup null and the panel reading "No coverage results" over a complete `ProjectData`. The annotator now indexes that data once per suite and answers both overload families the view uses: the `PsiFile`/`PsiDirectory` pair decides which rows exist, the `VirtualFile` pair fills the statistics column. The guard could never run. A stale suite's `InvalidPathException` is raised inside the `invokeAndWait` of `CoverageHelper.attachToProcess`, where the platform logs it and does not propagate it, so the runner catch that was its only caller never saw it. Recovering from a poisoned `workspace.xml` stays manual until the platform bug is fixed. Assisted-By: Claude Opus 4.8 --- .../testo/coverage/TestoCoverageAnnotator.kt | 153 +++++++++++++++++- .../testo/coverage/TestoCoverageEngine.kt | 7 +- .../coverage/TestoCoverageProgramRunner.kt | 11 +- .../coverage/TestoCoverageProjectData.kt | 10 +- .../testo/coverage/TestoCoverageRunner.kt | 7 +- .../coverage/TestoCoverageViewExtension.kt | 45 ++++++ .../testo/coverage/TestoStaleCoverageGuard.kt | 75 --------- .../resources/messages/TestoBundle.properties | 5 +- .../coverage/TestoStaleCoverageGuardTest.kt | 34 ---- 9 files changed, 213 insertions(+), 134 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt delete mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt index aefb238..f585625 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt @@ -1,17 +1,162 @@ package com.github.xepozz.testo.coverage +import com.github.xepozz.testo.TestoBundle 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 /** - * File-path-keyed coverage annotation with remote-interpreter path remapping, mirroring PHP's `PhpCoverageAnnotator`. - * [RemappingCoverageAnnotator] does the whole job; we only suppress the "0%" line string so a file with no covered - * lines shows nothing instead of a misleading zero. + * 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()) + } + } + + 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 && info.coveredLineCount != 0) super.getLinesCoverageInformationString(info) else null + 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) + indexedData = data + } + return index + } + } + + private fun buildIndex(data: ProjectData): Index { + val files = HashMap() + val dirs = HashMap() + val branches = HashMap() + for ((path, classData) in data.classes) { + val info = fileInfoForCoveredFile(classData) ?: continue + val filePath = key(path) + files[filePath] = info + val branchStat = branchStatFor(classData) + if (branchStat != null) branches[filePath] = branchStat + 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 Index(files, dirs, branches) + } + + 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/TestoCoverageEngine.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt index aec4d27..a2ddae0 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt @@ -11,7 +11,6 @@ import com.intellij.coverage.CoverageSuite import com.intellij.coverage.CoverageSuitesBundle import com.intellij.coverage.BaseCoverageSuite import com.intellij.coverage.view.CoverageViewExtension -import com.intellij.coverage.view.DirectoryCoverageViewExtension import com.intellij.execution.configurations.RunConfigurationBase import com.intellij.execution.configurations.coverage.CoverageEnabledConfiguration import com.intellij.openapi.project.Project @@ -96,11 +95,13 @@ class TestoCoverageEngine : CoverageEngine() { 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?.canonicalPath?.let { setOf(it) } ?: emptySet() + sourceFile.virtualFile?.path?.let { setOf(it) } ?: emptySet() override fun createCoverageViewExtension(project: Project, suiteBundle: CoverageSuitesBundle): CoverageViewExtension = - DirectoryCoverageViewExtension(project, getCoverageAnnotator(project), suiteBundle) + TestoCoverageViewExtension(project, TestoCoverageAnnotator.getInstance(project), suiteBundle) 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 559af0c..e3c6a91 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -69,15 +69,8 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { val profileState = runConfiguration.getState(env, command, null) ?: return null val executionResult = profileState.execute(env.executor, this) ?: return null - try { - CoverageHelper.attachToProcess(runConfiguration, executionResult.processHandler, env.runnerSettings) - } catch (e: Throwable) { - // A stale coverage suite (often another plugin's, e.g. PhpUnit's) whose file is gone breaks the platform's - // coverage init before our code runs. The tests already launched — surface a one-click cleanup instead of - // failing the run with an internal error. - if (!TestoStaleCoverageGuard.isStaleCoverageFailure(e)) throw e - TestoStaleCoverageGuard.notifyStaleCoverage(runConfiguration.project) - } + + CoverageHelper.attachToProcess(runConfiguration, executionResult.processHandler, env.runnerSettings) return RunContentBuilder(executionResult, env).showRunContent(env.contentToReuse) } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt index 357693b..7db65e2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt @@ -6,19 +6,19 @@ import com.intellij.rt.coverage.data.LineData import com.intellij.rt.coverage.data.ProjectData /** - * Builds the platform coverage model from a parsed report, mirroring how PHP's own `PhpCloverXMLOutputParser` populates - * a [ProjectData]: one `ClassData` per file keyed by the forward-slashed source path (the annotator normalizes and, on - * Windows, lower-cases both sides itself, so no further normalization here), lines laid out in a number-indexed array. + * 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. See `docs/coverage/report-formats.md` §2 and architecture §14.2. */ -fun ParsedReport.toProjectData(): ProjectData { +fun ParsedReport.toProjectData(keyFor: (String) -> String = { it }): ProjectData { val projectData = ProjectData() for (file in files) { - val classData = projectData.getOrCreateClassData(file.filePath) + val classData = projectData.getOrCreateClassData(keyFor(file.filePath)) val executable = file.lines.filter { it.line >= 0 } if (executable.isEmpty()) continue val lines = arrayOfNulls(executable.maxOf { it.line } + 1) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt index eb70b09..41eaf81 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt @@ -11,6 +11,7 @@ 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 /** @@ -38,7 +39,11 @@ class TestoCoverageRunner : CoverageRunner() { val report = parseCoverageReport(sessionDataFile.toPath(), suite?.format) suite?.applyParsed(report.hasBranches, report.perTest) suite?.project?.let { TestoCoverageByTestIndex.getInstance(it).update(report.perTest) } - SuccessCoverageLoadingResult(report.toProjectData()) + // Key each ClassData by the resolved VirtualFile path so it matches how TestoCoverageAnnotator looks files up. + val lfs = LocalFileSystem.getInstance() + val projectData = report.toProjectData { path -> lfs.findFileByPath(path)?.path ?: path } + LOG.info("Testo coverage loaded: ${report.format} ${projectData.classes.size} files from $sessionDataFile") + SuccessCoverageLoadingResult(projectData) } catch (e: CoverageParseException) { LOG.warn("Failed to load Testo coverage from $sessionDataFile", e) reporter.reportError(e) 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 0000000..67200d6 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt @@ -0,0 +1,45 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.TestoBundle +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.openapi.project.Project +import com.intellij.util.ui.ColumnInfo + +/** + * The platform's file tree plus a branch column, shown only for reports that carry branch data — clover records + * `truecount`/`falsecount` per condition, cobertura a `condition-coverage` ratio, and neither is present in a + * line-only report, where the column would read empty for every row. + */ +class TestoCoverageViewExtension( + 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)) + } + return columns.toTypedArray() + } + + override fun getPercentage(columnIdx: Int, node: AbstractTreeNode<*>): String? { + if (columnIdx != BRANCHES_COLUMN) return super.getPercentage(columnIdx, node) + val file = extractFile(node) ?: return null + return annotator.getBranchCoverageInformationString(file, mySuitesBundle) + } + + 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/TestoStaleCoverageGuard.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt deleted file mode 100644 index 925bf31..0000000 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuard.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.github.xepozz.testo.coverage - -import com.github.xepozz.testo.TestoBundle -import com.intellij.notification.NotificationAction -import com.intellij.notification.NotificationGroupManager -import com.intellij.notification.NotificationType -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.vfs.VfsUtil -import java.nio.file.InvalidPathException - -/** - * A stale coverage suite in `workspace.xml` — a report whose absolute file no longer exists — makes the platform's - * `CoverageDataSuitesManager` throw while loading: on Windows `Path.of(systemPath, absolutePath)` hits the second drive - * letter and raises [InvalidPathException], and the whole coverage subsystem fails to init. It fires before any plugin - * code, on the first coverage use, and the offending suite is often another plugin's (e.g. PhpUnit's `PhpCoverage`), - * so we cannot stop the load. We instead recognise the failure when it surfaces on a Testo run and offer to drop the - * persisted coverage component — which the platform re-reads only on restart. - */ -object TestoStaleCoverageGuard { - private const val COVERAGE_COMPONENT = "com.intellij.coverage.CoverageDataManagerImpl" - private val LOG = Logger.getInstance(TestoStaleCoverageGuard::class.java) - - /** The signatures of the stale-suite load failure: the `InvalidPathException` itself, or the wrapping component error. */ - fun isStaleCoverageFailure(t: Throwable): Boolean { - var cause: Throwable? = t - while (cause != null) { - if (cause is InvalidPathException) return true - val message = cause.message.orEmpty() - if ("CoverageDataSuitesManager" in message || COVERAGE_COMPONENT in message) return true - cause = cause.cause - } - return false - } - - fun notifyStaleCoverage(project: Project) { - val notification = NotificationGroupManager.getInstance().getNotificationGroup("Testo") - ?.createNotification( - TestoBundle.message("testo.coverage.stale.title"), - TestoBundle.message("testo.coverage.stale.text"), - NotificationType.WARNING, - ) ?: return - notification.addAction(NotificationAction.createSimple(TestoBundle.message("testo.coverage.stale.cleanup")) { - if (removeCoverageComponent(project)) { - notification.expire() - ApplicationManager.getApplication().restart() - } - }) - notification.notify(project) - } - - /** - * Drops the whole `CoverageDataManagerImpl` component from `workspace.xml` — it only remembers past coverage-result - * files, so nothing of value is lost. The edit takes effect on the next start: the failed component is not written - * back on exit, so restarting re-reads the cleaned file. - */ - private fun removeCoverageComponent(project: Project): Boolean { - val workspace = project.workspaceFile ?: return false - return try { - val root = workspace.inputStream.use { JDOMUtil.load(it) } - val component = root.getChildren("component") - .firstOrNull { it.getAttributeValue("name") == COVERAGE_COMPONENT } - ?: return false - root.removeContent(component) - val text = JDOMUtil.write(root) - ApplicationManager.getApplication().runWriteAction { VfsUtil.saveText(workspace, text) } - true - } catch (e: Exception) { - LOG.warn("Failed to clean stale coverage data from ${workspace.path}", e) - false - } - } -} diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 75dd8a5..bd59d8a 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -61,9 +61,8 @@ testo.coverage.action.text=Show coverage testo.coverage.action.description=Apply this coverage report to the editor testo.coverage.action.description.pending=Testo announced this coverage report but did not write it in this run testo.coverage.action.description.running=Waiting for the test run to finish -testo.coverage.stale.title=Coverage could not be loaded -testo.coverage.stale.text=A coverage report saved by an earlier run is missing on disk and blocks the IDE from loading coverage data. The tests ran, but coverage is not shown. -testo.coverage.stale.cleanup=Remove stale coverage data and restart +testo.coverage.view.column.branches=Branches, % +testo.coverage.view.branches.covered={0}% branches covered ({1}/{2}) notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt deleted file mode 100644 index 4a3638d..0000000 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoStaleCoverageGuardTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.xepozz.testo.coverage - -import com.intellij.diagnostic.PluginException -import com.intellij.openapi.extensions.PluginId -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.IOException -import java.nio.file.InvalidPathException - -/** Pure detection of the stale-coverage load failure — no platform fixture. */ -class TestoStaleCoverageGuardTest { - - @Test - fun detectsInvalidPathExceptionInCauseChain() { - val root = RuntimeException("wrapper", IllegalStateException("mid", InvalidPathException("C:\\a\\D:\\b", "Illegal char"))) - assertTrue(TestoStaleCoverageGuard.isStaleCoverageFailure(root)) - } - - @Test - fun detectsCoverageComponentInMessage() { - val e = PluginException( - "Cannot init component state (componentName=com.intellij.coverage.CoverageDataManagerImpl, componentClass=CoverageDataSuitesManager)", - PluginId.getId("com.intellij"), - ) - assertTrue(TestoStaleCoverageGuard.isStaleCoverageFailure(e)) - } - - @Test - fun ignoresUnrelatedFailures() { - assertFalse(TestoStaleCoverageGuard.isStaleCoverageFailure(IOException("disk full"))) - assertFalse(TestoStaleCoverageGuard.isStaleCoverageFailure(RuntimeException("something else"))) - } -} From c66c4609cad88455838e4b49be0dd2c3d70472c1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 14:41:13 +0400 Subject: [PATCH 14/41] fix(coverage): keep line-less files out of the coverage model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(coverage): take per-file line totals from a coverage-xml report refactor(coverage): rename the phpunit-xml format id to coverage-xml coverage-xml lists a file it recorded no covered line for, and a `ClassData` was created for it before the emptiness check. The platform reads `ClassData.getLines()` without a null check, so expanding the coverage tree threw an NPE out of `fileInfoForCoveredFile`. That format records only executed lines, so counting them called every file fully covered. Its per-file `` states `total`/`executed` even for a file with no covered line, which makes the percentages exact and puts fully uncovered files back in the tree. The report's own `percent` is ignored: a directory has to sum its files, and one rounding across formats keeps the column consistent. The format id follows the rename on the Testo side; nothing accepts `phpunit-xml` any more, so the "Show coverage" button appears only for a report announced as `coverage-xml`. Only the announced id changes — the report is still written in PHPUnit's coverage schema, and detection off a `` root or a directory path is untouched. Assisted-By: Claude Opus 4.8 --- .../testo/coverage/TestoCoverageAnnotator.kt | 34 +++++++++++++++---- .../testo/coverage/TestoCoverageEngine.kt | 13 ++++++- .../coverage/TestoCoverageProgramRunner.kt | 2 +- .../coverage/TestoCoverageProjectData.kt | 4 ++- .../testo/coverage/TestoCoverageRunner.kt | 6 ++-- .../testo/coverage/format/CoverageModel.kt | 10 ++++-- ...CoverageParser.kt => CoverageXmlParser.kt} | 21 ++++++++---- .../coverage/format/TestoCoverageParser.kt | 8 ++--- .../perTest/TestoCoverageByTestIndex.kt | 2 +- .../testo/tests/console/TestoReportAction.kt | 6 ++-- .../testo/tests/console/TestoReportStore.kt | 2 +- .../TestoCoverageByTestCodeVisionProvider.kt | 2 +- .../xepozz/testo/TestoReportStoreTest.kt | 2 +- .../coverage/TestoCoverageProjectDataTest.kt | 16 +++++++++ .../coverage/format/CoverageParserTest.kt | 34 ++++++++++++++----- .../perTest/TestoPerTestCoverageTest.kt | 2 +- 16 files changed, 124 insertions(+), 40 deletions(-) rename src/main/kotlin/com/github/xepozz/testo/coverage/format/{PhpUnitXmlCoverageParser.kt => CoverageXmlParser.kt} (64%) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt index f585625..fc243cf 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt @@ -1,6 +1,7 @@ package com.github.xepozz.testo.coverage import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.format.LineTotals import com.intellij.coverage.BaseCoverageAnnotator import com.intellij.coverage.CoverageDataManager import com.intellij.coverage.CoverageSuitesBundle @@ -108,23 +109,44 @@ class TestoCoverageAnnotator(project: Project) : RemappingCoverageAnnotator(proj synchronized(lock) { // RemappingCoverageAnnotator can swap the suite's data for a remapped copy, so compare identity, not content. if (indexedData !== data) { - index = buildIndex(data) + index = buildIndex(data, lineTotalsOf(bundle)) indexedData = data } return index } } - private fun buildIndex(data: ProjectData): 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 dirs = 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 - val branchStat = branchStatFor(classData) - if (branchStat != null) branches[filePath] = branchStat + 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() } @@ -142,7 +164,7 @@ class TestoCoverageAnnotator(project: Project) : RemappingCoverageAnnotator(proj dir = dir.substringBeforeLast('/', "") } } - return Index(files, dirs, branches) + return dirs } private fun branchStatFor(classData: ClassData): BranchStat? { 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 a2ddae0..ba8b9bb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt @@ -1,6 +1,7 @@ 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.coverage.format.PerTestCoverage import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.coverage.CoverageAnnotator @@ -36,6 +37,15 @@ class TestoCoverageEnabledConfiguration( class TestoCoverageSuite : BaseCoverageSuite { var format: CoverageFormat = CoverageFormat.CLOVER var perTest: PerTestCoverage? = null + + /** + * 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() @@ -48,9 +58,10 @@ class TestoCoverageSuite : BaseCoverageSuite { timeStamp: Long, ) : super(name, project, coverageRunner, fileProvider, timeStamp) - fun applyParsed(hasBranches: Boolean, perTest: PerTestCoverage?) { + fun applyParsed(hasBranches: Boolean, perTest: PerTestCoverage?, lineTotals: Map) { this.branchCoverage = hasBranches this.perTest = perTest + this.lineTotals = lineTotals } override fun isBranchCoverage(): Boolean = branchCoverage 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 e3c6a91..06af625 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -83,7 +83,7 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { return when (format) { CoverageFormat.CLOVER -> listOf("--coverage-clover=$targetCoverage") CoverageFormat.COBERTURA -> listOf("--coverage-cobertura=$targetCoverage") - CoverageFormat.PHPUNIT_XML -> listOf("--coverage-xml=$targetCoverage") + CoverageFormat.COVERAGE_XML -> listOf("--coverage-xml=$targetCoverage") } } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt index 7db65e2..229757c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt @@ -18,9 +18,11 @@ import com.intellij.rt.coverage.data.ProjectData fun ParsedReport.toProjectData(keyFor: (String) -> String = { it }): ProjectData { val projectData = ProjectData() for (file in files) { - val classData = projectData.getOrCreateClassData(keyFor(file.filePath)) 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) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt index 41eaf81..96a4a62 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt @@ -37,11 +37,13 @@ class TestoCoverageRunner : CoverageRunner() { val suite = baseCoverageSuite as? TestoCoverageSuite return try { val report = parseCoverageReport(sessionDataFile.toPath(), suite?.format) - suite?.applyParsed(report.hasBranches, report.perTest) 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 projectData = report.toProjectData { path -> lfs.findFileByPath(path)?.path ?: path } + 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, report.perTest, lineTotals.toMap()) LOG.info("Testo coverage loaded: ${report.format} ${projectData.classes.size} files from $sessionDataFile") SuccessCoverageLoadingResult(projectData) } catch (e: CoverageParseException) { 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 index eed13a5..df08dfa 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt @@ -7,7 +7,7 @@ package com.github.xepozz.testo.coverage.format enum class CoverageFormat(val id: String) { CLOVER("clover"), COBERTURA("cobertura"), - PHPUNIT_XML("phpunit-xml"); + COVERAGE_XML("coverage-xml"); companion object { fun fromId(id: String?): CoverageFormat? = entries.firstOrNull { it.id.equals(id, ignoreCase = true) } @@ -37,8 +37,14 @@ 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 (arch §6). */ -data class FileCoverage(val filePath: String, val lines: List) +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 diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/format/PhpUnitXmlCoverageParser.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt similarity index 64% rename from src/main/kotlin/com/github/xepozz/testo/coverage/format/PhpUnitXmlCoverageParser.kt rename to src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt index 654ce0b..1f7a809 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/PhpUnitXmlCoverageParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt @@ -1,15 +1,17 @@ package com.github.xepozz.testo.coverage.format +import org.w3c.dom.Element import java.nio.file.Files import java.nio.file.Path /** - * coverage-xml (PHPUnit-style): a directory — an `index.xml` overview plus one XML per source file. Only executed - * lines that have covering tests are emitted (a per-test overlay, no uncovered lines). Source file for a `` - * entry is `/`; the per-file XML sits at `/`. See report-formats §3. + * 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 `/`. See report-formats §3. */ -object PhpUnitXmlCoverageParser : TestoCoverageParser { - override val format = CoverageFormat.PHPUNIT_XML +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 @@ -41,10 +43,17 @@ object PhpUnitXmlCoverageParser : TestoCoverageParser { byLine.getOrPut(ref) { linkedSetOf() } += testId } } - files += FileCoverage(path, lines) + 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 index 10f774d..8f595f9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/TestoCoverageParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/TestoCoverageParser.kt @@ -14,15 +14,15 @@ interface TestoCoverageParser { * element. `null` only when the path is unreadable or nothing matches. */ fun detectCoverageFormat(reportPath: Path): CoverageFormat? { - if (Files.isDirectory(reportPath)) return CoverageFormat.PHPUNIT_XML - if (reportPath.fileName?.toString().equals("index.xml", ignoreCase = true)) return CoverageFormat.PHPUNIT_XML + 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.PHPUNIT_XML + root.tagName == "phpunit" -> CoverageFormat.COVERAGE_XML root.tagName == "coverage" && (root.hasAttribute("line-rate") || root.childElements("packages").isNotEmpty()) -> CoverageFormat.COBERTURA root.tagName == "coverage" -> CoverageFormat.CLOVER @@ -37,7 +37,7 @@ fun parseCoverageReport(reportPath: Path, format: CoverageFormat? = null): Parse val parser = when (resolved) { CoverageFormat.CLOVER -> CloverCoverageParser CoverageFormat.COBERTURA -> CoberturaCoverageParser - CoverageFormat.PHPUNIT_XML -> PhpUnitXmlCoverageParser + CoverageFormat.COVERAGE_XML -> CoverageXmlParser } return parser.parse(reportPath) } 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 index 173d0a1..339584d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt @@ -8,7 +8,7 @@ import com.intellij.openapi.project.Project /** * Holds the latest per-test coverage for the project so consumers (the "how many tests cover this" lens, arch §9; * later TIA, §8) can read it with no active coverage session. Populated by [com.github.xepozz.testo.coverage. - * TestoCoverageRunner] when a `phpunit-xml` report is loaded. + * TestoCoverageRunner] when a `coverage-xml` report is loaded. * * In-memory for now — it survives until the IDE closes or the next coverage-xml run replaces it. Cross-restart * persistence keyed by report mtime is the open item in architecture §14.4. 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 04cf178..fe39adb 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 @@ -584,7 +584,7 @@ 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 - * phpunit-xml (a directory the platform's file provider cannot consume) — or `null` while there is none yet. + * coverage-xml (a directory the platform's file provider cannot consume) — or `null` while there is none yet. */ internal fun resolveCoverageDataFile( ref: TestoReportRef, @@ -592,11 +592,11 @@ internal fun resolveCoverageDataFile( mapToLocal: (String) -> String?, writtenAfter: Long, ): Path? { - val phpunit = ref.coverageFormat == CoverageFormat.PHPUNIT_XML + 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 (phpunit && !it.fileName?.toString().equals("index.xml", ignoreCase = true)) it.resolve("index.xml") else it } + .map { if (coverageXml && !it.fileName?.toString().equals("index.xml", ignoreCase = true)) it.resolve("index.xml") else it } .firstOrNull { isReportOf(it, writtenAfter) } } 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 ae5d0ab..1f1ce10 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 @@ -21,7 +21,7 @@ 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 / phpunit-xml — that the "Show coverage" button can apply without a rerun. */ + /** 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index 5ec5929..eafcf7d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -32,7 +32,7 @@ import java.awt.event.MouseEvent * * This is the public-API answer to "how many tests cover this" (arch §9): 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 `phpunit-xml` coverage run has populated the index. + * The lens is empty (hidden) until a `coverage-xml` coverage run has populated the index. */ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt index f45e120..8211ac3 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -45,7 +45,7 @@ class TestoReportStoreTest { fun coverageFormatsAreRecognizedAndNotViewable() { fun ref(format: String) = TestoReportRef.fromAttributes(mapOf("format" to format, "path" to "/tmp/r"))!! - for (format in listOf("clover", "cobertura", "phpunit-xml")) { + for (format in listOf("clover", "cobertura", "coverage-xml")) { val ref = ref(format) assertTrue(format, ref.isCoverage) assertFalse(format, ref.isViewable) diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt index 9a6a0f6..7a6cd49 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt @@ -7,6 +7,7 @@ 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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import java.nio.file.Path @@ -67,5 +68,20 @@ class TestoCoverageProjectDataTest { assertEquals(0, cls.getLineData(13).status) // hits==0 wins over branch data -> uncovered } + /** 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/format/CoverageParserTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt index 22971ca..60756ad 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt @@ -79,13 +79,13 @@ class CoverageParserTest { assertTrue(report.hasBranches) // the interceptor class does; sanity that the flag tracks any branch line } - // ---- coverage-xml (phpunit-xml) ------------------------------------------------------------------------------- + // ---- coverage-xml --------------------------------------------------------------------------------------------- @Test - fun phpUnitXmlBuildsPerTestIndexBothDirections() { - val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.PHPUNIT_XML) + fun coverageXmlBuildsPerTestIndexBothDirections() { + val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.COVERAGE_XML) - assertEquals(CoverageFormat.PHPUNIT_XML, report.format) + assertEquals(CoverageFormat.COVERAGE_XML, report.format) assertFalse(report.hasBranches) assertNotNull(report.perTest) val perTest = report.perTest!! @@ -101,8 +101,8 @@ class CoverageParserTest { } @Test - fun phpUnitXmlEmitsOnlyExecutedLinesAndKeepsEmptyFiles() { - val report = parseCoverageReport(dir.resolve("coverage-xml"), CoverageFormat.PHPUNIT_XML) + 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 @@ -110,8 +110,24 @@ class CoverageParserTest { 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 phpUnitXmlAcceptsIndexFileDirectly() { + 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()) @@ -123,8 +139,8 @@ class CoverageParserTest { fun detectsEachFormat() { assertEquals(CoverageFormat.CLOVER, detectCoverageFormat(dir.resolve("clover.xml"))) assertEquals(CoverageFormat.COBERTURA, detectCoverageFormat(dir.resolve("cobertura.xml"))) - assertEquals(CoverageFormat.PHPUNIT_XML, detectCoverageFormat(dir.resolve("coverage-xml"))) - assertEquals(CoverageFormat.PHPUNIT_XML, detectCoverageFormat(dir.resolve("coverage-xml/index.xml"))) + assertEquals(CoverageFormat.COVERAGE_XML, detectCoverageFormat(dir.resolve("coverage-xml"))) + assertEquals(CoverageFormat.COVERAGE_XML, detectCoverageFormat(dir.resolve("coverage-xml/index.xml"))) } @Test 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 index 3364bac..c19c21c 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt @@ -19,7 +19,7 @@ class TestoPerTestCoverageTest { 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.PHPUNIT_XML).perTest, + parseCoverageReport(Path.of("src/test/testData/coverage/coverage-xml"), CoverageFormat.COVERAGE_XML).perTest, ) @Test From aed88b260b7728129f180104d99b16e6bc70491a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 16:29:22 +0400 Subject: [PATCH 15/41] feat(coverage): paint per-line coverage in the editor gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's editor annotation pipeline (createSrcFileAnnotator, CoverageEditorAnnotatorImpl, CoverageLineMarkerRenderer) is @ApiStatus.Internal end to end, so the stripes come from a Testo-owned highlighter over the public MarkupModel/LineMarkerRenderer API, on the standard coverage colour keys so the user's Colors & Fonts settings apply unchanged. Applying rides CoverageSuiteListener.coverageDataCalculated — fired on the EDT only after the report is parsed — and installation hangs off the annotator's onSuiteChosen, the one hook that also fires on closeSuitesBundle, which emits no listener event. Reading bundle.getCoverageData() hops through a pooled thread: the getter re-parses the report when its soft cache was collected. Highlighters are range markers, so edits shift the stripes with the code; only a marker torn across two lines by an Enter in its middle is dropped — the run measured a line that no longer exists, and the platform's restore path (LineHistoryMapper) is internal. Clicking a stripe pops the line's numbers platform-style — Hits, Branches (branch lines only), Tests — with the covering tests listed below, navigating through TestoTestIdentityMapper off the per-test index. Assisted-By: Claude Opus 5 (1M context) --- .../testo/coverage/TestoCoverageAnnotator.kt | 4 + .../editor/TestoCoverageEditorHighlighter.kt | 190 ++++++++++++++++++ .../editor/TestoCoverageGutterRenderer.kt | 158 +++++++++++++++ .../resources/messages/TestoBundle.properties | 9 + .../editor/TestoCoverageGutterRendererTest.kt | 44 ++++ 5 files changed, 405 insertions(+) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRendererTest.kt diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt index fc243cf..2d961e3 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAnnotator.kt @@ -1,6 +1,7 @@ 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 @@ -52,6 +53,9 @@ class TestoCoverageAnnotator(project: Project) : RemappingCoverageAnnotator(proj 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( 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 0000000..71b55f8 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt @@ -0,0 +1,190 @@ +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 (arch §4.2). 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() + + private class AnnotatedDocument(val highlighters: MutableList, val listenerDisposable: Disposable) + + /** Idempotent; safe off the EDT. Registers the suite/editor listeners once and reconciles the current state. */ + 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) { + 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 0000000..121fb75 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt @@ -0,0 +1,158 @@ +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.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoTestIdentityMapper +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.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 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`, arch §4.2). 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 (§9). + */ +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 ("Hits: N" under a toolbar): one metric per row, then the covering tests + // right below — a click or Enter navigates. Rows the line has no data for are simply absent. + 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("") { "${it.fqcn.trimStart('\\')}::${it.method}" } + list.selectedIndex = 0 + list.visibleRowCount = minOf(tests.size, 8) + + val panel = JPanel(BorderLayout()) + panel.add(header, BorderLayout.NORTH) + panel.add(JBScrollPane(list), BorderLayout.CENTER) + + 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() + } + }) + popup.show(at) + } + + override fun getTooltipText(): String = statusText() + + override fun getAccessibleName(): String = TestoBundle.message("testo.coverage.editor.accessible.name") + + private fun coveringTests(): List = + TestoCoverageByTestIndex.getInstance(project).data() + .testsCoveringLine(filePath, lineData.lineNumber) + .sortedBy { "${it.fqcn}::${it.method}" } + + 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/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index bd59d8a..981b0d9 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -63,6 +63,15 @@ testo.coverage.action.description.pending=Testo announced this coverage report b testo.coverage.action.description.running=Waiting for the test run to finish testo.coverage.view.column.branches=Branches, % testo.coverage.view.branches.covered={0}% branches covered ({1}/{2}) +testo.coverage.editor.status.full=Line covered +testo.coverage.editor.status.partial=Line partially covered +testo.coverage.editor.status.none=Line not covered +testo.coverage.editor.status.hits={0} hits +testo.coverage.editor.status.branches=branches {0}/{1} +testo.coverage.editor.popup.hits=Hits: {0} +testo.coverage.editor.popup.branches=Branches: {0}/{1} +testo.coverage.editor.popup.tests=Tests: {0} +testo.coverage.editor.accessible.name=Testo code coverage notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin 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 0000000..a26ef86 --- /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))) + } +} From af158694685f21bee11f36287f10d7a30162df1a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 19:53:25 +0400 Subject: [PATCH 16/41] feat(runs): archive every run and replay it as a full Testo console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(coverage): ask Testo for several report formats per run and apply them without a click feat(console): expand/collapse on the visible toolbar row, Test History on our own button fix(console): stop the run clock when the process ends before the toolbar is wired The platform import forces its own console properties and converter, so a reopened run had none of the Testo UI. Since every store here is filled by the converter, recording the raw teamcity stream and feeding it back through the live console rebuilds the run whole — channels, statuses, node tree, report buttons — and the reports and run parameters kept beside it make the reopened tab answer for that run rather than for whatever ran last. Retention is ours because the platform rotates its history with a bare FileUtil.delete, leaving no hook to drop the files we keep alongside. A replayed clock is frozen on the archived marks: replaying re-reports every event, so each mark would be restamped with today's time, and a small archive finishes replaying before the toolbar is wired — which left the elapsed time counting forever. That race also existed for very short live runs, and is closed in the same place. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 ++ CLAUDE.md | 47 +++-- .../testo/coverage/TestoCoverageActivation.kt | 59 ++++-- .../testo/coverage/TestoCoverageAutoApply.kt | 53 ++++++ .../coverage/TestoCoverageProgramRunner.kt | 57 ++++-- .../coverage/TestoCoverageViewActions.kt | 73 +++++++ .../coverage/TestoCoverageViewExtension.kt | 56 +++++- .../editor/TestoCoverageEditorHighlighter.kt | 11 +- .../perTest/TestoCoverageByTestData.kt | 9 + .../xepozz/testo/runs/TestoRunArchiver.kt | 136 +++++++++++++ .../testo/runs/TestoRunHistoryActions.kt | 171 +++++++++++++++++ .../xepozz/testo/runs/TestoRunHistoryGroup.kt | 105 ++++++++++ .../xepozz/testo/runs/TestoRunManifest.kt | 48 +++++ .../xepozz/testo/runs/TestoRunRecording.kt | 96 ++++++++++ .../testo/runs/TestoRunReplayProfile.kt | 179 ++++++++++++++++++ .../github/xepozz/testo/runs/TestoRunStore.kt | 119 ++++++++++++ .../testo/tests/TestoConsoleProperties.kt | 36 +++- .../actions/TestoRerunWithExecutorAction.kt | 7 +- .../tests/console/TestoChannelHistory.kt | 54 ++++-- .../tests/console/TestoConsoleAugmenter.kt | 35 ++-- .../testo/tests/console/TestoHistoryImport.kt | 110 ----------- .../testo/tests/console/TestoHistoryIndex.kt | 59 +++--- .../TestoOutputToGeneralEventsConverter.kt | 27 +++ .../tests/console/TestoProgressAction.kt | 8 +- .../testo/tests/console/TestoReportAction.kt | 107 +++++++++-- .../testo/tests/console/TestoReportStore.kt | 18 +- .../testo/tests/console/TestoRunTimings.kt | 45 ++++- .../tests/console/TestoTreeToolbarActions.kt | 95 ++++++++++ .../testo/tests/run/TestoDebugRunner.kt | 6 + .../testo/tests/run/TestoRunnerSettings.kt | 14 ++ .../run/TestoTestRunConfigurationEditor.kt | 41 ++++ .../ui/TestoHistoryCodeVisionProvider.kt | 62 ++---- src/main/resources/META-INF/plugin.xml | 5 + .../resources/messages/TestoBundle.properties | 23 ++- .../xepozz/testo/TestoReportStoreTest.kt | 18 ++ .../xepozz/testo/TestoRunTimingsTest.kt | 37 ++++ .../coverage/TestoCoverageArgumentsTest.kt | 66 +++++-- .../testo/coverage/TestoCoverageDedupeTest.kt | 45 +++++ .../perTest/TestoPerTestCoverageTest.kt | 8 + .../runs/TestoRunHistoryPresentationTest.kt | 43 +++++ .../xepozz/testo/runs/TestoRunStoreTest.kt | 112 +++++++++++ 41 files changed, 1993 insertions(+), 321 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoHistoryImport.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTreeToolbarActions.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageDedupeTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryPresentationTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a738dda..f200b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,24 @@ ## [Unreleased] +### Added + +- The run configuration chooses which coverage reports a Coverage run asks Testo for: Clover, Cobertura, coverage-xml. +- A Coverage run applies everything it produced on its own, one report per format, without a click. +- 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, a switch for the editor highlighting, badges naming the report formats + behind the shown coverage, and a column counting the tests that cover each file. +- Every run is archived — its output, its reports and the parameters it ran with — and replays from the *Test History* + button as a full Testo console: channels, statuses, report buttons and that run's own coverage. +- *Show history* above a test replays the newest archived run containing that test and selects its node. +- How many archived runs to keep is set in *Tools | Testo*; the history list clears itself from its own menu. +- *Expand All* / *Collapse All* now sit on the toolbar itself rather than inside its overflow menu. + ### Fixed - 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: report paths now resolve off the UI thread. +- The elapsed time in the run summary no longer counts up forever when a run ends before the toolbar is wired. ## [2026.5.262] - 2026-08-12 diff --git a/CLAUDE.md b/CLAUDE.md index 23b4a9d..93a0169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,9 +141,8 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoLogLevelFilterAction.kt # toolbar dropdown for the filter │ │ ├── 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) +│ │ ├── TestoChannelHistory.kt # channel output ⇄ SMTestProxy.metainfo (platform import) + tree-ready polling +│ │ ├── 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 +153,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 @@ -180,6 +180,15 @@ 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 +│ ├── TestoRunHistoryGroup.kt # the "Test History" toolbar button, replacing the platform's +│ └── TestoRunHistoryActions.kt # Tools | Testo: history chooser + retention; the lens's lookups +│ └── ui/ ├── TestoIconProvider.kt # Testo-marked icons for PHP test files ├── TestoHistoryCodeVisionProvider.kt # "Show history" lens above each test @@ -368,10 +377,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 @@ -435,12 +446,26 @@ Non-obvious constraints already paid for in blood — read before touching the r - **`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. +- **History is replayed, not imported.** The platform's import forces `ImportedTestConsoleProperties` and + `ImportedToGeneralTestEventsConverter`, so neither our console nor our converter runs — an imported tab is a + PHPUnit-looking tree with none of our toolbar. Replaying the archived teamcity stream through the *live* + properties (`TestoRunReplayProfile`) rebuilds everything instead, because every store is filled by the converter. + A replay is kept from acting like a run by three switches: `replayMode` (no re-recording), `getConfiguration()` + answering the replay profile (the platform's `addToHistory` saves only for a real `RunConfiguration`), and + `reportStore.startedAtOverride` (the captured report copies must pass the mtime-vs-start gate). +- **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. - **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. +- **`createImportActions` deliberately does not call `super`.** That array is the *only* source of the "Test History" + button above the test tree (`ToolbarPanel` adds nothing else of its own): `SMTRunnerConsoleProperties` returns + `ImportTestsGroup` + `ImportTestsFromFileAction` there, both opening a saved XML through the platform import — a + console with none of our UI. We return `TestoRunHistoryGroup` instead, which lists the run archive and replays it. + Actions without `RunTab.PREFERRED_PLACE = MORE_GROUP` land on the visible toolbar row, so no experimental key is + needed. Dropping `super` also drops the platform's "Import Test Results from file" from Testo tabs. The same array + is how expand/collapse reach the visible row — the platform keeps its own pair inside the overflow group, and + nothing can move or remove them (`ToolbarPanel` builds those groups inline, with no ids and no extension point). - **`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 diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt index 437bedb..0507df0 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt @@ -4,35 +4,56 @@ 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 an already-written Testo coverage report into the IDE with no process launch (architecture §10): build a suite - * bound to the report through our engine, tell it the format, and hand it to the platform, which reads the file via - * [TestoCoverageRunner.loadCoverageData], opens the Coverage tool window and applies [TestoCoverageAnnotator]. + * Loads already-written Testo coverage reports into the IDE with no process launch (architecture §10): 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 suite is **not** registered with [CoverageDataManager] (no `addCoverageSuite`/`addExternalCoverageSuite`): those - * persist it into `workspace.xml`, and the platform's reload then crashes on Windows when the saved absolute report + * 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 gathered-but-unregistered - * suite shows the same annotation, updates the per-test index, and never persists — the report is transient anyway. + * 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`); the format falls - * back to sniffing when unknown. Call on the EDT — `coverageGathered` opens UI. + * 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, name: String?, format: CoverageFormat?, dataFile: Path): Boolean { +fun applyTestoCoverage(project: Project, reports: List): Boolean { + if (reports.isEmpty()) return false val runner = CoverageRunner.getInstance(TestoCoverageRunner::class.java) ?: return false - val manager = CoverageDataManager.getInstance(project) - val timestamp = runCatching { Files.getLastModifiedTime(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(dataFile.toFile()) - val suite = TestoCoverageEngine.INSTANCE - .createCoverageSuite(name ?: "Testo coverage", project, runner, provider, timestamp) as? TestoCoverageSuite - ?: return false - suite.format = format ?: detectCoverageFormat(dataFile) ?: CoverageFormat.CLOVER - manager.coverageGathered(suite) + 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 + 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/TestoCoverageAutoApply.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt new file mode 100644 index 0000000..2758c9e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt @@ -0,0 +1,53 @@ +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() + } + +/** + * The Coverage executor's closing move: once the process exits, resolve every announced coverage report, dedupe by format + * (flags first), respect the grouped button's checkboxes, and apply the survivors as one merged bundle — the same + * [applyTestoCoverage] the button uses, 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() + val chosen = dedupeCoverageByFormat(resolved, flagKeys) + .filter { props.reportStore.isCoverageChecked(it.first.path) } + .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/TestoCoverageProgramRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt index 06af625..8c8d2b5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -1,10 +1,14 @@ package com.github.xepozz.testo.coverage 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.intellij.coverage.CoverageHelper +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.RunProfile import com.intellij.execution.configurations.RunProfileState @@ -54,39 +58,62 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { val interpreter = runConfiguration.interpreter ?: throw ExecutionException(PhpCommandSettingsBuilder.getInterpreterNotFoundError()) + // Kept for its IDE-managed base path alone — loading no longer goes through CoverageHelper. val coverageConfiguration = CoverageEnabledConfiguration.getOrCreate(runConfiguration) val localCoverage = coverageConfiguration.coverageFilePath - val targetCoverage = localCoverage?.takeIf { it.isNotEmpty() }?.let { toTargetPath(runConfiguration, interpreter, it) } + val settings = runConfiguration.testoSettings.getTestoRunnerSettings() + val flags = coverageFlagLocalPaths(settings, localCoverage) + val coverageArguments = 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 command = createTestoCoverageCommand( runConfiguration, interpreter, - createCoverageArguments(targetCoverage), + coverageArguments, localCoverage, - targetCoverage, + localCoverage?.takeIf { it.isNotEmpty() }?.let { toTargetPath(runConfiguration, interpreter, it) }, ) runConfiguration.checkConfiguration() val profileState = runConfiguration.getState(env, command, null) ?: return null val executionResult = profileState.execute(env.executor, this) ?: return null - CoverageHelper.attachToProcess(runConfiguration, executionResult.processHandler, env.runnerSettings) + // 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) + executionResult.processHandler.addProcessListener(object : ProcessAdapter() { + override fun processTerminated(event: ProcessEvent) { + val props = (executionResult.executionConsole as? SMTRunnerConsoleView)?.properties + as? TestoConsoleProperties ?: return + autoApplyCoverage(runConfiguration.project, props, flagDataFiles) + } + }) return RunContentBuilder(executionResult, env).showRunContent(env.contentToReuse) } - // Kept as clover by default; the format→flag map covers the other writers for the "Show coverage" path (arch §5). - fun createCoverageArguments(targetCoverage: String?): List = - coverageArgumentsFor(CoverageFormat.CLOVER, targetCoverage) - - fun coverageArgumentsFor(format: CoverageFormat, targetCoverage: String?): List { - if (targetCoverage.isNullOrEmpty()) return listOf("--coverage") - return when (format) { - CoverageFormat.CLOVER -> listOf("--coverage-clover=$targetCoverage") - CoverageFormat.COBERTURA -> listOf("--coverage-cobertura=$targetCoverage") - CoverageFormat.COVERAGE_XML -> listOf("--coverage-xml=$targetCoverage") + /** 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") } } + 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, 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 0000000..f3ca9e8 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt @@ -0,0 +1,73 @@ +package com.github.xepozz.testo.coverage + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.coverage.editor.TestoCoverageEditorHighlighter +import com.intellij.coverage.CoverageSuitesBundle +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.Presentation +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.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 + +/** + * 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) +} + +/** 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, 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 index 67200d6..d886671 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt @@ -1,22 +1,28 @@ 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.format.TestId +import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys 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 a branch column, shown only for reports that carry branch data — clover records - * `truecount`/`falsecount` per condition, cobertura a `condition-coverage` ratio, and neither is present in a - * line-only report, where the column would read empty for every row. + * 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( - project: Project, + private val project: Project, private val annotator: TestoCoverageAnnotator, suitesBundle: CoverageSuitesBundle, ) : DirectoryCoverageViewExtension(project, annotator, suitesBundle) { @@ -29,6 +35,10 @@ class TestoCoverageViewExtension( val name = TestoBundle.message("testo.coverage.view.column.branches") columns.add(PercentageCoverageColumnInfo(BRANCHES_COLUMN, name, mySuitesBundle)) } + if (hasPerTestData()) { + val testsByFile = TestoCoverageByTestIndex.getInstance(project).data().testsByFile() + if (testsByFile.isNotEmpty()) columns.add(TestsColumnInfo(testsByFile)) + } return columns.toTypedArray() } @@ -38,6 +48,44 @@ class TestoCoverageViewExtension( return annotator.getBranchCoverageInformationString(file, mySuitesBundle) } + // @Experimental (not @Internal) — the one public seam into the view's toolbar; verified present on 252 and 262. + override fun createExtraToolbarActions(): List = listOf( + com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), + com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), + TestoCoverageHighlightToggleAction(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 } + + /** Distinct covering tests: the file's own set, a directory as the union over the files beneath it. */ + private inner class TestsColumnInfo( + private val testsByFile: Map>, + ) : ColumnInfo, String>(TestoBundle.message("testo.coverage.view.column.tests")) { + // One directory is asked for per visible row and per sort comparison — the union walk runs once per path. + private val dirCounts = HashMap() + + override fun valueOf(node: NodeDescriptor<*>): String? = countFor(node)?.takeIf { it > 0 }?.toString() + + override fun getComparator(): Comparator> = compareBy { countFor(it) ?: -1 } + + private fun countFor(node: NodeDescriptor<*>): Int? { + val file = (node as? AbstractTreeNode<*>)?.let { extractFile(it) } ?: return null + val key = TestoCoverageKeys.normalize(file.path) + if (!file.isDirectory) return testsByFile[key]?.size + return dirCounts.getOrPut(key) { + val prefix = "$key/" + testsByFile.entries.asSequence() + .filter { it.key.startsWith(prefix) } + .flatMapTo(HashSet()) { it.value } + .size + } + } + } + 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 index 71b55f8..fe7e83a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt @@ -52,6 +52,15 @@ class TestoCoverageEditorHighlighter(private val project: Project) : Disposable 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. Registers the suite/editor listeners once and reconciles the current state. */ @@ -85,7 +94,7 @@ class TestoCoverageEditorHighlighter(private val project: Project) : Disposable val bundle = CoverageDataManager.getInstance(project).activeSuites() .firstOrNull { it.coverageEngine is TestoCoverageEngine } val gen = ++generation - if (bundle == null) { + if (bundle == null || !highlightingEnabled) { clearAll() return } 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 index b7a9740..2b22140 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt @@ -17,6 +17,9 @@ interface TestoCoverageByTestData { 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()) @@ -30,6 +33,10 @@ internal class MapCoverageByTestData( 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() @@ -44,6 +51,8 @@ internal class MapCoverageByTestData( override fun allTests(): Set = byTest.keys + override fun testsByFile(): Map> = byFile + companion object { fun from(perTest: PerTestCoverage): MapCoverageByTestData { val byLine = HashMap>() 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 0000000..4762e0b --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt @@ -0,0 +1,136 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.coverage.format.CoverageFormat +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.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 + val usedNames = HashSet() + val reports = props.reportStore.all().map { ref -> + val stored = if (ref.isCoverage) { + resolveCoverageDataFile(ref, project, mapToLocal, writtenAfter) + ?.let { capture(recording, ref, it, usedNames) } + } else null + 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), + 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() + + /** Returns the run-dir-relative location of the captured copy, or null when the copy failed. */ + private fun capture(recording: TestoRunRecording, ref: TestoReportRef, local: Path, usedNames: MutableSet): String? = + runCatching { + Files.createDirectories(recording.reportsDir) + val format = ref.coverageFormat + if (format == CoverageFormat.COVERAGE_XML) { + // `local` is the directory's index.xml — the report is the whole directory. + val name = uniqueName(format.id, "", usedNames) + copyDirectory(local.parent, recording.reportsDir.resolve(name)) + "${TestoRunRecording.REPORTS_DIR}/$name" + } else { + val name = uniqueName(format?.id ?: "report", ".xml", usedNames) + 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() + + // Two reports of one format can coexist (a CLI flag beside a testo.php writer) — both are captured. + private fun uniqueName(stem: String, extension: String, used: MutableSet): String { + var candidate = "$stem$extension" + 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 0000000..3b31b80 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -0,0 +1,171 @@ +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.console.TestoTestStatus +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.icons.AllIcons +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.application.ApplicationManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SimpleTextAttributes +import com.intellij.util.text.DateFormatUtil +import java.nio.file.Path +import javax.swing.Icon +import javax.swing.JList + +/** `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 = runKindIcon(runKindOf(manifest.executorId)) + 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 +} + +internal fun runKindIcon(kind: TestoRunKind): Icon = when (kind) { + TestoRunKind.COVERAGE -> AllIcons.General.RunWithCoverage + TestoRunKind.DEBUG -> AllIcons.Actions.StartDebugger + TestoRunKind.RUN -> AllIcons.Actions.Execute +} + +/** 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: replay the newest archived run that actually holds it (not merely the latest run), and + * select that test's node once the tree is rebuilt. Scans the archive off the EDT, launches on it. + */ +internal fun replayNewestRunWithTest(project: Project, url: String) { + val key = normalizeRunLocation(url) + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + val store = TestoRunStore.getInstance(project) + val match = store.listRuns().firstOrNull { (dir, _) -> + store.readLocations(dir).any { it == key || it.startsWith(key) } + } + ApplicationManager.getApplication().invokeLater( + { + if (match == null) { + NotificationGroupManager.getInstance().getNotificationGroup("Testo") + ?.createNotification( + TestoBundle.message("testo.runs.history.none"), + NotificationType.INFORMATION, + ) + ?.notify(project) + return@invokeLater + } + TestoRunReplayProfile.replay(project, match.first, match.second, url) + }, + project.disposed, + ) + } +} + +/** 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 0000000..1305c3e --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt @@ -0,0 +1,105 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.tests.console.TestoHistoryIndex +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.MessageDialogBuilder +import com.intellij.openapi.ui.Messages +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) : 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() + if (runs.isEmpty()) return arrayOf(NoRuns()) + return buildList { + runs.forEach { (dir, manifest) -> add(ReplayRun(project, dir, manifest)) } + add(Separator.getInstance()) + add(ClearHistory(project)) + }.toTypedArray() + } + + private class ReplayRun( + private val project: Project, + private val dir: Path, + private val manifest: TestoRunManifest, + ) : AnAction( + label(dir, manifest), + null, + runKindIcon(runKindOf(manifest.executorId)), + ), DumbAware { + override fun actionPerformed(e: AnActionEvent) = TestoRunReplayProfile.replay(project, dir, manifest) + + private companion object { + fun label(dir: Path, manifest: TestoRunManifest): String { + val name = manifest.configurationName.ifEmpty { dir.fileName.toString() } + val at = DateFormatUtil.formatDateTime(manifest.startedAt) + return "$name — $at ${runResultSummary(manifest)}" + } + } + } + + /** Deletes every archived run of this project — output, reports and all. Asks first: the files are the history. */ + private class ClearHistory(private val project: Project) : AnAction( + TestoBundle.message("testo.runs.history.clear"), + null, + AllIcons.Actions.GC, + ), DumbAware { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) { + val confirmed = MessageDialogBuilder + .yesNo(TestoBundle.message("testo.runs.history.clear"), TestoBundle.message("testo.runs.history.clear.confirm")) + .icon(Messages.getWarningIcon()) + .ask(project) + if (!confirmed) return + ApplicationManager.getApplication().executeOnPooledThread { + if (project.isDisposed) return@executeOnPooledThread + TestoRunStore.getInstance(project).clear() + // Every lens was answered off the archive that just went away. + TestoHistoryIndex.invalidate() + TestoHistoryIndex.refreshLens(project) + } + } + } + + 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 0000000..7373af4 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt @@ -0,0 +1,48 @@ +package com.github.xepozz.testo.runs + +import com.github.xepozz.testo.tests.console.TestoRunTimings + +/** + * 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, +) + +/** + * `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(), + /** [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 0000000..4b1debb --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt @@ -0,0 +1,96 @@ +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.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() + + 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) { + Files.writeString(dir.resolve(MANIFEST_FILE), gson.toJson(manifest), StandardCharsets.UTF_8) + } + + 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() 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 0000000..432cb94 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt @@ -0,0 +1,179 @@ +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.TestoChannelHistory +import com.github.xepozz.testo.tests.console.TestoConsoleAugmenter +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, + private 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 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) as TestoRunConfiguration + 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) + // The report buttons resolve announced paths through this: the run's own captured copies, not whatever the + // next run left at the original path. + props.reportPathOverride = { announced -> + manifest.reports.firstOrNull { it.path == announced && it.stored != null } + ?.stored?.let { runDir.resolve(it).toAbsolutePath().toString() } + } + // 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 { TestoChannelHistory.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() + } + } + + /** + * 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 + val captured = runDir.resolve(stored) + // coverage-xml is a directory; the loader consumes its index. + val dataFile = if (format == CoverageFormat.COVERAGE_XML) captured.resolve("index.xml") else captured + 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 0000000..f5b5197 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -0,0 +1,119 @@ +package com.github.xepozz.testo.runs + +import com.google.gson.Gson +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 (arch §10a): 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), newest first. Touches the filesystem — call off the EDT. */ + fun listRuns(): List> = runDirectories() + .mapNotNull { dir -> readManifest(dir)?.let { dir to it } } + .sortedByDescending { it.second.startedAt } + + fun readManifest(dir: Path): TestoRunManifest? = runCatching { + val file = dir.resolve(TestoRunRecording.MANIFEST_FILE) + if (!file.exists()) return null + gson.fromJson(Files.readString(file, StandardCharsets.UTF_8), TestoRunManifest::class.java) + ?.takeIf { it.v >= 1 } + }.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 complete = ArrayList>() + for (dir in runDirectories()) { + val manifest = readManifest(dir) + if (manifest != null) { + complete += dir to manifest.startedAt + } else if (now - startedAtOf(dir) > INCOMPLETE_GRACE_MS) { + // A directory that never got its manifest: the run crashed or the IDE died mid-write. + delete(dir) + } + } + complete.sortedByDescending { it.second }.drop(keep).forEach { delete(it.first) } + } + + /** Drops every archived run of this project. Touches the filesystem — call off the EDT. */ + fun clear() { + runDirectories().forEach { delete(it) } + } + + 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 7a734e6..e17155d 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,39 @@ 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 + + /** Replay's report resolution: announced path → this run's captured copy. Null on live runs. */ + var reportPathOverride: ((String) -> String?)? = null + // 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 -> + reportPathOverride?.invoke(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() + override fun createTestEventsConverter( testFrameworkName: String, consoleProperties: TestConsoleProperties, @@ -103,7 +129,13 @@ class TestoConsoleProperties( public override fun createImportActions(): Array = arrayOf( com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), - *(super.createImportActions() ?: emptyArray()), + // The platform keeps its own expand/collapse in the toolbar's overflow group; on a test tree they are used + // constantly, so ours sit on the visible row. + com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), + com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), + // 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), // Right-aligned actions are laid out from the right edge inwards: listed first = furthest right. 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 2c38fde..0bd862c 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 @@ -30,9 +30,10 @@ 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 com.github.xepozz.testo.runs.TestoRunReplayProfile -> profile.testoConfiguration else -> null } 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 index 42da48e..36c6199 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt @@ -57,38 +57,44 @@ internal object TestoChannelHistory { } /** - * 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). + * Wire a console built by the platform's own "Import Test Results": once its tree is built, decode every proxy's + * metainfo into a fresh store and install the channel UI. Our own history goes through the run archive instead + * (`com.github.xepozz.testo.runs`), which replays the real stream and needs none of this. */ - fun installForImport(project: Project, console: SMTRunnerConsoleView, targetUrl: String?) { + fun installForImport(project: Project, console: SMTRunnerConsoleView) { // 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() + whenTreeStable(console) { root -> + 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) + } + } + + /** + * Select the node of [url] once the tree has finished building — for a replayed archive, where the tree is still + * filling while the recorded output streams 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) { 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) } - } - } + action(root) return } lastCount = count @@ -97,6 +103,12 @@ internal object TestoChannelHistory { 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 + ApplicationManager.getApplication().invokeLater { form.selectAndNotify(match) } + } + private fun countDescendants(node: SMTestProxy): Int { var n = 0 for (child in node.children) n += 1 + countDescendants(child) 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 a8c260c..e5ec320 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. @@ -21,17 +21,13 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { 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. + // Live run — and a replayed archive, which runs on the same properties, so it gets the same UI. 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) + // ImportedTestConsoleProperties; rebuild channels from the metainfo the run stored on each proxy. + isImportedConsole(props) -> TestoChannelHistory.installForImport(project, console) } } } @@ -40,13 +36,10 @@ 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) + 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) } } @@ -124,13 +117,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 44f10fd..0000000 --- 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 3dfe020..8121f95 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,59 +1,56 @@ package com.github.xepozz.testo.tests.console +import com.github.xepozz.testo.runs.TestoRunStore 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 index is rebuilt on a pooled thread and never blocks: [contains] answers from the last snapshot while a rebuild + * is in flight. It is invalidated by exactly one event — an archived run becoming complete + * ([com.github.xepozz.testo.runs.TestoRunArchiver], which also prunes) — so the lookup itself touches no filesystem. */ 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). */ + /** The archive changed: rebuild on the next lookup. */ + fun invalidate() { + generation.incrementAndGet() + } + + /** 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 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 + val current = generation.get() + val snapshot = cache[key] + if (snapshot == null || snapshot.generation != current) scheduleRebuild(project, key, current) + val urls = snapshot?.urls ?: cache[key]?.urls ?: return false return url in urls || urls.any { it.startsWith(url) } } - // 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() - - private fun scheduleRebuild(project: Project, key: String, files: List, stamp: Long) { + private fun scheduleRebuild(project: Project, key: String, generation: Long) { if (!building.add(key)) return ApplicationManager.getApplication().executeOnPooledThread { try { + if (project.isDisposed) return@executeOnPooledThread + val store = TestoRunStore.getInstance(project) 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() + store.listRuns().forEach { (dir, _) -> urls.addAll(store.readLocations(dir)) } + // Only restart the daemon when the lenses would actually change: a rebuild also runs on the very first + // lookup, and restarting then interrupts an in-flight highlighting pass for nothing. + val previous = cache.put(key, Snapshot(generation, urls))?.urls ?: emptySet() if (previous != urls) refreshLens(project) } finally { building.remove(key) 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 fcd8860..74a41ea 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,6 +1,9 @@ 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 @@ -41,13 +44,35 @@ 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 + 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) } + 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. + private fun recordChunk(text: String, outputType: Key<*>) { + val props = testoProperties ?: return + if (props.replayMode) return + val recording = props.recording ?: synchronized(props) { + props.recording ?: runCatching { + TestoRunStore.getInstance(props.project).beginRun(props.configuration.name, props.executor.id) + }.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) } + } + override fun processServiceMessage(message: ServiceMessage, visitor: ServiceMessageVisitor) { val attrs = message.attributes @@ -76,6 +101,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) 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 9cee92d..c928e10 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/TestoReportAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt index fe39adb..cd34d22 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,7 +1,10 @@ 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 @@ -13,6 +16,7 @@ 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 @@ -68,7 +72,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 val coverageCells = LinkedHashMap() + private var coverageCell: CoverageGroupCell? = null private val timer = Timer(REFRESH_MS) { tick() } init { @@ -134,14 +138,13 @@ class TestoReportsAction( cells.values.forEach { it.refresh() } val coverage = reports.coverage() - coverage.forEach { ref -> - coverageCells.getOrPut(ref.path) { CoverageReportCell(ref).also { add(it) } }.ref = ref + val cell = when { + coverage.isEmpty() -> coverageCell?.let { remove(it); coverageCell = null; null } + else -> coverageCell ?: CoverageGroupCell().also { add(it); coverageCell = it } } - val coverageGone = coverageCells.keys - coverage.mapTo(HashSet()) { it.path } - coverageGone.forEach { path -> coverageCells.remove(path)?.let { remove(it) } } - coverageCells.values.forEach { it.refresh() } + cell?.refresh(coverage) - isVisible = cells.isNotEmpty() || coverageCells.isNotEmpty() + isVisible = cells.isNotEmpty() || coverageCell != null // Re-laid out only when the row changed shape — this runs twice a second. val width = preferredSize.width @@ -363,10 +366,14 @@ class TestoReportsAction( OpenReportGroup(TestoBundle.message(key), icon, way, { ref }, project, reports, ::openOrArm) } - /** "Show coverage": applies an already-written coverage report to the editor with no rerun — no dropdown needed. */ - private inner class CoverageReportCell(ref: TestoReportRef) : JComponent() { - var ref: TestoReportRef = ref - private var located: Path? = null + /** + * 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 @@ -381,17 +388,20 @@ class TestoReportsAction( override fun mouseEntered(e: MouseEvent) { hovered = true; repaint() } override fun mouseExited(e: MouseEvent) { hovered = false; repaint() } override fun mouseClicked(e: MouseEvent) { - located?.let { applyTestoCoverage(project, ref.name, ref.coverageFormat, it) } + if (e.x >= width - arrowZone()) showMenu() else applyChecked() } }) } - private fun text(): String = ref.name ?: TestoBundle.message("testo.coverage.action.text") + private fun text(): String = TestoBundle.message("testo.coverage.group.text") - fun refresh() { + private fun arrowZone(): Int = ARROW.iconWidth + GAP + PADDING + + fun refresh(coverage: List) { + refs = coverage val finished = reports.runFinished if (!finished) { - applyResolved(null, false) + applyResolved(emptyMap(), false) return } // resolveCoverageDataFile goes through the PHP path mapper and touches the filesystem — forbidden on the @@ -399,9 +409,12 @@ class TestoReportsAction( if (resolving) return resolving = true val startedAt = reports.runStartedAt - val cellRef = ref + val snapshot = coverage ApplicationManager.getApplication().executeOnPooledThread { - val found = resolveCoverageDataFile(cellRef, project, mapToLocal, startedAt) + val found = LinkedHashMap() + for (ref in snapshot) { + resolveCoverageDataFile(ref, project, mapToLocal, startedAt)?.let { found[ref.path] = it } + } ApplicationManager.getApplication().invokeLater( { resolving = false @@ -412,22 +425,73 @@ class TestoReportsAction( } } - private fun applyResolved(found: Path?, finished: Boolean) { + private fun applyResolved(found: Map, finished: Boolean) { if (refreshed && found == located && finished == runWasFinished) return refreshed = true located = found runWasFinished = finished toolTipText = when { - found != null -> TestoBundle.message("testo.coverage.action.description") + 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()) + PADDING + 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) } @@ -444,12 +508,13 @@ class TestoReportsAction( val arc = JBUI.scale(6) g2.fillRoundRect(0, 0, width, height, arc, arc) } - val icon = if (located != null) COVERAGE_ICON else COVERAGE_PENDING_ICON + 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() } 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 1f1ce10..4ab7520 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 @@ -74,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 @@ -92,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() @@ -139,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 @@ -147,6 +162,7 @@ class TestoReportStore { autoOpenThisRun.clear() autoOpenMuted.clear() } + synchronized(coverageUnchecked) { coverageUnchecked.clear() } } fun all(): List = synchronized(reports) { reports.values.toList() } 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 c49d527..11fce9c 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/TestoTreeToolbarActions.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoTreeToolbarActions.kt new file mode 100644 index 0000000..f7d4a10 --- /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 0bb890f..61de2df 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 @@ -79,6 +79,12 @@ 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() { override fun start(session: XDebugSession): XDebugProcess { diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt index 814f14f..d33e869 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt @@ -28,6 +28,17 @@ class TestoRunnerSettings( @Attribute("testo_type") var testoType: String = "", + + // Which coverage reports a Coverage run requests via CLI flags. Clover is off by default: cobertura carries + // everything clover does plus branch data. coverage-xml adds the per-test overlay. + @Attribute("coverage_clover") + var coverageClover: Boolean = false, + + @Attribute("coverage_cobertura") + var coverageCobertura: Boolean = true, + + @Attribute("coverage_xml") + var coverageXml: Boolean = true, ) : PhpTestRunnerSettings() { /** 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) @@ -107,6 +118,9 @@ class TestoRunnerSettings( runnerSettings.repeat = settings.repeat runnerSettings.parallel = settings.parallel runnerSettings.testoType = settings.testoType + runnerSettings.coverageClover = settings.coverageClover + runnerSettings.coverageCobertura = settings.coverageCobertura + runnerSettings.coverageXml = settings.coverageXml runnerSettings.migrateLegacyNames() } 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 7056982..f31a71b 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 @@ -4,6 +4,7 @@ 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 @@ -36,6 +37,16 @@ class TestoTestRunConfigurationEditor( } } } + private val coverageCloverBox = JBCheckBox("Clover") + private val coverageCoberturaBox = JBCheckBox("Cobertura") + private val coverageXmlBox = JBCheckBox("coverage-xml") + + // Held disabled until Testo grows a level flag (cobertura already raises the level to Branch on its own). + private val coverageLevelField = ComboBox(arrayOf("Line", "Branch", "Path")).apply { + selectedItem = "Line" + isEnabled = false + toolTipText = "Coverage level selection is not supported by Testo yet" + } private val myMainPanel = panel { row { @@ -104,6 +115,24 @@ class TestoTestRunConfigurationEditor( } .layout(RowLayout.PARENT_GRID) .rowComment("Engine used to collect code coverage") + + row { + label("Coverage 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("Coverage level") + .gap(RightGap.COLUMNS) + cell(coverageLevelField) + } + .layout(RowLayout.PARENT_GRID) + .rowComment("Not supported by Testo yet; Cobertura raises the level to Branch on its own") } } @@ -120,6 +149,9 @@ class TestoTestRunConfigurationEditor( repeatField.addChangeListener { listener() } parallelField.addChangeListener { listener() } coverageEngineField.addActionListener { listener() } + coverageCloverBox.addActionListener { listener() } + coverageCoberturaBox.addActionListener { listener() } + coverageXmlBox.addActionListener { listener() } } override fun createEditor(): JComponent = myMainPanel @@ -133,6 +165,9 @@ class TestoTestRunConfigurationEditor( || (repeatField.value as Int) != runner.repeat || (parallelField.value as Int) != runner.parallel || coverageEngineField.selectedItem != runner.coverageEngine + || coverageCloverBox.isSelected != runner.coverageClover + || coverageCoberturaBox.isSelected != runner.coverageCobertura + || coverageXmlBox.isSelected != runner.coverageXml || parentEditor.isSpecificallyModified } @@ -145,6 +180,9 @@ class TestoTestRunConfigurationEditor( repeatField.value = runnerSettings.repeat parallelField.value = runnerSettings.parallel coverageEngineField.selectedItem = runnerSettings.coverageEngine + coverageCloverBox.isSelected = runnerSettings.coverageClover + coverageCoberturaBox.isSelected = runnerSettings.coverageCobertura + coverageXmlBox.isSelected = runnerSettings.coverageXml parentEditor.javaClass.declaredMethods.find { it.name == "resetEditorFrom" && it.parameterCount == 1 }?.let { it.isAccessible = true @@ -175,6 +213,9 @@ class TestoTestRunConfigurationEditor( runnerSettings.repeat = repeatField.value as? Int ?: 0 runnerSettings.parallel = parallelField.value as? Int ?: 0 runnerSettings.coverageEngine = coverageEngineField.selectedItem as? CoverageEngine ?: CoverageEngine.XDEBUG + runnerSettings.coverageClover = coverageCloverBox.isSelected + runnerSettings.coverageCobertura = coverageCoberturaBox.isSelected + runnerSettings.coverageXml = coverageXmlBox.isSelected } companion object { 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 7877b72..80b0b3b 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.replayNewestRunWithTest 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 replays the newest archived run containing that + * test 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,17 @@ 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 + // Show the lens only for a test some archived run can replay. + 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 +71,8 @@ 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) + // The most recent run that actually holds this test, not merely the globally latest one. + replayNewestRunWithTest(element.project, url) } /** @@ -104,7 +101,7 @@ class TestoHistoryCodeVisionProvider : CodeVisionProviderBase() { onClick, TestoIcons.TESTO, hint, - "Open the latest test run history", + "Replay the newest archived run containing this test", ) ) } @@ -112,30 +109,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/plugin.xml b/src/main/resources/META-INF/plugin.xml index 1cf8db5..16a56ca 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -152,6 +152,11 @@ class="com.github.xepozz.testo.tests.actions.TestoRerunStyleMirrorAction"/> + + + ` 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 nonEmptyPathProducesCloverFlag() { - assertEquals(listOf("--coverage-clover=/tmp/report@cfg.xml"), createCoverageArguments("/tmp/report@cfg.xml")) + fun noBasePathMeansNoFlags() { + assertTrue(coverageFlagLocalPaths(TestoRunnerSettings(), null).isEmpty()) + assertTrue(coverageFlagLocalPaths(TestoRunnerSettings(), "").isEmpty()) } @Test - fun nullPathFallsBackToBareCoverage() { - assertEquals(listOf("--coverage"), createCoverageArguments(null)) + fun everyFormatDisabledMeansNoFlags() { + val settings = TestoRunnerSettings(coverageCobertura = false, coverageXml = false) + assertTrue(coverageFlagLocalPaths(settings, base).isEmpty()) } @Test - fun emptyPathFallsBackToBareCoverage() { - assertEquals(listOf("--coverage"), createCoverageArguments("")) + fun pathWithSpacesIsKeptVerbatimInSingleFlag() { + assertEquals( + "--coverage-cobertura=/path with space/r-cobertura.xml", + coverageFlagFor(CoverageFormat.COBERTURA, "/path with space/r-cobertura.xml"), + ) } @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 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 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 0000000..a6e461d --- /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/perTest/TestoPerTestCoverageTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt index c19c21c..80fbf36 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt @@ -53,6 +53,14 @@ class TestoPerTestCoverageTest { 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 filterSelectorHasLeadingBackslashOnce() { val mapper = TestoTestIdentityMapper.getInstance() 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 0000000..b550321 --- /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 0000000..9c0e17e --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt @@ -0,0 +1,112 @@ +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 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 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()) + } +} From 5f98118fe3b7d41e21f5cea850534139b1200a1e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 20:41:03 +0400 Subject: [PATCH 17/41] feat(console): rearrange the test toolbar and make history tabs act like the run they hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(runs): archive only the deduped coverage reports and replay from them alone feat(console): rerun a history tab with the executor its run used The platform builds the test toolbar with no seam to reorder it — the sort popup and the overflow group are created inline, without ids or an extension point, and a snapshot of the visible group goes to RunTab. So the only handle on the group the user sees is from inside it: an invisible action rides the same toolbar and moves the sort popup into the overflow group and the platform's expand/collapse out of it, matching structurally so a reshuffled platform leaves it a no-op. A replay's reports now come from the archive rather than from the announcements in the log it replays: those name paths a later run has overwritten. What is archived is the same one-per-format pick the live apply makes — the coverage runner hands its flag paths to the console properties, so the two cannot disagree — and a report that lost the pick is recorded but not offered. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +- CLAUDE.md | 12 +- .../coverage/TestoCoverageProgramRunner.kt | 7 +- .../xepozz/testo/runs/TestoRunArchiver.kt | 14 +- .../testo/runs/TestoRunReplayProfile.kt | 33 ++++- .../testo/tests/TestoConsoleProperties.kt | 25 ++-- .../actions/TestoRerunWithExecutorAction.kt | 100 +++++++++----- .../TestoOutputToGeneralEventsConverter.kt | 8 +- .../testo/tests/console/TestoReportAction.kt | 4 +- .../testo/tests/console/TestoToolbarLayout.kt | 125 ++++++++++++++++++ 10 files changed, 272 insertions(+), 60 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f200b19..e98a5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ button as a full Testo console: channels, statuses, report buttons and that run's own coverage. - *Show history* above a test replays the newest archived run containing that test and selects its node. - How many archived runs to keep is set in *Tools | Testo*; the history list clears itself from its own menu. -- *Expand All* / *Collapse All* now sit on the toolbar itself rather than inside its overflow menu. +- *Expand All* / *Collapse All* now sit on the toolbar itself rather than inside its overflow menu, and the sort + button moves the other way, into that menu. +- A tab opened from the history reruns with the executor the archived run used: a coverage run reruns with coverage. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 93a0169..87d1afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── 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 +│ │ ├── TestoToolbarLayout.kt # moves the platform's sort popup into the toolbar's overflow group │ │ ├── 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 @@ -464,8 +465,15 @@ Non-obvious constraints already paid for in blood — read before touching the r console with none of our UI. We return `TestoRunHistoryGroup` instead, which lists the run archive and replays it. Actions without `RunTab.PREFERRED_PLACE = MORE_GROUP` land on the visible toolbar row, so no experimental key is needed. Dropping `super` also drops the platform's "Import Test Results from file" from Testo tabs. The same array - is how expand/collapse reach the visible row — the platform keeps its own pair inside the overflow group, and - nothing can move or remove them (`ToolbarPanel` builds those groups inline, with no ids and no extension point). + is how expand/collapse reach the visible row. The array is laid out right-to-left (listed first = furthest right), + which is the only control over placement there — everything in it lands after the platform's own actions. +- **`TestoToolbarLayoutAction` rearranges the platform's toolbar from inside it.** `ToolbarPanel` builds the sort + popup and the overflow group inline — no ids, no extension point, no `CustomActionsSchema` entry — and hands a + snapshot of the visible group to `RunTab`, so the group the user sees is reachable only from an action sitting in + it. This invisible action walks up to the toolbars around it (a bounded number of update passes, then it gives up + for good) and moves the sort popup into the overflow group and the platform's expand/collapse out of it. Matched + structurally — a popup group holding `SortByDurationAction`, a group class named `MoreActionGroup`, the + expand/collapse icons — so a platform reshuffle makes it a no-op rather than a breakage. - **`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 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 8c8d2b5..b9a1343 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -87,11 +87,12 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { // 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) { - val props = (executionResult.executionConsole as? SMTRunnerConsoleView)?.properties - as? TestoConsoleProperties ?: return - autoApplyCoverage(runConfiguration.project, props, flagDataFiles) + autoApplyCoverage(runConfiguration.project, props ?: return, flagDataFiles) } }) return RunContentBuilder(executionResult, env).showRunContent(env.contentToReuse) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt index 4762e0b..3022ef6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt @@ -1,6 +1,8 @@ package com.github.xepozz.testo.runs +import com.github.xepozz.testo.coverage.dedupeCoverageByFormat 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.TestoHistoryIndex import com.github.xepozz.testo.tests.console.TestoReportRef @@ -37,12 +39,16 @@ internal object TestoRunArchiver { recording.closeOutput() val mapToLocal: (String) -> String? = { runCatching { props.pathMapper.getLocalPath(it) }.getOrNull() } val writtenAfter = props.reportStore.runStartedAt + // Only what survives the one-per-format dedup is copied: it is the report the run actually applied, + // and a replay reads the archive as the whole truth about this run's coverage. + 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 stored = if (ref.isCoverage) { - resolveCoverageDataFile(ref, project, mapToLocal, writtenAfter) - ?.let { capture(recording, ref, it, usedNames) } - } else null + val stored = winners[ref]?.let { capture(recording, ref, it, usedNames) } StoredReport(ref.format, ref.name, ref.path, ref.relativePath, stored) } recording.writeLocations() diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt index 432cb94..1da7384 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt @@ -9,6 +9,7 @@ import com.github.xepozz.testo.coverage.format.CoverageFormat import com.github.xepozz.testo.tests.TestoConsoleProperties import com.github.xepozz.testo.tests.console.TestoChannelHistory import com.github.xepozz.testo.tests.console.TestoConsoleAugmenter +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 @@ -53,6 +54,9 @@ internal class TestoRunReplayProfile( 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 @@ -83,12 +87,7 @@ internal class TestoRunReplayProfile( val marks = manifest.timings.takeIf { !it.isEmpty } ?: TestoRunTimings.Marks(startedAt = manifest.startedAt, finishedAt = manifest.finishedAt) if (!marks.isEmpty) props.runTimings.restore(marks) - // The report buttons resolve announced paths through this: the run's own captured copies, not whatever the - // next run left at the original path. - props.reportPathOverride = { announced -> - manifest.reports.firstOrNull { it.path == announced && it.stored != null } - ?.stored?.let { runDir.resolve(it).toAbsolutePath().toString() } - } + 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 -> @@ -123,6 +122,28 @@ internal class TestoRunReplayProfile( } } + /** + * 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 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 e17155d..e3ca742 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -70,13 +70,13 @@ class TestoConsoleProperties( // or every replay would spawn a new platform-history entry (and re-write per-test states). var replayProfile: com.intellij.execution.configurations.RunProfile? = null - /** Replay's report resolution: announced path → this run's captured copy. Null on live runs. */ - var reportPathOverride: ((String) -> String?)? = 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) { path -> - reportPathOverride?.invoke(path) ?: pathMapper.getLocalPath(path) - } + 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. @@ -128,15 +128,20 @@ class TestoConsoleProperties( // the standalone debug console toolbar too. public override fun createImportActions(): Array = arrayOf( + // Laid out from the right edge inwards: listed first = furthest right. So this array reads right to left — + // the log-level filter sits at the right end of the group, expand/collapse at its left, next to the + // separator that follows Show Passed / Show Ignored. com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), - // The platform keeps its own expand/collapse in the toolbar's overflow group; on a test tree they are used - // constantly, so ours sit on the visible row. - com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), - com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), // 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), - // Right-aligned actions are laid out from the right edge inwards: listed first = furthest right. + com.intellij.openapi.actionSystem.Separator.getInstance(), + // The platform keeps its own expand/collapse in the toolbar's overflow group; on a test tree they are used + // constantly, so ours sit on the visible row (and the platform's are taken out of the overflow below). + com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), + com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), + // Invisible: it is here only to reach the toolbar it is added to. See TestoToolbarLayoutAction. + com.github.xepozz.testo.tests.console.TestoToolbarLayoutAction(), 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 0bd862c..d5bff21 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 @@ -33,12 +34,52 @@ internal fun ExecutionEnvironment.testoRunProfile(): RunProfile? = // 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 com.github.xepozz.testo.runs.TestoRunReplayProfile -> profile.testoConfiguration + 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). Null when the environment names no executor we can run. + */ +internal fun ExecutionEnvironment.testoRerunExecutorId(): String? { + val archived = (runProfile as? TestoRunReplayProfile)?.executorId + ?.takeIf { ExecutorRegistry.getInstance().getExecutorById(it) != null } + return archived ?: executor.id +} + +/** Whether this tab is a replayed archive — a rerun there launches the tests rather than replaying the log again. */ +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) +} + open class TestoRerunWithExecutorAction( text: String, icon: Icon, @@ -68,27 +109,7 @@ open class TestoRerunWithExecutorAction( override fun actionPerformed(e: AnActionEvent) { 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) } } @@ -121,13 +142,33 @@ class TestoRerunCurrentAction : AnAction(), DumbAware { 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(environment) } - override fun actionPerformed(e: AnActionEvent) { - val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return - ExecutionManager.getInstance(environment.project).restartRunProfile(environment) + override fun actionPerformed(e: AnActionEvent) = rerunCurrent(e) +} + +/** The icon of the executor a rerun would use — the archived one on a replayed tab, this tab's otherwise. */ +internal fun rerunIcon(environment: ExecutionEnvironment): Icon { + val executorId = environment.testoRerunExecutorId() + return executorId?.let { ExecutorRegistry.getInstance().getExecutorById(it)?.icon } + ?: environment.executor.icon + ?: AllIcons.Actions.Restart +} + +/** + * 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() + val executorId = environment.testoRerunExecutorId() + if (environment.isTestoReplay() && target != null && executorId != null) { + relaunchTesto(e, environment, target, executorId) + return } + ExecutionManager.getInstance(environment.project).restartRunProfile(environment) } class TestoRerunSplitButtonAction : SplitButtonAction(buildExecutorGroup()) { @@ -187,11 +228,8 @@ class TestoAwareRerunAction : AnAction(), DumbAware { return } e.presentation.isEnabledAndVisible = true - e.presentation.icon = environment.executor.icon ?: AllIcons.Actions.Restart + e.presentation.icon = rerunIcon(environment) } - override fun actionPerformed(e: AnActionEvent) { - val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return - ExecutionManager.getInstance(environment.project).restartRunProfile(environment) - } + override fun actionPerformed(e: AnActionEvent) = rerunCurrent(e) } 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 74a41ea..66c4d44 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 @@ -47,10 +47,12 @@ class TestoOutputToGeneralEventsConverter( 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 + 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) } @@ -144,7 +146,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/TestoReportAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt index cd34d22..0824f9f 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 @@ -532,7 +532,9 @@ class TestoReportsAction( 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. - private val COVERAGE_ICON: Icon = AllIcons.General.RunWithCoverage + // 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. diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt new file mode 100644 index 0000000..c2b4243 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt @@ -0,0 +1,125 @@ +package com.github.xepozz.testo.tests.console + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Constraints +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.project.DumbAware +import com.intellij.util.ui.UIUtil +import java.awt.Container +import javax.swing.JComponent + +/** + * Rearranges the test toolbar the platform built: the sort popup moves into the overflow ("burger") group, and the + * platform's own expand/collapse leave it — ours sit on the visible row now + * ([com.github.xepozz.testo.tests.TestoConsoleProperties.createImportActions]). + * + * There is no seam for this. `ToolbarPanel` creates both groups inline, with no action ids, no extension point and no + * `CustomActionsSchema` entry, and hands a snapshot of the visible one to `RunTab` — so the only handle on the group + * the user actually sees is from *inside* it. Hence this: an invisible action that rides the same toolbar and, the + * first few times it is asked to update, walks the toolbars around it and moves the two things. + * + * Deliberately best-effort, and matched structurally rather than by name — a popup group holding + * `SortByDurationAction`, a group whose class is `MoreActionGroup`, actions wearing the expand/collapse icons — so it + * survives translation and renaming, and does nothing at all (rather than breaking the toolbar) once the platform's + * layout changes shape. + */ +internal class TestoToolbarLayoutAction : AnAction(), DumbAware { + + private var done = false + private var attempts = 0 + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = false + // The toolbar updates twice a second; this walks components, so it runs only until it lands (the toolbar may + // not be assembled on the first pass) and then gives up for good. + if (done || attempts >= MAX_ATTEMPTS) return + attempts++ + val component = e.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT) as? Container ?: return + var ancestor: Container? = component + var hops = 0 + while (ancestor != null && hops < ANCESTOR_LIMIT) { + (ancestor as? JComponent)?.let { root -> + UIUtil.uiTraverser(root).traverse().forEach { candidate -> + if (candidate is ActionToolbar && rearrange(candidate)) done = true + } + } + ancestor = ancestor.parent + hops++ + } + } + + override fun actionPerformed(e: AnActionEvent) = Unit + + /** True once this toolbar has been rearranged — i.e. it was the one holding the platform's groups. */ + private fun rearrange(toolbar: ActionToolbar): Boolean = runCatching { + val group = actionGroupOf(toolbar) ?: return false + val more = find(group, 0) { it.javaClass.simpleName == MORE_GROUP } as? DefaultActionGroup ?: return false + moveSortIntoMoreGroup(group, more) + dropExpandCollapse(more) + true + }.getOrDefault(false) + + /** The toolbar's group, read by name: the accessor lives on the implementation, which is not ours to reference. */ + private fun actionGroupOf(toolbar: ActionToolbar): ActionGroup? = + runCatching { toolbar.javaClass.getMethod("getActionGroup").invoke(toolbar) as? ActionGroup }.getOrNull() + + private fun moveSortIntoMoreGroup(root: ActionGroup, more: DefaultActionGroup) { + val sort = find(root, 0) { it is ActionGroup && it.isPopup && holds(it, SORT_MARKER) } ?: return + val owner = parentOf(root, sort, 0) as? DefaultActionGroup ?: return + if (owner === more) return + owner.remove(sort) + more.add(sort, Constraints.FIRST) + } + + private fun dropExpandCollapse(more: DefaultActionGroup) { + more.getChildActionsOrStubs() + .filter { it.templatePresentation.icon.let { icon -> icon === EXPAND_ICON || icon === COLLAPSE_ICON } } + .forEach { more.remove(it) } + } + + private fun holds(group: ActionGroup, markerClassName: String): Boolean = + children(group).any { it.javaClass.simpleName == markerClassName } + + private fun find(group: ActionGroup, depth: Int, predicate: (AnAction) -> Boolean): AnAction? { + if (depth > DEPTH_LIMIT) return null + for (child in children(group)) { + if (predicate(child)) return child + if (child is ActionGroup) find(child, depth + 1, predicate)?.let { return it } + } + return null + } + + private fun parentOf(group: ActionGroup, child: AnAction, depth: Int): ActionGroup? { + if (depth > DEPTH_LIMIT) return null + for (candidate in children(group)) { + if (candidate === child) return group + if (candidate is ActionGroup) parentOf(candidate, child, depth + 1)?.let { return it } + } + return null + } + + // Only the groups we can read without asking: `ActionGroup.getChildren` is @OverrideOnly, so calling it is out — + // and every group on this path is a DefaultActionGroup anyway (`RunTab.ToolbarActionGroup` copies its delegate's + // children into itself). Stubs are fine: everything matched here is a real instance the toolbar was built with. + private fun children(group: ActionGroup): Array = + (group as? DefaultActionGroup)?.getChildActionsOrStubs() ?: AnAction.EMPTY_ARRAY + + private companion object { + private const val MORE_GROUP = "MoreActionGroup" + private const val SORT_MARKER = "SortByDurationAction" + private const val DEPTH_LIMIT = 3 + private const val ANCESTOR_LIMIT = 12 + private const val MAX_ATTEMPTS = 20 + + private val EXPAND_ICON = AllIcons.Actions.Expandall + private val COLLAPSE_ICON = AllIcons.Actions.Collapseall + } +} From b78399d6b59192605278148db371f604dd8824c1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 22:14:02 +0400 Subject: [PATCH 18/41] =?UTF-8?q?feat(runs):=20a=20Replay=20group=20per=20?= =?UTF-8?q?run=20=E2=80=94=20export,=20import,=20and=20what=20history=20ma?= =?UTF-8?q?y=20do=20with=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(runs): archive every report a run announces, not just its coverage The archive was only ever going to be as useful as it is complete: an HTML report is as much part of a run as its coverage, so everything announced is captured now. Testo announces a report by its entry file and lays it out either as that file alone or as a directory beside its assets (docs/spec/html-report.md), so an `index.*` entry means the directory travels whole and the manifest names the entry inside the copy — which also folds coverage-xml into the same rule instead of a special case. The one report deliberately left out is a coverage one that lost the per-format dedup: same data under a second path, already ignored by the bundle that was applied. Clearing the history spares the run the open tab is built on — it is marked discarded rather than deleted, so the tab keeps its files and choosing Keep there brings it back — and asks whether locked runs are included rather than assuming. "Locked" rather than "pinned" because the list draws it with a lock; archives written under the old spelling still read. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 + CLAUDE.md | 2 + .../xepozz/testo/runs/TestoReplayGroup.kt | 177 ++++++++++++++++++ .../xepozz/testo/runs/TestoRunArchive.kt | 78 ++++++++ .../xepozz/testo/runs/TestoRunArchiver.kt | 40 ++-- .../testo/runs/TestoRunHistoryActions.kt | 14 +- .../xepozz/testo/runs/TestoRunHistoryGroup.kt | 64 +++++-- .../xepozz/testo/runs/TestoRunManifest.kt | 15 ++ .../xepozz/testo/runs/TestoRunRecording.kt | 4 + .../testo/runs/TestoRunReplayProfile.kt | 12 +- .../github/xepozz/testo/runs/TestoRunStore.kt | 87 +++++++-- .../testo/tests/TestoConsoleProperties.kt | 8 +- .../resources/messages/TestoBundle.properties | 19 +- .../xepozz/testo/runs/TestoRunArchiveTest.kt | 94 ++++++++++ .../xepozz/testo/runs/TestoRunStoreTest.kt | 7 + 15 files changed, 576 insertions(+), 50 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchive.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/runs/TestoRunArchiveTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e98a5f1..a55df87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ - *Expand All* / *Collapse All* now sit on the toolbar itself rather than inside its overflow menu, and the sort button moves the other way, into that menu. - A tab opened from the history reruns with the executor the archived run used: a coverage run reruns with coverage. +- A *Replay* button on the test toolbar exports the run as a single archive and imports one back, and says what the + history may do with it: keep it, drop it, or lock it so retention never touches it. +- Every report a run announces is archived with it — an HTML report travels with its assets — so a replayed run opens + its own reports rather than whatever the latest run left behind. +- The history list marks the run the tab is showing in bold, and puts a lock on the locked ones. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 87d1afd..2f61a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,8 @@ src/main/kotlin/com/github/xepozz/testo/ │ ├── 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": export / keep-discard-pin / import, for this tab's run │ ├── TestoRunHistoryGroup.kt # the "Test History" toolbar button, replacing the platform's │ └── TestoRunHistoryActions.kt # Tools | Testo: history chooser + retention; the lens's lookups │ 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 0000000..f985930 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt @@ -0,0 +1,177 @@ +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.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.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.Actions.Play_forward }, +), DumbAware { + + init { + isPopup = true + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun getChildren(e: AnActionEvent?): Array = arrayOf( + ExportAction(), + 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(), + ImportAction(), + ) + + /** 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() + + 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, + ) + } + } + } + + /** 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) + } + } + + private fun current(): RunRetention = + props.recording?.retention + ?: runDir()?.let { TestoRunStore.getInstance(project).retentionOf(it) } + ?: 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 0000000..75a661f --- /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 index 3022ef6..5acb4f7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt @@ -8,6 +8,7 @@ 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 @@ -39,8 +40,10 @@ internal object TestoRunArchiver { recording.closeOutput() val mapToLocal: (String) -> String? = { runCatching { props.pathMapper.getLocalPath(it) }.getOrNull() } val writtenAfter = props.reportStore.runStartedAt - // Only what survives the one-per-format dedup is copied: it is the report the run actually applied, - // and a replay reads the archive as the whole truth about this run's coverage. + // 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 } } @@ -48,7 +51,11 @@ internal object TestoRunArchiver { val winners = dedupeCoverageByFormat(resolved, flagKeys).toMap() val usedNames = HashSet() val reports = props.reportStore.all().map { ref -> - val stored = winners[ref]?.let { capture(recording, ref, it, usedNames) } + 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() @@ -62,6 +69,7 @@ internal object TestoRunArchiver { 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, ) @@ -102,26 +110,32 @@ internal object TestoRunArchiver { }.onFailure { LOG.warn("Failed to serialize the Testo run configuration", it) }.getOrNull() }.orEmpty() - /** Returns the run-dir-relative location of the captured copy, or null when the copy failed. */ + /** + * 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 format = ref.coverageFormat - if (format == CoverageFormat.COVERAGE_XML) { - // `local` is the directory's index.xml — the report is the whole directory. - val name = uniqueName(format.id, "", usedNames) + val name = uniqueName(capturedReportName(reportStem(ref), local), usedNames) + if (isDirectoryReport(local)) { copyDirectory(local.parent, recording.reportsDir.resolve(name)) - "${TestoRunRecording.REPORTS_DIR}/$name" + "${TestoRunRecording.REPORTS_DIR}/$name/${local.fileName}" } else { - val name = uniqueName(format?.id ?: "report", ".xml", usedNames) 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() - // Two reports of one format can coexist (a CLI flag beside a testo.php writer) — both are captured. - private fun uniqueName(stem: String, extension: String, used: MutableSet): String { - var candidate = "$stem$extension" + 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt index 3b31b80..02acc81 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.LayeredIcon import com.intellij.ui.SimpleTextAttributes import com.intellij.util.text.DateFormatUtil import java.nio.file.Path @@ -68,7 +69,7 @@ class TestoRunHistoryAction : AnAction(TestoBundle.message("testo.runs.history.a hasFocus: Boolean, ) { val manifest = value.second - icon = runKindIcon(runKindOf(manifest.executorId)) + 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) @@ -110,11 +111,20 @@ internal fun runKindOf(executorId: String?): TestoRunKind = when (executorId) { } internal fun runKindIcon(kind: TestoRunKind): Icon = when (kind) { - TestoRunKind.COVERAGE -> AllIcons.General.RunWithCoverage + // The tool window's own icon rather than the shield-and-arrow one: it is the plainer shape, and the lock overlay + // has room to sit on it. + TestoRunKind.COVERAGE -> AllIcons.Toolwindows.ToolWindowCoverage TestoRunKind.DEBUG -> AllIcons.Actions.StartDebugger TestoRunKind.RUN -> AllIcons.Actions.Execute } +/** The history entry's icon: what the run was, wearing a lock when the user locked it out of the rotation. */ +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() diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt index 1305c3e..6a7689a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt @@ -2,6 +2,7 @@ package com.github.xepozz.testo.runs import com.github.xepozz.testo.TestoBundle 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 @@ -11,8 +12,8 @@ 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.MessageDialogBuilder import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.text.StringUtil import com.intellij.util.text.DateFormatUtil import java.nio.file.Path @@ -26,7 +27,11 @@ import java.nio.file.Path * * Children are built on a background thread ([ActionUpdateThread.BGT]), which is what lets them read the archive. */ -class TestoRunHistoryGroup(private val project: Project) : ActionGroup( +class TestoRunHistoryGroup( + private val project: Project, + /** The archive this tab is showing, so the list can say which entry the user is already looking at. */ + private val currentRunDir: () -> Path? = { null }, +) : ActionGroup( TestoBundle.messagePointer("testo.runs.history.group"), TestoBundle.messagePointer("testo.runs.history.group.description"), { AllIcons.Vcs.History }, @@ -42,10 +47,13 @@ class TestoRunHistoryGroup(private val project: Project) : ActionGroup( if (e == null || project.isDisposed) return EMPTY_ARRAY val runs = TestoRunStore.getInstance(project).listRuns() if (runs.isEmpty()) return arrayOf(NoRuns()) + val current = runCatching { currentRunDir()?.toAbsolutePath()?.normalize() }.getOrNull() return buildList { - runs.forEach { (dir, manifest) -> add(ReplayRun(project, dir, manifest)) } + runs.forEach { (dir, manifest) -> + add(ReplayRun(project, dir, manifest, dir.toAbsolutePath().normalize() == current)) + } add(Separator.getInstance()) - add(ClearHistory(project)) + add(ClearHistory(project, currentRunDir)) }.toTypedArray() } @@ -53,24 +61,34 @@ class TestoRunHistoryGroup(private val project: Project) : ActionGroup( private val project: Project, private val dir: Path, private val manifest: TestoRunManifest, + current: Boolean, ) : AnAction( - label(dir, manifest), + label(dir, manifest, current), null, - runKindIcon(runKindOf(manifest.executorId)), + runHistoryIcon(manifest), ), DumbAware { override fun actionPerformed(e: AnActionEvent) = TestoRunReplayProfile.replay(project, dir, manifest) private companion object { - fun label(dir: Path, manifest: TestoRunManifest): String { + fun label(dir: Path, manifest: TestoRunManifest, current: Boolean): String { val name = manifest.configurationName.ifEmpty { dir.fileName.toString() } val at = DateFormatUtil.formatDateTime(manifest.startedAt) - return "$name — $at ${runResultSummary(manifest)}" + 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 every archived run of this project — output, reports and all. Asks first: the files are the history. */ - private class ClearHistory(private val project: Project) : AnAction( + /** + * 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 currentRunDir: () -> Path?, + ) : AnAction( TestoBundle.message("testo.runs.history.clear"), null, AllIcons.Actions.GC, @@ -78,19 +96,33 @@ class TestoRunHistoryGroup(private val project: Project) : ActionGroup( override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun actionPerformed(e: AnActionEvent) { - val confirmed = MessageDialogBuilder - .yesNo(TestoBundle.message("testo.runs.history.clear"), TestoBundle.message("testo.runs.history.clear.confirm")) - .icon(Messages.getWarningIcon()) - .ask(project) - if (!confirmed) return + 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 { currentRunDir() }.getOrNull() ApplicationManager.getApplication().executeOnPooledThread { if (project.isDisposed) return@executeOnPooledThread - TestoRunStore.getInstance(project).clear() + TestoRunStore.getInstance(project).clearHistory(keepLocked = choice == KEEP_LOCKED, spare = current) // 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 { diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt index 7373af4..d664795 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunManifest.kt @@ -1,6 +1,7 @@ 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. @@ -15,6 +16,19 @@ data class StoredReport( 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). @@ -38,6 +52,7 @@ data class TestoRunManifest( * 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(), diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt index 4b1debb..8b7c0ba 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt @@ -26,6 +26,10 @@ class TestoRunRecording internal constructor( 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) { diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt index 1da7384..103e1e9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt @@ -48,7 +48,8 @@ import javax.swing.Icon */ internal class TestoRunReplayProfile( private val project: Project, - private val runDir: Path, + /** 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, @@ -63,8 +64,7 @@ internal class TestoRunReplayProfile( * console still needs a configuration to be built from). */ val testoConfiguration: TestoRunConfiguration by lazy { - val configuration = TestoRunConfigurationType.INSTANCE - .createTemplateConfiguration(project) as TestoRunConfiguration + 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) } @@ -154,9 +154,9 @@ internal class TestoRunReplayProfile( .mapNotNull { report -> val stored = report.stored ?: return@mapNotNull null val format = CoverageFormat.fromId(report.format) ?: return@mapNotNull null - val captured = runDir.resolve(stored) - // coverage-xml is a directory; the loader consumes its index. - val dataFile = if (format == CoverageFormat.COVERAGE_XML) captured.resolve("index.xml") else captured + // 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index f5b5197..965b78f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -37,11 +37,57 @@ class TestoRunStore(private val project: Project) { return TestoRunRecording(dir, configurationName, executorId, startedAt) } - /** Complete runs (manifest present), newest first. Touches the filesystem — call off the EDT. */ + /** + * 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 } + fun retentionOf(dir: Path): RunRetention = readManifest(dir)?.retention ?: RunRetention.AUTO + + /** Rewrites the manifest's retention. A run still in flight has none yet — that choice rides on the recording. */ + 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. + */ + fun importRun(zip: Path): Pair? { + val staging = root().resolve("import-${System.currentTimeMillis()}") + return runCatching { + unzipRunDirectory(zip, staging) + val manifest = readManifest(staging) ?: run { + FileUtil.delete(staging) + return null + } + val target = freeDirectory(manifest) + Files.move(staging, target) + 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) { + Files.writeString(dir.resolve(TestoRunRecording.MANIFEST_FILE), gson.toJson(manifest), StandardCharsets.UTF_8) + } + fun readManifest(dir: Path): TestoRunManifest? = runCatching { val file = dir.resolve(TestoRunRecording.MANIFEST_FILE) if (!file.exists()) return null @@ -72,22 +118,41 @@ class TestoRunStore(private val project: Project) { fun prune() { val keep = retentionLimit() val now = System.currentTimeMillis() - val complete = ArrayList>() + val rotating = ArrayList>() for (dir in runDirectories()) { val manifest = readManifest(dir) - if (manifest != null) { - complete += dir to manifest.startedAt - } else if (now - startedAtOf(dir) > INCOMPLETE_GRACE_MS) { - // A directory that never got its manifest: the run crashed or the IDE died mid-write. - delete(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) + // Thrown away by hand — it goes whatever its age, and the locked ones never do. + manifest.retention == RunRetention.DISCARD -> delete(dir) + manifest.retention == RunRetention.LOCKED -> Unit + else -> rotating += dir to manifest.startedAt } } - complete.sortedByDescending { it.second }.drop(keep).forEach { delete(it.first) } + rotating.sortedByDescending { it.second }.drop(keep).forEach { delete(it.first) } } - /** Drops every archived run of this project. Touches the filesystem — call off the EDT. */ - fun clear() { - runDirectories().forEach { delete(it) } + /** + * 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() } + for (dir in runDirectories()) { + val retention = retentionOf(dir) + if (keepLocked && retention == RunRetention.LOCKED) continue + if (dir.toAbsolutePath().normalize() == spared) { + setRetention(dir, RunRetention.DISCARD) + continue + } + delete(dir) + } } private fun runDirectories(): List = runCatching { 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 e3ca742..0e6a0cc 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -85,6 +85,10 @@ class TestoConsoleProperties( 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, @@ -132,9 +136,11 @@ class TestoConsoleProperties( // the log-level filter sits at the right end of the group, expand/collapse at its left, next to the // separator that follows Show Passed / Show Ignored. com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), + // This run's own archive: export it, decide what retention may do with it, load an exported one. + 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), + com.github.xepozz.testo.runs.TestoRunHistoryGroup(project) { currentRunDir() }, com.intellij.openapi.actionSystem.Separator.getInstance(), // The platform keeps its own expand/collapse in the toolbar's overflow group; on a test tree they are used // constantly, so ours sit on the visible row (and the platform's are taken out of the overflow below). diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index c7db841..527c686 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -83,7 +83,10 @@ testo.runs.history.empty=No archived Testo runs yet — run some tests first testo.runs.history.none=No archived Testo run contains this test yet — run it to record one. testo.runs.history.group=Test History testo.runs.history.clear=Clear Testo Run History -testo.runs.history.clear.confirm=Delete every archived Testo run of this project, including the captured reports? +testo.runs.history.clear.confirm=Delete the archived Testo runs of this project, including the reports captured with \ + them? The run this tab shows leaves the history but keeps its files until the next cleanup. +testo.runs.history.clear.unlocked=Delete Unlocked +testo.runs.history.clear.all=Delete All testo.runs.history.group.description=Replay an archived Testo run testo.runs.history.summary.failed={0} total, {1} failed testo.runs.history.summary.passed={0} total, all passed @@ -91,6 +94,20 @@ testo.runs.history.summary.empty=no tests testo.runs.retention.group=Run History Retention testo.runs.retention.option=Keep Last {0} Runs testo.runs.replay.name={0} (replay) +testo.runs.replay.group=Replay +testo.runs.replay.group.description=Export this run, keep it, or load an exported one +testo.runs.replay.export=Export Replay… +testo.runs.replay.export.title=Export Testo Replay +testo.runs.replay.export.description=Save this run — output, reports and metadata — as one archive +testo.runs.replay.export.done=Testo replay exported to {0} +testo.runs.replay.export.failed=Could not export the Testo replay — see the log for details. +testo.runs.replay.import=Import Replay… +testo.runs.replay.import.title=Import Testo Replay +testo.runs.replay.import.failed=That archive holds no Testo run. +testo.runs.replay.retention.title=In history +testo.runs.replay.keep=Keep +testo.runs.replay.discard=Do Not Keep +testo.runs.replay.lock=Lock (Never Delete) notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin 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 0000000..617d4cd --- /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/TestoRunStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt index 9c0e17e..3e75a07 100644 --- a/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt @@ -98,6 +98,13 @@ class TestoRunStoreTest { 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()) From 07d929ff05715deaf89ea83d9bc3f21b0467d344 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 15 Aug 2026 23:02:30 +0400 Subject: [PATCH 19/41] feat(runs): retention and run kinds where the history list is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(console): lift Run with Coverage out of the test tree's submenu fix(console): move the sort button into the overflow group for real refactor(console): drop the metainfo bridge to the platform's test import Retention was a Tools menu away from the list it governs; it is now a submenu right above the button that clears that list, and each entry wears the tool window icon of what the run was — run, debug or coverage — so the lock overlay has a plain shape to sit on. An imported run arrives locked: it was carried here by hand, and rotation deleting it after ten local runs would throw away the only copy. The Replay group keeps its two file gestures together at the bottom, since a run leaves as a zip and comes back as one. The toolbar surgery was looking for any toolbar with a MoreActionGroup and settling for the first one it found, which on a run tab is the tab's own — so the test tree's sort button never moved. It now matches the toolbar by its place and takes the separator in front of the sort button along, leaving nothing between Show Ignored and our expand/collapse. Run with Coverage sits behind the platform's More Run/Debug submenu because ExecutorRegistryImpl sorts every executor but Run and Debug into RunContextGroupMore, switched by a global registry key. The test tree's popup now borrows those two actions by id one level up rather than moving them, which would empty the submenu everywhere else. With the platform's own import and export both gone from Testo tabs, nothing reads the channel output back out of SMTestProxy.metainfo any more — a replay rebuilds the tabs from the recorded stream. What is left of that file is the node selection a replayed lens click needs. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 +- CLAUDE.md | 34 ++-- .../xepozz/testo/runs/TestoReplayGroup.kt | 3 +- .../testo/runs/TestoRunHistoryActions.kt | 8 +- .../xepozz/testo/runs/TestoRunHistoryGroup.kt | 4 +- .../testo/runs/TestoRunReplayProfile.kt | 4 +- .../github/xepozz/testo/runs/TestoRunStore.kt | 7 +- .../actions/TestoTestTreeRunContextGroup.kt | 44 +++++ .../tests/console/TestoChannelHistory.kt | 177 ------------------ .../tests/console/TestoConsoleAugmenter.kt | 28 +-- .../tests/console/TestoReplaySelection.kt | 73 ++++++++ .../tests/console/TestoTestTreeDecorator.kt | 3 +- .../testo/tests/console/TestoToolbarLayout.kt | 86 ++++----- src/main/resources/META-INF/plugin.xml | 9 +- 14 files changed, 212 insertions(+), 279 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a55df87..094103b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,14 +15,19 @@ button as a full Testo console: channels, statuses, report buttons and that run's own coverage. - *Show history* above a test replays the newest archived run containing that test and selects its node. - How many archived runs to keep is set in *Tools | Testo*; the history list clears itself from its own menu. -- *Expand All* / *Collapse All* now sit on the toolbar itself rather than inside its overflow menu, and the sort - button moves the other way, into that menu. +- *Expand All* / *Collapse All* now sit on the toolbar itself, next to *Show Passed* / *Show Ignored*, and the sort + button moves the other way, into the overflow menu. - A tab opened from the history reruns with the executor the archived run used: a coverage run reruns with coverage. - A *Replay* button on the test toolbar exports the run as a single archive and imports one back, and says what the history may do with it: keep it, drop it, or lock it so retention never touches it. - Every report a run announces is archived with it — an HTML report travels with its assets — so a replayed run opens its own reports rather than whatever the latest run left behind. -- The history list marks the run the tab is showing in bold, and puts a lock on the locked ones. +- The history list marks the run the tab is showing in bold, and puts a lock on the locked ones, and each entry wears + the icon of what the run was: run, debug or coverage. +- How many runs the history keeps is set from the history list itself, right above the button that clears it. +- An imported run comes in locked, so retention never deletes the one copy of a run carried in from elsewhere. +- The test tree's context menu offers *Run with Coverage* and *Modify Run Configuration* on its own level, instead of + only inside the *More Run/Debug* submenu. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 2f61a14..d081cc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,8 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoRerunFailedTestsAction.kt # failed leaves → explicit --filter list │ │ ├── TestoRerunWithExecutorAction.kt # rerun in Run/Debug/Coverage + split button │ │ ├── TestoRerunStyle.kt # MIRROR_AWARE vs SPLIT_BUTTON toolbar styles -│ │ └── TestoRunCommandAction.kt # "Run Testo " (Run Anything) +│ │ ├── TestoRunCommandAction.kt # "Run Testo " (Run Anything) +│ │ └── TestoTestTreeRunContextGroup.kt # coverage + modify config, lifted out of the popup's submenu │ │ │ ├── console/ # the channel console subsystem (largest area) │ │ ├── TestoOutputToGeneralEventsConverter.kt # reads channel/level/icon/color off SM messages @@ -141,7 +142,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoLogLevelFilterAction.kt # toolbar dropdown for the filter │ │ ├── TestoChannelsUi.kt # the tabbed channel view (~1150 lines) + testoDisplayName() │ │ ├── TestoConsoleAugmenter.kt # ExecutionListener that installs the channel tabs -│ │ ├── TestoChannelHistory.kt # channel output ⇄ SMTestProxy.metainfo (platform import) + tree-ready polling +│ │ ├── 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 @@ -188,9 +189,9 @@ src/main/kotlin/com/github/xepozz/testo/ │ ├── 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": export / keep-discard-pin / import, for this tab's run +│ ├── TestoReplayGroup.kt # toolbar "Replay": keep-discard-lock + export/import, for this tab's run │ ├── TestoRunHistoryGroup.kt # the "Test History" toolbar button, replacing the platform's -│ └── TestoRunHistoryActions.kt # Tools | Testo: history chooser + retention; the lens's lookups +│ └── TestoRunHistoryActions.kt # run kind icons + summaries, the retention submenu, the lens's lookups │ └── ui/ ├── TestoIconProvider.kt # Testo-marked icons for PHP test files @@ -430,9 +431,8 @@ 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 tree has one filter slot, shared with *Show passed* / *Show ignored*.** `TestoProgressAction.applyFilter` is @@ -466,16 +466,24 @@ Non-obvious constraints already paid for in blood — read before touching the r `ImportTestsGroup` + `ImportTestsFromFileAction` there, both opening a saved XML through the platform import — a console with none of our UI. We return `TestoRunHistoryGroup` instead, which lists the run archive and replays it. Actions without `RunTab.PREFERRED_PLACE = MORE_GROUP` land on the visible toolbar row, so no experimental key is - needed. Dropping `super` also drops the platform's "Import Test Results from file" from Testo tabs. The same array - is how expand/collapse reach the visible row. The array is laid out right-to-left (listed first = furthest right), - which is the only control over placement there — everything in it lands after the platform's own actions. + needed. Dropping `super` also drops the platform's "Import Test Results from file" from Testo tabs — deliberate: + our own export/import is the *Replay* group, and the platform's counterpart opens a console with none of our UI. + Its export half is gone for the same reason (`getConfiguration()` answers the replay profile, and `ToolbarPanel` + only builds `ExportTestResultsAction` for a real `RunConfiguration`). The same array is how expand/collapse reach + the visible row. The array is laid out right-to-left (listed first = furthest right), which is the only control + over placement there — everything in it lands after the platform's own actions. - **`TestoToolbarLayoutAction` rearranges the platform's toolbar from inside it.** `ToolbarPanel` builds the sort popup and the overflow group inline — no ids, no extension point, no `CustomActionsSchema` entry — and hands a snapshot of the visible group to `RunTab`, so the group the user sees is reachable only from an action sitting in it. This invisible action walks up to the toolbars around it (a bounded number of update passes, then it gives up - for good) and moves the sort popup into the overflow group and the platform's expand/collapse out of it. Matched - structurally — a popup group holding `SortByDurationAction`, a group class named `MoreActionGroup`, the - expand/collapse icons — so a platform reshuffle makes it a no-op rather than a breakage. + for good), finds the one whose place is `TestTreeViewToolbar`, and moves the sort popup — with the separator that + preceded it — into the overflow group, and the platform's expand/collapse out of it. Matched structurally — a popup + group holding `SortByDurationAction`, a group class named `MoreActionGroup`, the expand/collapse icons — so a + platform reshuffle makes it a no-op rather than a breakage. +- **Run with Coverage sits behind the platform's "More Run/Debug" submenu**, and the rule that puts it there is + `ExecutorRegistryImpl` sorting every executor but Run and Debug into `RunContextGroupMore`, switched by a global + registry key. `TestoTestTreeRunContextGroup` therefore borrows the same actions by id (the coverage executor's + `contextActionId`, plus `CreateRunConfiguration`) into the test tree's popup one level up, rather than moving them. - **`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 diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt index f985930..737b3e4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt @@ -45,12 +45,13 @@ class TestoReplayGroup( override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun getChildren(e: AnActionEvent?): Array = arrayOf( - ExportAction(), 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"), + // The two file gestures are one pair — a run leaves as a zip and comes back as one. Separator.getInstance(), + ExportAction(), ImportAction(), ) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt index 02acc81..5dcab53 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -110,12 +110,12 @@ internal fun runKindOf(executorId: String?): TestoRunKind = when (executorId) { 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) { - // The tool window's own icon rather than the shield-and-arrow one: it is the plainer shape, and the lock overlay - // has room to sit on it. TestoRunKind.COVERAGE -> AllIcons.Toolwindows.ToolWindowCoverage - TestoRunKind.DEBUG -> AllIcons.Actions.StartDebugger - TestoRunKind.RUN -> AllIcons.Actions.Execute + TestoRunKind.DEBUG -> AllIcons.Toolwindows.ToolWindowDebugger + TestoRunKind.RUN -> AllIcons.Toolwindows.ToolWindowRun } /** The history entry's icon: what the run was, wearing a lock when the user locked it out of the rotation. */ diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt index 6a7689a..d1cbfa5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt @@ -46,13 +46,15 @@ class TestoRunHistoryGroup( override fun getChildren(e: AnActionEvent?): Array { if (e == null || project.isDisposed) return EMPTY_ARRAY val runs = TestoRunStore.getInstance(project).listRuns() - if (runs.isEmpty()) return arrayOf(NoRuns()) val current = runCatching { 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, currentRunDir)) }.toTypedArray() } diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt index 103e1e9..ac72114 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunReplayProfile.kt @@ -7,8 +7,8 @@ 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.TestoChannelHistory 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 @@ -103,7 +103,7 @@ internal class TestoRunReplayProfile( feed(handler) applyArchivedCoverage() val url = targetUrl ?: return@executeOnPooledThread - (console as? SMTRunnerConsoleView)?.let { TestoChannelHistory.selectWhenReady(it, url) } + (console as? SMTRunnerConsoleView)?.let { TestoReplaySelection.selectWhenReady(it, url) } } } }) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index 965b78f..dc38c90 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -58,17 +58,22 @@ class TestoRunStore(private val project: Project) { /** * 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 manifest = readManifest(staging) ?: run { + 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) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt new file mode 100644 index 0000000..1eff4b2 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.testo.tests.actions + +import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner +import com.intellij.execution.ExecutorRegistry +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware + +/** + * The two run-context actions the test tree's popup buries: *Run with Coverage* and *Modify Run Configuration*. + * + * The platform hides every executor other than Run and Debug behind the `RunContextGroupMore` submenu — a rule that + * lives in `ExecutorRegistryImpl` and is switched by a global registry key, so it cannot be relaxed for one popup. + * This group borrows the very same actions by id and offers them one level up, where a test tree needs them. They stay + * in the submenu too: the actions are shared instances, and removing them from the platform's group would empty it + * everywhere else as well. + */ +class TestoTestTreeRunContextGroup : ActionGroup(), DumbAware { + + init { + isPopup = false + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun getChildren(e: AnActionEvent?): Array { + val manager = ActionManager.getInstance() + val coverageActionId = ExecutorRegistry.getInstance() + .getExecutorById(TestoCoverageProgramRunner.EXECUTOR_ID) + ?.contextActionId + return listOfNotNull( + coverageActionId?.let { manager.getAction(it) }, + manager.getAction(MODIFY_RUN_CONFIGURATION), + ).toTypedArray() + } + + private companion object { + // "Modify Run Configuration…" — the platform's own id, as PlatformExecutionActions.xml spells it. + private const val MODIFY_RUN_CONFIGURATION = "CreateRunConfiguration" + } +} 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 36c6199..0000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelHistory.kt +++ /dev/null @@ -1,177 +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 a console built by the platform's own "Import Test Results": once its tree is built, decode every proxy's - * metainfo into a fresh store and install the channel UI. Our own history goes through the run archive instead - * (`com.github.xepozz.testo.runs`), which replays the real stream and needs none of this. - */ - fun installForImport(project: Project, console: SMTRunnerConsoleView) { - // 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() - whenTreeStable(console) { root -> - 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) - } - } - - /** - * Select the node of [url] once the tree has finished building — for a replayed archive, where the tree is still - * filling while the recorded output streams 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) { - 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 - 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 - ApplicationManager.getApplication().invokeLater { 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 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/TestoConsoleAugmenter.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoConsoleAugmenter.kt index e5ec320..1191928 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 @@ -20,15 +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 - when { - // Live run — and a replayed archive, which runs on the same properties, so it gets the same UI. - props is TestoConsoleProperties -> installChannels(project, console, props, handler) - // 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 the metainfo the run stored on each proxy. - isImportedConsole(props) -> TestoChannelHistory.installForImport(project, console) - } + // 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) } } @@ -43,17 +37,6 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { } } - // 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 - } - return false - } - private fun findDescriptor(executorId: String, handler: ProcessHandler): RunContentDescriptor? { val manager = RunContentManager.getInstance(project) ExecutorRegistry.getInstance().getExecutorById(executorId)?.let { executor -> @@ -63,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. @@ -86,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, 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 0000000..07ba5cb --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt @@ -0,0 +1,73 @@ +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.openapi.application.ApplicationManager +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) { + 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 + 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 + ApplicationManager.getApplication().invokeLater { 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 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 + } +} 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 fd508cc..d076b3c 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/TestoToolbarLayout.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt index c2b4243..c59f81b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt @@ -9,25 +9,26 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Constraints import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.PlatformCoreDataKeys +import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.project.DumbAware import com.intellij.util.ui.UIUtil import java.awt.Container import javax.swing.JComponent /** - * Rearranges the test toolbar the platform built: the sort popup moves into the overflow ("burger") group, and the - * platform's own expand/collapse leave it — ours sit on the visible row now - * ([com.github.xepozz.testo.tests.TestoConsoleProperties.createImportActions]). + * Rearranges the test toolbar the platform built: the sort popup moves into the overflow ("burger") group along with + * the separator that used to precede it, and the platform's own expand/collapse leave that group — ours sit on the + * visible row now ([com.github.xepozz.testo.tests.TestoConsoleProperties.createImportActions]). * * There is no seam for this. `ToolbarPanel` creates both groups inline, with no action ids, no extension point and no * `CustomActionsSchema` entry, and hands a snapshot of the visible one to `RunTab` — so the only handle on the group * the user actually sees is from *inside* it. Hence this: an invisible action that rides the same toolbar and, the - * first few times it is asked to update, walks the toolbars around it and moves the two things. + * first few times it is asked to update, walks the toolbars around it looking for the one the test tree owns. * - * Deliberately best-effort, and matched structurally rather than by name — a popup group holding - * `SortByDurationAction`, a group whose class is `MoreActionGroup`, actions wearing the expand/collapse icons — so it - * survives translation and renaming, and does nothing at all (rather than breaking the toolbar) once the platform's - * layout changes shape. + * Deliberately best-effort. The toolbar is found by its place, its contents matched structurally — a popup group + * holding `SortByDurationAction`, a group whose class is `MoreActionGroup`, actions wearing the expand/collapse icons + * — so this survives translation and renaming, and does nothing at all (rather than breaking the toolbar) once the + * platform's layout changes shape. */ internal class TestoToolbarLayoutAction : AnAction(), DumbAware { @@ -48,7 +49,9 @@ internal class TestoToolbarLayoutAction : AnAction(), DumbAware { while (ancestor != null && hops < ANCESTOR_LIMIT) { (ancestor as? JComponent)?.let { root -> UIUtil.uiTraverser(root).traverse().forEach { candidate -> - if (candidate is ActionToolbar && rearrange(candidate)) done = true + if (candidate is ActionToolbar && candidate.place == TEST_TREE_TOOLBAR_PLACE) { + if (rearrange(candidate.actionGroup)) done = true + } } } ancestor = ancestor.parent @@ -58,25 +61,31 @@ internal class TestoToolbarLayoutAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) = Unit - /** True once this toolbar has been rearranged — i.e. it was the one holding the platform's groups. */ - private fun rearrange(toolbar: ActionToolbar): Boolean = runCatching { - val group = actionGroupOf(toolbar) ?: return false - val more = find(group, 0) { it.javaClass.simpleName == MORE_GROUP } as? DefaultActionGroup ?: return false - moveSortIntoMoreGroup(group, more) + /** True once this toolbar's group has been rearranged — i.e. it held the platform's groups and now does not. */ + private fun rearrange(group: ActionGroup): Boolean = runCatching { + val root = group as? DefaultActionGroup ?: return false + val more = root.getChildActionsOrStubs() + .filterIsInstance() + .firstOrNull { it.javaClass.simpleName == MORE_GROUP } + ?: return false + val moved = moveSortIntoMoreGroup(root, more) dropExpandCollapse(more) - true + moved }.getOrDefault(false) - /** The toolbar's group, read by name: the accessor lives on the implementation, which is not ours to reference. */ - private fun actionGroupOf(toolbar: ActionToolbar): ActionGroup? = - runCatching { toolbar.javaClass.getMethod("getActionGroup").invoke(toolbar) as? ActionGroup }.getOrNull() - - private fun moveSortIntoMoreGroup(root: ActionGroup, more: DefaultActionGroup) { - val sort = find(root, 0) { it is ActionGroup && it.isPopup && holds(it, SORT_MARKER) } ?: return - val owner = parentOf(root, sort, 0) as? DefaultActionGroup ?: return - if (owner === more) return - owner.remove(sort) + /** + * Moves the sort popup into [more], taking the separator in front of it along: that separator was there to part + * the two toggles from the sort button, and with the button gone it would only fence off our own actions. + */ + private fun moveSortIntoMoreGroup(root: DefaultActionGroup, more: DefaultActionGroup): Boolean { + val children = root.getChildActionsOrStubs() + val index = children.indexOfFirst { it is ActionGroup && it.isPopup && holds(it, SORT_MARKER) } + if (index < 0) return false + children.getOrNull(index - 1)?.takeIf { it is Separator }?.let { root.remove(it) } + val sort = children[index] + root.remove(sort) more.add(sort, Constraints.FIRST) + return true } private fun dropExpandCollapse(more: DefaultActionGroup) { @@ -85,37 +94,18 @@ internal class TestoToolbarLayoutAction : AnAction(), DumbAware { .forEach { more.remove(it) } } - private fun holds(group: ActionGroup, markerClassName: String): Boolean = - children(group).any { it.javaClass.simpleName == markerClassName } - - private fun find(group: ActionGroup, depth: Int, predicate: (AnAction) -> Boolean): AnAction? { - if (depth > DEPTH_LIMIT) return null - for (child in children(group)) { - if (predicate(child)) return child - if (child is ActionGroup) find(child, depth + 1, predicate)?.let { return it } - } - return null - } - - private fun parentOf(group: ActionGroup, child: AnAction, depth: Int): ActionGroup? { - if (depth > DEPTH_LIMIT) return null - for (candidate in children(group)) { - if (candidate === child) return group - if (candidate is ActionGroup) parentOf(candidate, child, depth + 1)?.let { return it } - } - return null - } - // Only the groups we can read without asking: `ActionGroup.getChildren` is @OverrideOnly, so calling it is out — // and every group on this path is a DefaultActionGroup anyway (`RunTab.ToolbarActionGroup` copies its delegate's // children into itself). Stubs are fine: everything matched here is a real instance the toolbar was built with. - private fun children(group: ActionGroup): Array = - (group as? DefaultActionGroup)?.getChildActionsOrStubs() ?: AnAction.EMPTY_ARRAY + private fun holds(group: ActionGroup, markerClassName: String): Boolean = + (group as? DefaultActionGroup)?.getChildActionsOrStubs() + ?.any { it.javaClass.simpleName == markerClassName } == true private companion object { + // The place ToolbarPanel creates its toolbar under. A literal there too — the platform exposes no constant. + private const val TEST_TREE_TOOLBAR_PLACE = "TestTreeViewToolbar" private const val MORE_GROUP = "MoreActionGroup" private const val SORT_MARKER = "SortByDurationAction" - private const val DEPTH_LIMIT = 3 private const val ANCESTOR_LIMIT = 12 private const val MAX_ATTEMPTS = 20 diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 16a56ca..461822b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -155,8 +155,13 @@ - + + + + + Date: Sat, 15 Aug 2026 23:31:42 +0400 Subject: [PATCH 20/41] fix(runs): let the Replay menu tell the truth after the history is cleared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(runs): the Replay button wears its run's kind, and opens its folder revert(console): stop rearranging the platform's part of the test toolbar revert(console): drop the duplicated run-context actions from the test tree popup Clearing the history marks the run the open tab is showing as "do not keep" instead of deleting it out from under that tab, but the tab kept reporting "keep in history": the retention radio read the recording first, and the recording is a live object the store never touches. The manifest is the truth once it exists; the recording only answers for a run that has not been archived yet, and clearing sets it there directly for a run still in flight. The toolbar surgery could not work and is gone. ToolbarPanel copies both of its groups into arrays (actionsToMerge / additionalActionsToMerge) and RunTab rebuilds the tab's toolbar from those copies, so mutating the live groups afterwards changes nothing the user sees — the sort button never moved. The platform's sort popup, its separator and its expand/collapse stay where the platform put them. The same is true one level up: Run with Coverage is in the "More Run/Debug" submenu because ExecutorRegistryImpl sorts every executor but Run and Debug into RunContextGroupMore unless a global registry key says otherwise, and the actions there are shared instances — so offering them on the test tree popup's own level only duplicated the submenu. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 +- CLAUDE.md | 28 ++--- .../xepozz/testo/runs/TestoReplayGroup.kt | 42 ++++++- .../xepozz/testo/runs/TestoRunHistoryGroup.kt | 23 ++-- .../github/xepozz/testo/runs/TestoRunStore.kt | 3 +- .../testo/tests/TestoConsoleProperties.kt | 8 +- .../actions/TestoTestTreeRunContextGroup.kt | 44 ------- .../testo/tests/console/TestoToolbarLayout.kt | 115 ------------------ src/main/resources/META-INF/plugin.xml | 7 -- 9 files changed, 78 insertions(+), 204 deletions(-) delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt delete mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 094103b..de64811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,25 +15,25 @@ button as a full Testo console: channels, statuses, report buttons and that run's own coverage. - *Show history* above a test replays the newest archived run containing that test and selects its node. - How many archived runs to keep is set in *Tools | Testo*; the history list clears itself from its own menu. -- *Expand All* / *Collapse All* now sit on the toolbar itself, next to *Show Passed* / *Show Ignored*, and the sort - button moves the other way, into the overflow menu. +- *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 coverage run reruns with coverage. -- A *Replay* button on the test toolbar exports the run as a single archive and imports one back, and says what the - history may do with it: keep it, drop it, or lock it so retention never touches it. +- A *Replay* button on the test toolbar exports the run as a single archive, imports one back, shows the run's own + folder in the file manager, and says what the history may do with it: keep it, drop it, or lock it so retention + never touches it. The button wears the icon of what the run was — run, debug or coverage. - Every report a run announces is archived with it — an HTML report travels with its assets — so a replayed run opens its own reports rather than whatever the latest run left behind. - The history list marks the run the tab is showing in bold, and puts a lock on the locked ones, and each entry wears the icon of what the run was: run, debug or coverage. - How many runs the history keeps is set from the history list itself, right above the button that clears it. - An imported run comes in locked, so retention never deletes the one copy of a run carried in from elsewhere. -- The test tree's context menu offers *Run with Coverage* and *Modify Run Configuration* on its own level, instead of - only inside the *More Run/Debug* submenu. ### Fixed - 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: report paths now resolve off the UI thread. - The elapsed time in the run summary no longer counts up forever when a run ends before the toolbar is wired. +- Clearing the history now shows on the tab of the run it spares: its *Replay* menu says *Do not keep in history*, + where it used to keep claiming the run was being kept. ## [2026.5.262] - 2026-08-12 diff --git a/CLAUDE.md b/CLAUDE.md index d081cc7..ecb12a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,8 +130,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoRerunFailedTestsAction.kt # failed leaves → explicit --filter list │ │ ├── TestoRerunWithExecutorAction.kt # rerun in Run/Debug/Coverage + split button │ │ ├── TestoRerunStyle.kt # MIRROR_AWARE vs SPLIT_BUTTON toolbar styles -│ │ ├── TestoRunCommandAction.kt # "Run Testo " (Run Anything) -│ │ └── TestoTestTreeRunContextGroup.kt # coverage + modify config, lifted out of the popup's submenu +│ │ └── TestoRunCommandAction.kt # "Run Testo " (Run Anything) │ │ │ ├── console/ # the channel console subsystem (largest area) │ │ ├── TestoOutputToGeneralEventsConverter.kt # reads channel/level/icon/color off SM messages @@ -155,7 +154,6 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── 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 -│ │ ├── TestoToolbarLayout.kt # moves the platform's sort popup into the toolbar's overflow group │ │ ├── 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 @@ -189,7 +187,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ ├── 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, for this tab's run +│ ├── 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 │ @@ -472,18 +470,16 @@ Non-obvious constraints already paid for in blood — read before touching the r only builds `ExportTestResultsAction` for a real `RunConfiguration`). The same array is how expand/collapse reach the visible row. The array is laid out right-to-left (listed first = furthest right), which is the only control over placement there — everything in it lands after the platform's own actions. -- **`TestoToolbarLayoutAction` rearranges the platform's toolbar from inside it.** `ToolbarPanel` builds the sort - popup and the overflow group inline — no ids, no extension point, no `CustomActionsSchema` entry — and hands a - snapshot of the visible group to `RunTab`, so the group the user sees is reachable only from an action sitting in - it. This invisible action walks up to the toolbars around it (a bounded number of update passes, then it gives up - for good), finds the one whose place is `TestTreeViewToolbar`, and moves the sort popup — with the separator that - preceded it — into the overflow group, and the platform's expand/collapse out of it. Matched structurally — a popup - group holding `SortByDurationAction`, a group class named `MoreActionGroup`, the expand/collapse icons — so a - platform reshuffle makes it a no-op rather than a breakage. -- **Run with Coverage sits behind the platform's "More Run/Debug" submenu**, and the rule that puts it there is - `ExecutorRegistryImpl` sorting every executor but Run and Debug into `RunContextGroupMore`, switched by a global - registry key. `TestoTestTreeRunContextGroup` therefore borrows the same actions by id (the coverage executor's - `contextActionId`, plus `CreateRunConfiguration`) into the test tree's popup one level up, rather than moving them. +- **What the platform put on the test toolbar cannot be moved or removed** — the sort popup, the separator after + *Show Ignored*, the expand/collapse inside the overflow group. `ToolbarPanel` builds those inline (no ids, no + extension point, no `CustomActionsSchema` entry) and then copies both of its groups into `actionsToMerge` / + `additionalActionsToMerge`, which is what `RunTab` rebuilds the tab's toolbar from — so mutating the live groups + afterwards changes nothing the user sees. An invisible action riding the toolbar was tried and reverted; the only + control we have there is our own `createImportActions` array. +- **Every executor but Run and Debug is hidden behind the "More Run/Debug" submenu** of a run-context popup, by + `ExecutorRegistryImpl` sorting them into `RunContextGroupMore` unless the `executor.actions.submenu` registry key + is off. That key is global and the actions are shared instances, so a copy of *Run with Coverage* at the popup's + own level only duplicates the submenu entry — tried in the test tree's popup and reverted. - **`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 diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt index 737b3e4..ae22a89 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt @@ -4,6 +4,7 @@ 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 @@ -20,6 +21,7 @@ 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 /** @@ -35,7 +37,7 @@ class TestoReplayGroup( ) : ActionGroup( TestoBundle.messagePointer("testo.runs.replay.group"), TestoBundle.messagePointer("testo.runs.replay.group.description"), - { AllIcons.Actions.Play_forward }, + { AllIcons.Toolwindows.ToolWindowRun }, ), DumbAware { init { @@ -44,20 +46,32 @@ class TestoReplayGroup( 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"), - // The two file gestures are one pair — a run leaves as a zip and comes back as one. + // The file gestures are one block — a run leaves as a zip, comes back as one, and lies on disk meanwhile. 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, @@ -126,6 +140,24 @@ class TestoReplayGroup( } } + /** 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, @@ -156,9 +188,11 @@ class TestoReplayGroup( } } + // 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 = - props.recording?.retention - ?: runDir()?.let { TestoRunStore.getInstance(project).retentionOf(it) } + runDir()?.let { TestoRunStore.getInstance(project).retentionOf(it) } + ?: props.recording?.retention ?: RunRetention.AUTO } diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt index d1cbfa5..7ecafe6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryGroup.kt @@ -1,6 +1,7 @@ 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 @@ -29,8 +30,8 @@ import java.nio.file.Path */ class TestoRunHistoryGroup( private val project: Project, - /** The archive this tab is showing, so the list can say which entry the user is already looking at. */ - private val currentRunDir: () -> Path? = { null }, + /** 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"), @@ -46,7 +47,7 @@ class TestoRunHistoryGroup( override fun getChildren(e: AnActionEvent?): Array { if (e == null || project.isDisposed) return EMPTY_ARRAY val runs = TestoRunStore.getInstance(project).listRuns() - val current = runCatching { currentRunDir()?.toAbsolutePath()?.normalize() }.getOrNull() + val current = runCatching { props.currentRunDir()?.toAbsolutePath()?.normalize() }.getOrNull() return buildList { if (runs.isEmpty()) add(NoRuns()) runs.forEach { (dir, manifest) -> @@ -55,7 +56,7 @@ class TestoRunHistoryGroup( 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, currentRunDir)) + add(ClearHistory(project, props)) }.toTypedArray() } @@ -89,7 +90,7 @@ class TestoRunHistoryGroup( */ private class ClearHistory( private val project: Project, - private val currentRunDir: () -> Path?, + private val props: TestoConsoleProperties, ) : AnAction( TestoBundle.message("testo.runs.history.clear"), null, @@ -111,10 +112,18 @@ class TestoRunHistoryGroup( Messages.getWarningIcon(), ) if (choice != KEEP_LOCKED && choice != DELETE_ALL) return - val current = runCatching { currentRunDir() }.getOrNull() + val current = runCatching { props.currentRunDir() }.getOrNull() ApplicationManager.getApplication().executeOnPooledThread { if (project.isDisposed) return@executeOnPooledThread - TestoRunStore.getInstance(project).clearHistory(keepLocked = choice == KEEP_LOCKED, spare = current) + 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) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index dc38c90..8fdbfca 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -46,7 +46,8 @@ class TestoRunStore(private val project: Project) { .filter { it.second.retention != RunRetention.DISCARD } .sortedByDescending { it.second.startedAt } - fun retentionOf(dir: Path): RunRetention = readManifest(dir)?.retention ?: RunRetention.AUTO + /** 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 run still in flight has none yet — that choice rides on the recording. */ fun setRetention(dir: Path, retention: RunRetention) { 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 0e6a0cc..a1a01ee 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -140,14 +140,14 @@ class TestoConsoleProperties( 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) { currentRunDir() }, + com.github.xepozz.testo.runs.TestoRunHistoryGroup(project, this), com.intellij.openapi.actionSystem.Separator.getInstance(), // The platform keeps its own expand/collapse in the toolbar's overflow group; on a test tree they are used - // constantly, so ours sit on the visible row (and the platform's are taken out of the overflow below). + // constantly, so ours sit on the visible row. The platform's stay where they are: `ToolbarPanel` copies + // both of its groups into arrays before `RunTab` rebuilds the toolbar from them, so nothing the platform + // put there can be moved or removed afterwards. com.github.xepozz.testo.tests.console.TestoTreeCollapseAction(), com.github.xepozz.testo.tests.console.TestoTreeExpandAction(), - // Invisible: it is here only to reach the toolbar it is added to. See TestoToolbarLayoutAction. - com.github.xepozz.testo.tests.console.TestoToolbarLayoutAction(), reportsAction, progressAction, ) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt b/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt deleted file mode 100644 index 1eff4b2..0000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/actions/TestoTestTreeRunContextGroup.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.xepozz.testo.tests.actions - -import com.github.xepozz.testo.coverage.TestoCoverageProgramRunner -import com.intellij.execution.ExecutorRegistry -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.project.DumbAware - -/** - * The two run-context actions the test tree's popup buries: *Run with Coverage* and *Modify Run Configuration*. - * - * The platform hides every executor other than Run and Debug behind the `RunContextGroupMore` submenu — a rule that - * lives in `ExecutorRegistryImpl` and is switched by a global registry key, so it cannot be relaxed for one popup. - * This group borrows the very same actions by id and offers them one level up, where a test tree needs them. They stay - * in the submenu too: the actions are shared instances, and removing them from the platform's group would empty it - * everywhere else as well. - */ -class TestoTestTreeRunContextGroup : ActionGroup(), DumbAware { - - init { - isPopup = false - } - - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT - - override fun getChildren(e: AnActionEvent?): Array { - val manager = ActionManager.getInstance() - val coverageActionId = ExecutorRegistry.getInstance() - .getExecutorById(TestoCoverageProgramRunner.EXECUTOR_ID) - ?.contextActionId - return listOfNotNull( - coverageActionId?.let { manager.getAction(it) }, - manager.getAction(MODIFY_RUN_CONFIGURATION), - ).toTypedArray() - } - - private companion object { - // "Modify Run Configuration…" — the platform's own id, as PlatformExecutionActions.xml spells it. - private const val MODIFY_RUN_CONFIGURATION = "CreateRunConfiguration" - } -} diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt deleted file mode 100644 index c59f81b..0000000 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoToolbarLayout.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.github.xepozz.testo.tests.console - -import com.intellij.icons.AllIcons -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.actionSystem.ActionToolbar -import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.Constraints -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.actionSystem.PlatformCoreDataKeys -import com.intellij.openapi.actionSystem.Separator -import com.intellij.openapi.project.DumbAware -import com.intellij.util.ui.UIUtil -import java.awt.Container -import javax.swing.JComponent - -/** - * Rearranges the test toolbar the platform built: the sort popup moves into the overflow ("burger") group along with - * the separator that used to precede it, and the platform's own expand/collapse leave that group — ours sit on the - * visible row now ([com.github.xepozz.testo.tests.TestoConsoleProperties.createImportActions]). - * - * There is no seam for this. `ToolbarPanel` creates both groups inline, with no action ids, no extension point and no - * `CustomActionsSchema` entry, and hands a snapshot of the visible one to `RunTab` — so the only handle on the group - * the user actually sees is from *inside* it. Hence this: an invisible action that rides the same toolbar and, the - * first few times it is asked to update, walks the toolbars around it looking for the one the test tree owns. - * - * Deliberately best-effort. The toolbar is found by its place, its contents matched structurally — a popup group - * holding `SortByDurationAction`, a group whose class is `MoreActionGroup`, actions wearing the expand/collapse icons - * — so this survives translation and renaming, and does nothing at all (rather than breaking the toolbar) once the - * platform's layout changes shape. - */ -internal class TestoToolbarLayoutAction : AnAction(), DumbAware { - - private var done = false - private var attempts = 0 - - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT - - override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = false - // The toolbar updates twice a second; this walks components, so it runs only until it lands (the toolbar may - // not be assembled on the first pass) and then gives up for good. - if (done || attempts >= MAX_ATTEMPTS) return - attempts++ - val component = e.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT) as? Container ?: return - var ancestor: Container? = component - var hops = 0 - while (ancestor != null && hops < ANCESTOR_LIMIT) { - (ancestor as? JComponent)?.let { root -> - UIUtil.uiTraverser(root).traverse().forEach { candidate -> - if (candidate is ActionToolbar && candidate.place == TEST_TREE_TOOLBAR_PLACE) { - if (rearrange(candidate.actionGroup)) done = true - } - } - } - ancestor = ancestor.parent - hops++ - } - } - - override fun actionPerformed(e: AnActionEvent) = Unit - - /** True once this toolbar's group has been rearranged — i.e. it held the platform's groups and now does not. */ - private fun rearrange(group: ActionGroup): Boolean = runCatching { - val root = group as? DefaultActionGroup ?: return false - val more = root.getChildActionsOrStubs() - .filterIsInstance() - .firstOrNull { it.javaClass.simpleName == MORE_GROUP } - ?: return false - val moved = moveSortIntoMoreGroup(root, more) - dropExpandCollapse(more) - moved - }.getOrDefault(false) - - /** - * Moves the sort popup into [more], taking the separator in front of it along: that separator was there to part - * the two toggles from the sort button, and with the button gone it would only fence off our own actions. - */ - private fun moveSortIntoMoreGroup(root: DefaultActionGroup, more: DefaultActionGroup): Boolean { - val children = root.getChildActionsOrStubs() - val index = children.indexOfFirst { it is ActionGroup && it.isPopup && holds(it, SORT_MARKER) } - if (index < 0) return false - children.getOrNull(index - 1)?.takeIf { it is Separator }?.let { root.remove(it) } - val sort = children[index] - root.remove(sort) - more.add(sort, Constraints.FIRST) - return true - } - - private fun dropExpandCollapse(more: DefaultActionGroup) { - more.getChildActionsOrStubs() - .filter { it.templatePresentation.icon.let { icon -> icon === EXPAND_ICON || icon === COLLAPSE_ICON } } - .forEach { more.remove(it) } - } - - // Only the groups we can read without asking: `ActionGroup.getChildren` is @OverrideOnly, so calling it is out — - // and every group on this path is a DefaultActionGroup anyway (`RunTab.ToolbarActionGroup` copies its delegate's - // children into itself). Stubs are fine: everything matched here is a real instance the toolbar was built with. - private fun holds(group: ActionGroup, markerClassName: String): Boolean = - (group as? DefaultActionGroup)?.getChildActionsOrStubs() - ?.any { it.javaClass.simpleName == markerClassName } == true - - private companion object { - // The place ToolbarPanel creates its toolbar under. A literal there too — the platform exposes no constant. - private const val TEST_TREE_TOOLBAR_PLACE = "TestTreeViewToolbar" - private const val MORE_GROUP = "MoreActionGroup" - private const val SORT_MARKER = "SortByDurationAction" - private const val ANCESTOR_LIMIT = 12 - private const val MAX_ATTEMPTS = 20 - - private val EXPAND_ICON = AllIcons.Actions.Expandall - private val COLLAPSE_ICON = AllIcons.Actions.Collapseall - } -} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 461822b..135a3f8 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -156,13 +156,6 @@ - - - - - Date: Sun, 16 Aug 2026 01:56:03 +0400 Subject: [PATCH 21/41] feat(coverage): run the tests that cover a line, a declaration, a file or a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(coverage): select the file open in the editor in the Coverage view fix(coverage): highlight the hovered row in the covering-tests popup fix(coverage): keep the Tests column narrow and the format badges at the right Per-test coverage answers which tests touched which lines, and until now it only ever navigated. One launcher now turns any set of those tests into a run — explicit --filter selectors on a throwaway configuration, the shape rerun-failed already uses, under the coverage executor because seeing the coverage they produce is the point. It is offered from the line popup, from a gutter icon on every covered method, function and class, and from the Coverage panel for the selected file or directory, the last one folding a whole subtree into one set. The panel's own "Always select opened element" cannot work in a file-based coverage view: it hands the extension the PSI leaf under the caret and then looks for a tree node whose value equals it, while the nodes hold files and directories. PhpStorm's own coverage has the same dead button. The mapper it goes through is internal, and overriding it fails the verifier, so this adds a toggle of its own that follows the editor off the message bus and walks the tree with TreeUtil.promiseSelect. The Tests column was as wide as a percentage because the view sizes a column by whatever getPercentage answers for the root node, and ours fell through to the coverage string; it now answers with the count it displays. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 ++- CLAUDE.md | 26 ++++- .../coverage/TestoCoverageSelectOpenedFile.kt | 92 ++++++++++++++++++ .../coverage/TestoCoverageViewActions.kt | 95 ++++++++++++++++++- .../coverage/TestoCoverageViewExtension.kt | 28 ++++-- .../editor/TestoCoverageGutterRenderer.kt | 32 +++++++ .../TestoCoveringTestsLineMarkerProvider.kt | 87 +++++++++++++++++ .../perTest/TestoCoverageByTestData.kt | 13 +++ .../perTest/TestoCoveringTestsLauncher.kt | 50 ++++++++++ src/main/resources/META-INF/coverage.xml | 3 + .../resources/messages/TestoBundle.properties | 7 ++ .../perTest/TestoPerTestCoverageTest.kt | 12 +++ 12 files changed, 445 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageSelectOpenedFile.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsLauncher.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index de64811..575c3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,16 @@ - A Coverage run applies everything it produced on its own, one report per format, without a click. - 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, a switch for the editor highlighting, badges naming the report formats - behind the shown coverage, and a column counting the tests that cover each file. + behind the shown coverage (kept at the right end of the toolbar), and a narrow column counting the tests that cover + each file. +- *Run Covering Tests* on the Coverage panel runs, with coverage, the tests that cover the selected row — a file's own + tests, or every test under a directory. +- *Select Opened File* on the Coverage panel selects the file open in the editor, which the platform's *Always select + opened element* never managed to do in a file-based coverage view. +- A gutter icon on every covered method, function and class runs exactly the tests that cover it, with coverage; + the Coverage panel has a switch for those icons. +- The popup on a covered line highlights the row under the pointer and ends with a button running all of that line's + covering tests. - Every run is archived — its output, its reports and the parameters it ran with — and replays from the *Test History* button as a full Testo console: channels, statuses, report buttons and that run's own coverage. - *Show history* above a test replays the newest archived run containing that test and selects its node. diff --git a/CLAUDE.md b/CLAUDE.md index ecb12a6..0c0fb74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,15 @@ 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} @@ -227,8 +235,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. @@ -480,6 +488,18 @@ Non-obvious constraints already paid for in blood — read before touching the r `ExecutorRegistryImpl` sorting them into `RunContextGroupMore` unless the `executor.actions.submenu` registry key is off. That key is global and the actions are shared instances, so a copy of *Run with Coverage* at the popup's own level 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 hands + `CoverageViewExtension.getElementToSelect` the PSI *leaf* under the caret and then looks for a tree node whose value + equals it, while every node here holds a `PsiFile` or a `PsiDirectory` — so nothing ever matches (PhpStorm's own + coverage has the same dead button). That mapper, the view's select call and its tree are all `@ApiStatus.Internal`, + and overriding the mapper fails `verifyPlugin`. Hence `TestoCoverageSelectOpenedFile`: our own toggle, which follows + the editor off the message bus and walks the tree with `TreeUtil.promiseSelect`. The tree is reached through the + toolbar's target component, handed over from the action's `update`. +- **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)`**, so a column whose values are not percentages must + still answer there — the *Tests* column returns its count, or the view sizes it for "100% (1234/1234)". - **`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 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 0000000..de394d1 --- /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 index f3ca9e8..5959056 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt @@ -2,16 +2,26 @@ 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 @@ -20,6 +30,7 @@ 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 — @@ -44,10 +55,92 @@ internal class TestoCoverageHighlightToggleAction(private val project: Project) 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, + AllIcons.Toolwindows.ToolWindowRunWithCoverage, +), 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, DumbAware { +) : AnAction(), CustomComponentAction, RightAlignedToolbarAction, DumbAware { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT override fun actionPerformed(e: AnActionEvent) = Unit diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt index d886671..4a3b1c8 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt @@ -5,6 +5,7 @@ import com.github.xepozz.testo.coverage.format.CoverageFormat import com.github.xepozz.testo.coverage.format.TestId import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys +import com.github.xepozz.testo.coverage.perTest.testsUnder import com.intellij.coverage.CoverageBundle import com.intellij.coverage.CoverageSuitesBundle import com.intellij.coverage.view.DirectoryCoverageViewExtension @@ -26,6 +27,10 @@ class TestoCoverageViewExtension( private val annotator: TestoCoverageAnnotator, suitesBundle: CoverageSuitesBundle, ) : DirectoryCoverageViewExtension(project, annotator, suitesBundle) { + // Where the Tests column ended up, or -1 when it is not shown — the view asks for column values by index. + private var testsColumn = -1 + private var tests: TestsColumnInfo? = null + override fun createColumnInfos(): Array> { val columns = mutableListOf>( ElementColumnInfo(), @@ -35,24 +40,39 @@ class TestoCoverageViewExtension( val name = TestoBundle.message("testo.coverage.view.column.branches") columns.add(PercentageCoverageColumnInfo(BRANCHES_COLUMN, name, mySuitesBundle)) } + testsColumn = -1 + tests = null if (hasPerTestData()) { val testsByFile = TestoCoverageByTestIndex.getInstance(project).data().testsByFile() - if (testsByFile.isNotEmpty()) columns.add(TestsColumnInfo(testsByFile)) + if (testsByFile.isNotEmpty()) { + tests = TestsColumnInfo(testsByFile) + testsColumn = columns.size + columns.add(tests!!) + } } 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 tests?.valueOf(node) if (columnIdx != BRANCHES_COLUMN) return super.getPercentage(columnIdx, node) val file = extractFile(node) ?: return null return annotator.getBranchCoverageInformationString(file, mySuitesBundle) } + // @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), ) @@ -77,11 +97,7 @@ class TestoCoverageViewExtension( val key = TestoCoverageKeys.normalize(file.path) if (!file.isDirectory) return testsByFile[key]?.size return dirCounts.getOrPut(key) { - val prefix = "$key/" - testsByFile.entries.asSequence() - .filter { it.key.startsWith(prefix) } - .flatMapTo(HashSet()) { it.value } - .size + TestoCoverageByTestIndex.getInstance(project).data().testsUnder(key, isDirectory = true).size } } } 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 index 121fb75..636b3f6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt @@ -3,7 +3,9 @@ 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.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher import com.github.xepozz.testo.coverage.perTest.TestoTestIdentityMapper +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 @@ -22,12 +24,15 @@ 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 @@ -92,10 +97,26 @@ internal class TestoCoverageGutterRenderer( list.cellRenderer = SimpleListCellRenderer.create("") { "${it.fqcn.trimStart('\\')}::${it.method}" } 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) @@ -120,6 +141,14 @@ internal class TestoCoverageGutterRenderer( if (e.keyCode == KeyEvent.VK_ENTER) navigateSelected() } }) + runAll.addActionListener { + popup.cancel() + TestoCoveringTestsLauncher.run( + project, + tests, + TestoCoveringTestsLauncher.runName(lineSubject(), tests.size), + ) + } popup.show(at) } @@ -127,6 +156,9 @@ internal class TestoCoverageGutterRenderer( 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) 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 0000000..b9320ff --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt @@ -0,0 +1,87 @@ +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.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher +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.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.util.elementType +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 + val project = element.project + if (!TestoCoveringTestsGutter.getInstance(project).enabled) return null + + val tests = coveringTests(owner as PhpNamedElement) + 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 }, + { _, _ -> TestoCoveringTestsLauncher.run(project, tests, TestoCoveringTestsLauncher.runName(subject, tests.size)) }, + GutterIconRenderer.Alignment.LEFT, + { label }, + ) + } + + /** The tests that touched any line of the declaration — for a class, the union over everything it holds. */ + private fun coveringTests(owner: PhpNamedElement): Set { + val file = owner.containingFile ?: return emptySet() + val virtualFile = file.virtualFile ?: return emptySet() + val data = TestoCoverageByTestIndex.getInstance(owner.project).data() + val document = PsiDocumentManager.getInstance(owner.project).getDocument(file) ?: return emptySet() + val range = owner.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 data.testsCoveringRange(virtualFile.path, first..last) + } +} + +/** 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/perTest/TestoCoverageByTestData.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt index 2b22140..84c7809 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt @@ -28,6 +28,19 @@ interface TestoCoverageByTestData { } } +/** + * 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>, 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 0000000..0aa31f6 --- /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/resources/META-INF/coverage.xml b/src/main/resources/META-INF/coverage.xml index 271ba80..e07e112 100644 --- a/src/main/resources/META-INF/coverage.xml +++ b/src/main/resources/META-INF/coverage.xml @@ -4,5 +4,8 @@ + + diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 527c686..85031e0 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -67,6 +67,12 @@ testo.coverage.view.branches.covered={0}% branches covered ({1}/{2}) testo.tree.expand=Expand Selected or All testo.tree.collapse=Collapse Selected or All testo.coverage.view.toggle.highlight=Highlight Coverage in Editor +testo.coverage.view.toggle.gutters=Show "Run Covering Tests" in the Gutter +testo.coverage.view.select.opened=Select Opened File +testo.coverage.view.run.covering=Run Covering Tests +testo.coverage.view.run.covering.count=Run Covering Tests ({0}) +testo.coverage.gutter.run.covering=Run covering tests ({0}) +testo.coverage.covering.run.name=Tests covering {0} ({1}) testo.coverage.editor.status.full=Line covered testo.coverage.editor.status.partial=Line partially covered testo.coverage.editor.status.none=Line not covered @@ -75,6 +81,7 @@ testo.coverage.editor.status.branches=branches {0}/{1} testo.coverage.editor.popup.hits=Hits: {0} testo.coverage.editor.popup.branches=Branches: {0}/{1} testo.coverage.editor.popup.tests=Tests: {0} +testo.coverage.editor.popup.run.all=Run all {0} tests testo.coverage.editor.accessible.name=Testo code coverage testo.runs.history.action=Testo Run History… 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 index 80fbf36..e42d848 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/perTest/TestoPerTestCoverageTest.kt @@ -61,6 +61,18 @@ class TestoPerTestCoverageTest { 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() From 7d9788ed26e97dc3b536fc350eb5831cb29b7f6e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 16 Aug 2026 11:47:22 +0400 Subject: [PATCH 22/41] fix(coverage): paint the editor on the first coverage run of a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(coverage): make the Tests column answer for its own width feat(coverage): open the covering tests as a list before running anything The highlighter was installed from the annotator's onSuiteChosen, which the platform calls only when a bundle is reloaded or closed — never on the first chooseSuitesBundle of a session, so the first run of an IDE session stayed unpainted until a toggle forced a refresh. It is now installed where the bundle is handed over, before the data-calculated event it listens for. The Tests column stayed as wide as a percentage because the view sizes a column by what getPercentage answers for the root node, and the index it was compared against was remembered from createColumnInfos — on a different instance. CoverageView, CoverageTableModel and CoverageViewTreeStructure each build their own extension, so the column position is now derived from the bundle instead. A width the user has already been shown outlives this: it is saved per project in an internal state bean that wins over the computed one. A gutter icon that starts a whole test run on the first click is a trap, so it opens the list instead: run them all from the top row, or pick one. The rows drop the namespace — it is the same for every one of them — and one declaration can no longer be marked twice, the marker now being pinned to the declaration's own name node. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++- CLAUDE.md | 11 +++- .../testo/coverage/TestoCoverageActivation.kt | 5 ++ .../coverage/TestoCoverageViewActions.kt | 3 +- .../coverage/TestoCoverageViewExtension.kt | 61 +++++++++---------- .../editor/TestoCoverageGutterRenderer.kt | 3 +- .../TestoCoveringTestsLineMarkerProvider.kt | 13 +++- .../perTest/TestoCoveringTestsPopup.kt | 45 ++++++++++++++ .../TestoCoverageByTestCodeVisionProvider.kt | 3 +- .../resources/messages/TestoBundle.properties | 1 + 10 files changed, 111 insertions(+), 41 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 575c3c4..382c9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ tests, or every test under a directory. - *Select Opened File* on the Coverage panel selects the file open in the editor, which the platform's *Always select opened element* never managed to do in a file-based coverage view. -- A gutter icon on every covered method, function and class runs exactly the tests that cover it, with coverage; - the Coverage panel has a switch for those icons. +- A gutter icon on every covered method, function and class lists the tests that cover it — all of them in one run + from the top of the list, or one at a time — and the Coverage panel has a switch for those icons. - The popup on a covered line highlights the row under the pointer and ends with a button running all of that line's covering tests. - Every run is archived — its output, its reports and the parameters it ran with — and replays from the *Test History* @@ -43,6 +43,9 @@ - The elapsed time in the run summary no longer counts up forever when a run ends before the toolbar is wired. - Clearing the history now shows on the tab of the run it spares: its *Replay* menu says *Do not keep in history*, where it used to keep claiming the run was being kept. +- The first coverage run of an IDE session paints the editor right away, instead of waiting for something else to + refresh the highlighting. +- Lists of covering tests name the test class without its namespace, which is the same for every row anyway. ## [2026.5.262] - 2026-08-12 diff --git a/CLAUDE.md b/CLAUDE.md index 0c0fb74..eb7d3a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -499,7 +499,16 @@ Non-obvious constraints already paid for in blood — read before touching the r 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)`**, so a column whose values are not percentages must - still answer there — the *Tests* column returns its count, or the view sizes it for "100% (1234/1234)". + still answer there — the *Tests* column returns its count, or the view sizes it for "100% (1234/1234)". The width the + user ends up with is then remembered in `CoverageViewManager.StateBean.myColumnSize` (`@Internal`), which wins over + the computed one whenever the column count matches. +- **`CoverageViewExtension` is instantiated three times per view** — `CoverageView`, `CoverageTableModel` and + `CoverageViewTreeStructure` each call `createCoverageViewExtension`. Nothing one of them stores in a field is + visible to another, so 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 + when a bundle is *reloaded or closed* — the first `chooseSuitesBundle` of a session never calls it, so installing + there alone left the very first coverage run of an IDE session unpainted until something else forced a refresh. - **`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 diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt index 0507df0..f91c8ca 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt @@ -1,5 +1,6 @@ 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 @@ -44,6 +45,10 @@ fun applyTestoCoverage(project: Project, reports: List): Bo 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 } diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt index 5959056..c151891 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewActions.kt @@ -107,7 +107,8 @@ internal class TestoSelectOpenedFileAction(private val project: Project) : Toggl internal class TestoRunCoveringTestsAction(private val project: Project) : AnAction( TestoBundle.message("testo.coverage.view.run.covering"), null, - AllIcons.Toolwindows.ToolWindowRunWithCoverage, + // 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt index 4a3b1c8..bbd123f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt @@ -2,9 +2,7 @@ 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.format.TestId import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex -import com.github.xepozz.testo.coverage.perTest.TestoCoverageKeys import com.github.xepozz.testo.coverage.perTest.testsUnder import com.intellij.coverage.CoverageBundle import com.intellij.coverage.CoverageSuitesBundle @@ -27,10 +25,6 @@ class TestoCoverageViewExtension( private val annotator: TestoCoverageAnnotator, suitesBundle: CoverageSuitesBundle, ) : DirectoryCoverageViewExtension(project, annotator, suitesBundle) { - // Where the Tests column ended up, or -1 when it is not shown — the view asks for column values by index. - private var testsColumn = -1 - private var tests: TestsColumnInfo? = null - override fun createColumnInfos(): Array> { val columns = mutableListOf>( ElementColumnInfo(), @@ -40,28 +34,39 @@ class TestoCoverageViewExtension( val name = TestoBundle.message("testo.coverage.view.column.branches") columns.add(PercentageCoverageColumnInfo(BRANCHES_COLUMN, name, mySuitesBundle)) } - testsColumn = -1 - tests = null - if (hasPerTestData()) { - val testsByFile = TestoCoverageByTestIndex.getInstance(project).data().testsByFile() - if (testsByFile.isNotEmpty()) { - tests = TestsColumnInfo(testsByFile) - testsColumn = columns.size - columns.add(tests!!) - } - } + 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 tests?.valueOf(node) + 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, @@ -82,24 +87,16 @@ class TestoCoverageViewExtension( mySuitesBundle.suites.filterIsInstance().any { it.format == CoverageFormat.COVERAGE_XML } /** Distinct covering tests: the file's own set, a directory as the union over the files beneath it. */ - private inner class TestsColumnInfo( - private val testsByFile: Map>, - ) : ColumnInfo, String>(TestoBundle.message("testo.coverage.view.column.tests")) { - // One directory is asked for per visible row and per sort comparison — the union walk runs once per path. - private val dirCounts = HashMap() + 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? = countFor(node)?.takeIf { it > 0 }?.toString() + override fun valueOf(node: NodeDescriptor<*>): String? = count(node).takeIf { it > 0 }?.toString() - override fun getComparator(): Comparator> = compareBy { countFor(it) ?: -1 } + override fun getComparator(): Comparator> = compareBy { count(it) } - private fun countFor(node: NodeDescriptor<*>): Int? { - val file = (node as? AbstractTreeNode<*>)?.let { extractFile(it) } ?: return null - val key = TestoCoverageKeys.normalize(file.path) - if (!file.isDirectory) return testsByFile[key]?.size - return dirCounts.getOrPut(key) { - TestoCoverageByTestIndex.getInstance(project).data().testsUnder(key, isDirectory = true).size - } - } + private fun count(node: NodeDescriptor<*>): Int = counts.getOrPut(node) { countFor(node) ?: 0 } } companion object { 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 index 636b3f6..cd35891 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt @@ -5,6 +5,7 @@ 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.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 @@ -94,7 +95,7 @@ internal class TestoCoverageGutterRenderer( header.add(JBLabel(TestoBundle.message("testo.coverage.editor.popup.tests", tests.size))) val list = JBList(tests) - list.cellRenderer = SimpleListCellRenderer.create("") { "${it.fqcn.trimStart('\\')}::${it.method}" } + 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 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 index b9320ff..9fa1360 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt @@ -3,7 +3,7 @@ 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.TestoCoverageByTestIndex -import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsLauncher +import com.github.xepozz.testo.coverage.perTest.TestoCoveringTestsPopup import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProvider @@ -15,6 +15,7 @@ import com.intellij.openapi.project.Project import com.intellij.psi.PsiDocumentManager 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 @@ -33,10 +34,14 @@ class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { 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 = coveringTests(owner as PhpNamedElement) + val tests = coveringTests(owner).sortedBy { "${it.fqcn}::${it.method}" } if (tests.isEmpty()) return null val label = TestoBundle.message("testo.coverage.gutter.run.covering", tests.size) val subject = owner.name @@ -46,7 +51,9 @@ class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { element.textRange, AllIcons.Toolwindows.ToolWindowRunWithCoverage, { label }, - { _, _ -> TestoCoveringTestsLauncher.run(project, tests, TestoCoveringTestsLauncher.runName(subject, tests.size)) }, + { event, _ -> + TestoCoveringTestsPopup.show(project, tests, subject, null, RelativePoint(event)) + }, GutterIconRenderer.Alignment.LEFT, { label }, ) 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 0000000..48635b2 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt @@ -0,0 +1,45 @@ +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.editor.Editor +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, editor: Editor?, 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() + if (at != null) popup.show(at) else if (editor != null) popup.showInBestPositionFor(editor) + } +} diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index eafcf7d..c490037 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -4,6 +4,7 @@ import com.github.xepozz.testo.TestoIcons import com.github.xepozz.testo.coverage.format.TestId import com.github.xepozz.testo.coverage.perTest.TestoCoverageByTestIndex import com.github.xepozz.testo.coverage.perTest.TestoTestIdentityMapper +import com.github.xepozz.testo.coverage.perTest.shortTestLabel import com.intellij.codeInsight.codeVision.CodeVisionAnchorKind import com.intellij.codeInsight.codeVision.CodeVisionEntry import com.intellij.codeInsight.codeVision.CodeVisionRelativeOrdering @@ -68,7 +69,7 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { val popup = JBPopupFactory.getInstance() .createPopupChooserBuilder(tests) .setTitle(if (tests.size == 1) "1 Covering Test" else "${tests.size} Covering Tests") - .setRenderer(SimpleListCellRenderer.create("") { "${it.fqcn.trimStart('\\')}::${it.method}" }) + .setRenderer(SimpleListCellRenderer.create("") { shortTestLabel(it) }) .setItemChosenCallback { id -> (mapper.resolve(id, project) as? Navigatable)?.takeIf { it.canNavigate() }?.navigate(true) } diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 85031e0..2670635 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -72,6 +72,7 @@ testo.coverage.view.select.opened=Select Opened File testo.coverage.view.run.covering=Run Covering Tests testo.coverage.view.run.covering.count=Run Covering Tests ({0}) testo.coverage.gutter.run.covering=Run covering tests ({0}) +testo.coverage.popup.title=Tests covering {0} testo.coverage.covering.run.name=Tests covering {0} ({1}) testo.coverage.editor.status.full=Line covered testo.coverage.editor.status.partial=Line partially covered From 29861a85347dd1712a90904c5328723d7e4b2f07 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 16 Aug 2026 14:27:54 +0400 Subject: [PATCH 23/41] feat(run): suite and group names as tags, groups picked from what the project declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(run): a Coverage group of its own, with the analysis level and coverage-only options fix(run): exclude a group as --group=!name — Testo's CLI has no --exclude-group refactor(run): drop the Repeat and Command fields, park Parallel beside the runner's own flags test(coverage): pin branch data surviving the merge of several reports An audit of Testo's own option definitions — `Run::configure` plus every `#[InputOption]` — says the CLI takes exactly config, teamcity, json, log-json, filter, path, suite, type, group, coverage, no-coverage, coverage-level, log-junit, log-html, log-report and the three coverage-* report flags. So `--exclude-group` and `--repeat` were being sent to a runner that would abort on them, and `--parallel` has no flag yet: exclusion now goes out as `--group=!name`, Repeat is gone, and Parallel is parked at 1 (the value that sends nothing) until the flag exists. A name is opaque to the plugin — whatever `#[Group]` spells reaches the CLI untouched — so the editor had no separator it could safely own. Groups and suites are tags now: groups are picked from `TestoGroupsIndex`, a file-based index of every name a `#[\Testo\Filter\Group]` in the project spells, and suites are typed and added with Enter. Suites became a list in the model at the same time (one `--suite` per name); a configuration saved with a single suite migrates without being split. Command went with the rest: a test run is always `testo run`, and the whole console — `--teamcity`, the tree, the channels — only makes sense for it. Other subcommands are what the Run Anything provider is for. Parallel sits in the PHP editor's own *Test Runner options* row rather than below its panel, which is where the flag belongs. `PhpTestRunConfigurationEditor` hands out the whole form and nothing smaller, so the row is found by its label, its one-row `GridLayoutManager` is rebuilt with a second row, and the platform's children are re-added with the constraints it gives back — that shared label column is what lines the two rows up. A form change on PhpStorm's side only drops the field back below the panel. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 ++ CLAUDE.md | 29 ++- .../coverage/TestoCoverageProgramRunner.kt | 14 +- .../xepozz/testo/index/TestoGroupsIndex.kt | 79 ++++++++ .../testo/tests/run/TestoRunConfiguration.kt | 10 +- .../tests/run/TestoRunConfigurationHandler.kt | 18 +- .../run/TestoRunConfigurationProducer.kt | 8 +- .../testo/tests/run/TestoRunnerSettings.kt | 45 ++++- .../xepozz/testo/tests/run/TestoTagsField.kt | 137 +++++++++++++ .../run/TestoTestRunConfigurationEditor.kt | 187 ++++++++++++------ src/main/resources/META-INF/plugin.xml | 2 + .../resources/messages/TestoBundle.properties | 9 + .../xepozz/testo/TestoGroupsIndexPsiTest.kt | 55 ++++++ .../testo/TestoRunConfigurationHandlerTest.kt | 49 ++--- .../TestoRunnerSettingsSerializationTest.kt | 29 +++ .../xepozz/testo/TestoRunnerSettingsTest.kt | 28 +-- .../coverage/TestoCoverageArgumentsTest.kt | 29 +++ .../coverage/TestoCoverageProjectDataTest.kt | 19 ++ 18 files changed, 634 insertions(+), 130 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/index/TestoGroupsIndex.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 382c9c9..beeac72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,9 +35,25 @@ the icon of what the run was: run, debug or coverage. - How many runs the history keeps is set from the history list itself, right above the button that clears it. - An imported run comes in locked, so retention never deletes the one copy of a run carried in from elsewhere. +- The run configuration keeps its coverage settings in a group of their own, with the analysis level + (`--coverage-level`, or *auto* to leave it to testo.php) and options only a Coverage run adds — benchmarks are kept + out of coverage by default. +- Group and Exclude group are lists of tags, added from the `#[Group]` names the project declares rather than typed + with commas. +- Suite is a list of tags too — typed and added with Enter — and a run may now narrow to several suites at once, one + `--suite` flag each. A configuration saved with a single suite keeps it. +- The run configuration is laid out in groups: Parallel sits under Test Runner Options, then Run Options, Filter and + Coverage. + +### Removed + +- The Repeat field: Testo's command line has no `--repeat`, so it never did anything. +- The Command field: a test run is always `testo run`, and other subcommands are what *Run Anything* is for. +- Parallel no longer sends a flag Testo does not have: the field is parked at 1 (no `--parallel`) until it does. ### Fixed +- 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: report paths now resolve off the UI thread. - The elapsed time in the run summary no longer counts up forever when a run ends before the toolbar is wired. @@ -46,6 +62,7 @@ - The first coverage run of an IDE session paints the editor right away, instead of waiting for something else to refresh the highlighting. - Lists of covering tests name the test class without its namespace, which is the same for every row anyway. +- Excluding a group runs again: it goes out as `--group=!name`, the only form Testo's command line has. ## [2026.5.262] - 2026-08-12 diff --git a/CLAUDE.md b/CLAUDE.md index eb7d3a5..a697dcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ ├── 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/ @@ -180,6 +181,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 @@ -261,16 +263,17 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor - `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. + `--parallel`, plus one `--filter ` per entry in `rerunFilters`. `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. - `--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 - INI options depending on `coverageEngine`. +- Coverage adds one `--coverage-=` per checked report (or bare `--coverage` if no path), + `--coverage-level=` unless the level is *auto*, 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`. `methodName` is an encoded selector, not just a name: @@ -514,9 +517,11 @@ Non-obvious constraints already paid for in blood — read before touching the r - **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 +- **Suite and group names are lists everywhere.** `TestoRunnerSettings` persists `suites`, `groups`/`excludeGroups` + via `@XCollection` and the editor shows them as tags (`TestoTagsField` — groups pick from what `TestoGroupsIndex` + found, suites are typed and added with Enter), so a name is never split on anything: a comma or a space inside one + survives. The comma lives only in the pre-list persisted form, and a legacy `suite="x"` becomes a one-item list + without being split at all. `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. - **`rerunFilters` is `@Transient`** — it lives only on the throwaway clone a "rerun failed" launch creates, and @@ -527,6 +532,12 @@ Non-obvious constraints already paid for in blood — read before touching the r 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.** `PhpTestRunConfigurationEditor` hands out the whole panel + and nothing smaller, and its *Test Runner options* row is a one-row `GridLayoutManager` built by the UI designer, so + a row cannot be added to it. `injectParallelRow` finds that row by its label (the bundle string carries a `&` + mnemonic the rendered label does not), rebuilds the row's layout with a second row and re-adds the existing children + with the constraints `getConstraintsForComponent` gives back — which is what keeps the label column shared, and the + two rows aligned. It falls back to a row of our own below the panel, so a PhpStorm form change only moves the field. - **`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/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt index b9a1343..a0bf905 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -10,6 +10,7 @@ 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.RunProfileState import com.intellij.execution.configurations.RunnerSettings @@ -63,7 +64,7 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { val localCoverage = coverageConfiguration.coverageFilePath val settings = runConfiguration.testoSettings.getTestoRunnerSettings() val flags = coverageFlagLocalPaths(settings, localCoverage) - val coverageArguments = when { + 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") @@ -71,6 +72,7 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { coverageFlagFor(format, toTargetPath(runConfiguration, interpreter, local)) } } + val coverageArguments = reportArguments + extraCoverageArguments(settings) val command = createTestoCoverageCommand( runConfiguration, @@ -109,6 +111,16 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { } } + /** + * The analysis level and the configuration's coverage-only options — everything a Coverage run adds beyond the + * report flags. The level is left out when set to auto: the one configured in testo.php then stands. + */ + fun extraCoverageArguments(settings: TestoRunnerSettings): List = buildList { + val level = settings.coverageLevel.trim() + if (level.isNotEmpty() && level != TestoRunnerSettings.COVERAGE_LEVEL_AUTO) add("--coverage-level=$level") + addAll(ParametersList.parse(settings.coverageOptions)) + } + fun coverageFlagFor(format: CoverageFormat, targetCoverage: String): String = when (format) { CoverageFormat.CLOVER -> "--coverage-clover=$targetCoverage" CoverageFormat.COBERTURA -> "--coverage-cobertura=$targetCoverage" 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 0000000..f94a2ba --- /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/tests/run/TestoRunConfiguration.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunConfiguration.kt index 220ad17..477f907 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 @@ -60,7 +60,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 +117,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 +136,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) } 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 6216c71..43cd6ad 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,25 +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()) + arguments.add("--group") + arguments.add(if (group.startsWith("!")) group else "!$group") } - if (runner.parallel > 0) { + if (runner.parallel != 1) { arguments.add("--parallel") arguments.add(runner.parallel.toString()) } 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 3a9771b..5fc7d56 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, ) : 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() @@ -59,6 +69,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() @@ -73,9 +87,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, @@ -110,17 +135,19 @@ 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.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 0000000..d1396fd --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt @@ -0,0 +1,137 @@ +package com.github.xepozz.testo.tests.run + +import com.github.xepozz.testo.TestoBundle +import com.intellij.icons.AllIcons +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. + * + * Names either come from a popup of what the project declares ([suggestions]) or are typed into an inline field. + */ +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 + } + + /** The no-suggestions half: a name is typed and Enter turns it into a tag, ready for the next one. */ + 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 known = suggestions?.invoke().orEmpty().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 f31a71b..27b3b39 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,5 +1,7 @@ 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 @@ -10,10 +12,20 @@ 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 @@ -22,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) { @@ -40,30 +63,34 @@ class TestoTestRunConfigurationEditor( 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() - // Held disabled until Testo grows a level flag (cobertura already raises the level to Branch on its own). - private val coverageLevelField = ComboBox(arrayOf("Line", "Branch", "Path")).apply { - selectedItem = "Line" + // Held disabled until Testo can be asked for the report the plugin needs; nothing is persisted meanwhile. + private val htmlReportBox = JBCheckBox("Build an HTML report").apply { isEnabled = false - toolTipText = "Coverage level selection is not supported by Testo yet" + toolTipText = "Not supported by Testo yet" } + /** Parallel belongs beside the other runner flags, so it is put into the PHP form's own Test Runner options row. */ + private val parallelInjected = injectParallelRow() + private val myMainPanel = panel { row { cell(parentEditor.component) .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) @@ -71,7 +98,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") @@ -80,7 +107,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") @@ -89,35 +116,37 @@ 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") - .gap(RightGap.COLUMNS) - cell(repeatField) + cell(htmlReportBox) } .layout(RowLayout.PARENT_GRID) - .rowComment("--repeat= (0 = disabled)") + .rowComment("Not supported by Testo yet") + } + 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) - .align(AlignX.FILL) + cell(coverageLevelField) } .layout(RowLayout.PARENT_GRID) - .rowComment("Engine used to collect code coverage") + .rowComment("--coverage-level=; auto leaves the level to testo.php. Branch and path need Xdebug") row { - label("Coverage reports") + label("Reports") .gap(RightGap.COLUMNS) cell(coverageCloverBox) cell(coverageCoberturaBox) @@ -127,12 +156,13 @@ class TestoTestRunConfigurationEditor( .rowComment("Reports a Coverage run requests: --coverage-clover / --coverage-cobertura / --coverage-xml; all are applied together") row { - label("Coverage level") + label("Additional options") .gap(RightGap.COLUMNS) - cell(coverageLevelField) + cell(coverageOptionsField) + .align(AlignX.FILL) } .layout(RowLayout.PARENT_GRID) - .rowComment("Not supported by Testo yet; Cobertura raises the level to Branch on its own") + .rowComment("Arguments added to Coverage runs only, e.g. --coverage-level=branch. The default keeps benchmarks out of coverage") } } @@ -142,47 +172,47 @@ 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() } 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 || 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 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 @@ -204,21 +234,66 @@ 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.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 `GridLayoutManager` form built by PhpStorm with exactly one row and no seam to extend — the + * editor exposes the whole panel and nothing smaller. The row is found by the label the platform gave it, its + * layout is rebuilt with a second row, and its children are re-added with the constraints they already had, so + * the label column stays shared and the two rows line up. Anything unexpected and the caller falls back to a + * row of our own below the panel; nothing here can leave the form half-built, since the swap is one step. + */ + 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/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 135a3f8..34db959 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -80,6 +80,8 @@ implementation="com.github.xepozz.testo.tests.console.TestoRepeatedFrameFolding"/> + diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 2670635..3590cab 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -134,3 +134,12 @@ actions.new.test.action.name=Testo Test actions.new.test.action.description=Creates new Testo Test php.testo.run.configuration.rerun.incorrect.configuration=Expected Testo run-configuration type, got: ''{0}'' + +testo.tags.remove=Remove "{0}" +testo.tags.custom=Type a name… +testo.tags.custom.prompt=Group name: +testo.tags.groups.empty=No groups +testo.tags.groups.add=Add a group +testo.tags.groups.exclude.add=Exclude a group +testo.tags.suites.empty=No suites +testo.tags.suites.add=Suite name, Enter to add diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt new file mode 100644 index 0000000..b11d843 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoGroupsIndexPsiTest.kt @@ -0,0 +1,55 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.index.TestoGroupsIndex +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.lang.PhpFileType + +/** + * What the group index reads out of one file. `#[Group]` is variadic and may sit on a class, a method or a standalone + * function, so a file contributes a set — the index has no per-declaration structure to keep. + */ +class TestoGroupsIndexPsiTest : BasePlatformTestCase() { + + fun testNamesAreCollectedFromEveryDeclarationAndDeduplicated() { + val file = myFixture.configureByText( + PhpFileType.INSTANCE, + """() 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,21 +188,18 @@ 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() { @@ -217,40 +214,46 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertEquals("8", arguments[1]) } - fun testPrepareArguments_zeroRepeatAndParallel_skipped() { + fun testPrepareArguments_defaultParallel_skipped() { + val settings = TestoRunConfigurationSettings() + settings.runnerSettings.parallel = 1 + val arguments = mutableListOf() + + TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) + + assertTrue("One worker is the default and needs no flag", arguments.isEmpty()) + } + + /** 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(10, 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("!slow")) assertTrue(arguments.contains("--parallel")) assertTrue(arguments.contains("4")) } @@ -311,7 +314,7 @@ 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() diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunnerSettingsSerializationTest.kt index 6a6be98..96d6fec 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 f0fa6ef..bbd1c17 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 0f64ff1..25ea58a 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt @@ -71,6 +71,35 @@ class TestoCoverageArgumentsTest : TestoCoverageProgramRunner() { assertEquals(Path.of("/tmp/r-coverage-xml/index.xml"), files[1]) } + @Test + fun coverageOnlyOptionsDefaultToExcludingBenchmarks() { + assertEquals(listOf("--type=!bench"), extraCoverageArguments(TestoRunnerSettings())) + } + + @Test + fun coverageOnlyOptionsAreSplitLikeACommandLine() { + val settings = TestoRunnerSettings(coverageOptions = """--type=!bench --filter "a b"""") + + assertEquals(listOf("--type=!bench", "--filter", "a b"), extraCoverageArguments(settings)) + } + + @Test + fun emptyCoverageOnlyOptionsAddNothing() { + assertTrue(extraCoverageArguments(TestoRunnerSettings(coverageOptions = " ", coverageLevel = "auto")).isEmpty()) + } + + @Test + fun autoLevelSendsNoLevelFlag() { + assertTrue(extraCoverageArguments(TestoRunnerSettings()).none { it.startsWith("--coverage-level") }) + } + + @Test + fun chosenLevelLeadsTheCoverageOnlyArguments() { + val settings = TestoRunnerSettings(coverageLevel = "branch") + + assertEquals(listOf("--coverage-level=branch", "--type=!bench"), extraCoverageArguments(settings)) + } + @Test fun executorIdIsCoverage() { assertEquals("Coverage", EXECUTOR_ID) diff --git a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt index 7a6cd49..2b90673 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectDataTest.kt @@ -6,6 +6,7 @@ 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 @@ -68,6 +69,24 @@ class TestoCoverageProjectDataTest { 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() { From 257dd096ebc7615266040595b7e8367ca69bb82b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 16 Aug 2026 16:17:16 +0400 Subject: [PATCH 24/41] feat(console): the Log Levels filter beside the output it filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown leaves the test toolbar for the console's own vertical strip (print, clear, scroll to end), after a separator, wearing a filter icon. That strip is the only free seam: TestResultsPanel builds it once from a DefaultActionGroup copy of its `protected final` actions array, and the group stays mutable through the public ActionToolbar.getActionGroup(). The tab-row entry point (TabInfo.setTabPaneActions) was tried and dropped — it costs a row of its own. The action now takes its LogLevelFilter in the constructor: installed next to the tabs it filters, it no longer needs to fish the filter out of the action context. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +++ CLAUDE.md | 12 +++++-- .../testo/tests/TestoConsoleProperties.kt | 13 ++++--- .../testo/tests/console/TestoChannelsUi.kt | 19 ++++++++++ .../console/TestoLogLevelFilterAction.kt | 35 ++++--------------- 5 files changed, 46 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beeac72..e1bd4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,11 @@ - The Command field: a test run is always `testo run`, and other subcommands are what *Run Anything* is for. - Parallel no longer sends a flag Testo does not have: the field is parked at 1 (no `--parallel`) until it does. +### Changed + +- The *Log Levels* filter moved off the test toolbar onto the console's own toolbar, right of the channel tabs, and + wears a filter icon. + ### Fixed - The Test Runner Options help button opens Testo's CLI reference. diff --git a/CLAUDE.md b/CLAUDE.md index a697dcf..d89345c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── 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 +│ │ ├── TestoLogLevelFilterAction.kt # dropdown for the filter, on the console's own vertical toolbar │ │ ├── TestoChannelsUi.kt # the tabbed channel view (~1150 lines) + testoDisplayName() │ │ ├── TestoConsoleAugmenter.kt # ExecutionListener that installs the channel tabs │ │ ├── TestoReplaySelection.kt # selects a test's node once the replayed tree stops growing @@ -468,8 +468,14 @@ Non-obvious constraints already paid for in blood — read before touching the r - **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. -- **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. +- **The console's own vertical toolbar is reached through `ActionToolbar.getActionGroup()`.** `TestResultsPanel` takes + its console actions as a `protected final AnAction[]` and builds that toolbar once, from + `new DefaultActionGroup(myConsoleActions)` — so the array is closed, but the group it was copied into is not. The + log-level filter is appended there (after a separator) at install time, then `updateActionsAsync()`. The other seam, + `TabInfo.setTabPaneActions` (the entry-point strip at the right edge of the tab row, read off the **selected** tab — + so it would have to be set on every tab), was tried and dropped: it costs a row of its own. +- **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`.** That array is the *only* source of the "Test History" button above the test tree (`ToolbarPanel` adds nothing else of its own): `SMTRunnerConsoleProperties` returns `ImportTestsGroup` + `ImportTestsFromFileAction` there, both opening a saved XML through the platform import — a 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 a1a01ee..e88655f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -126,16 +126,15 @@ 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( // Laid out from the right edge inwards: listed first = furthest right. So this array reads right to left — - // the log-level filter sits at the right end of the group, expand/collapse at its left, next to the - // separator that follows Show Passed / Show Ignored. - com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), + // the replay group sits at the right end, expand/collapse at its left, next to the separator that follows + // Show Passed / Show Ignored. // This run's own archive: export it, decide what retention may do with it, load an exported one. 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 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 465fa4c..984cf48 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,8 @@ 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.ActionToolbar +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditor @@ -1108,6 +1110,7 @@ object TestoChannelsUi { }) addComponentTab(tabbed, OUTPUT_TAB, AllIcons.Debugger.Console, original) holder.add(tabbed.component, BorderLayout.CENTER) + installLevelFilter(holder) holder.revalidate() holder.repaint() tabs = tabbed @@ -1115,6 +1118,22 @@ object TestoChannelsUi { return tabbed } + // The log-level filter goes onto the vertical strip already sitting to the right of the output (print, clear, + // scroll to end) rather than onto a row of its own: it filters what that area shows, and the strip costs no + // extra space. TestResultsPanel wraps its console actions in a plain DefaultActionGroup and keeps no other way + // in — the array itself is `protected final`, and the toolbar is built from it once, at construction. + private fun installLevelFilter(holder: java.awt.Container) { + val toolbar = holder.components.firstNotNullOfOrNull { it as? ActionToolbar } + val group = toolbar?.actionGroup as? DefaultActionGroup + if (group == null) { + thisLogger().warn("Testo log level filter disabled: no console action toolbar beside the output") + return + } + group.addSeparator() + group.add(TestoLogLevelFilterAction(levelFilter)) + toolbar.updateActionsAsync() + } + companion object { private const val ALL_TAB = "All" private const val OUTPUT_TAB = "Output" 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 8ccbca0..35cf499 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,48 +1,36 @@ 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.ToggleAction import com.intellij.openapi.project.DumbAware /** - * Toolbar dropdown that toggles which log levels the channel consoles show. "All" flips every seen level on/off at once; + * 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. * - * 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. + * Lives on the console's own vertical toolbar, right of the channel tabs (see [TestoChannelsUi]) — beside the output + * it filters, rather than on the test results toolbar which has nothing to do with channel output. */ -// 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, + private val filter: LogLevelFilter, ) : ActionGroup(), DumbAware { init { isPopup = true - templatePresentation.icon = AllIcons.Actions.Show + templatePresentation.icon = AllIcons.General.Filter templatePresentation.text = TestoBundle.message("testo.console.loglevel.filter.title") } override fun getActionUpdateThread() = ActionUpdateThread.EDT - override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = resolveFilter(e) != null - } - - 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()) { @@ -54,9 +42,8 @@ class TestoLogLevelFilterAction( 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 isSelected(e: AnActionEvent) = filter.isAllEnabled() override fun setSelected(e: AnActionEvent, state: Boolean) { - val filter = resolveFilter(e) ?: return if (state) filter.enableAll() else filter.disableAll() filter.fireChange() } @@ -64,22 +51,14 @@ class TestoLogLevelFilterAction( private inner class LevelToggle(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.isHidden(level) override fun setSelected(e: AnActionEvent, state: Boolean) { - val filter = resolveFilter(e) ?: return filter.setHidden(level, !state) 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", From ec2488f02c1b2c8f9cd564c578748ac6066a20da Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 16 Aug 2026 16:52:41 +0400 Subject: [PATCH 25/41] docs: comments cut to their constraints; CLAUDE.md and CHANGELOG condensed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(run): stop sending --parallel — Testo's CLI does not take it, and a legacy value broke every run fix(runs): Clear History spares a run another tab is still writing, with the same day of grace prune gives fix(runs): `::testPay` no longer answers for `::testPayment` — the history lens, the index and the replay selection require a delimiter after the prefix fix(console): run-archive write failures log once and disable recording for the run instead of silently retrying on every line fix(console): selecting a node in a replay no longer throws when the tab was closed while the log streamed refactor(coverage): drop the write-only TestoCoverageSuite.perTest, the dead popup editor parameter and a duplicate catch The `arch §N` / `report-formats §N` citations are gone from comments: they point at the untracked docs/, which a reader of the repository cannot open, and every cited constraint was already stated inline. Assisted-By: Claude Fable 5 --- CHANGELOG.md | 77 +++++------- CLAUDE.md | 119 ++++++++---------- .../testo/coverage/TestoCoverageActivation.kt | 2 +- .../testo/coverage/TestoCoverageAutoApply.kt | 5 +- .../testo/coverage/TestoCoverageEngine.kt | 7 +- .../coverage/TestoCoverageProgramRunner.kt | 1 - .../coverage/TestoCoverageProjectData.kt | 2 +- .../testo/coverage/TestoCoverageRunner.kt | 6 +- .../coverage/TestoCoverageViewExtension.kt | 2 - .../editor/TestoCoverageEditorHighlighter.kt | 4 +- .../editor/TestoCoverageGutterRenderer.kt | 7 +- .../TestoCoveringTestsLineMarkerProvider.kt | 2 +- .../coverage/format/CloverCoverageParser.kt | 2 +- .../format/CoberturaCoverageParser.kt | 1 - .../testo/coverage/format/CoverageModel.kt | 8 +- .../coverage/format/CoverageXmlParser.kt | 2 +- .../perTest/TestoCoverageByTestData.kt | 5 +- .../perTest/TestoCoverageByTestIndex.kt | 8 +- .../perTest/TestoCoveringTestsPopup.kt | 5 +- .../perTest/TestoTestIdentityMapper.kt | 4 +- .../xepozz/testo/runs/TestoReplayGroup.kt | 1 - .../xepozz/testo/runs/TestoRunArchiver.kt | 1 - .../testo/runs/TestoRunHistoryActions.kt | 4 +- .../github/xepozz/testo/runs/TestoRunStore.kt | 8 +- .../testo/tests/TestoConsoleProperties.kt | 10 +- .../actions/TestoRerunWithExecutorAction.kt | 1 - .../testo/tests/console/TestoHistoryIndex.kt | 3 +- .../console/TestoLogLevelFilterAction.kt | 3 +- .../TestoOutputToGeneralEventsConverter.kt | 15 ++- .../tests/console/TestoReplaySelection.kt | 17 +-- .../testo/tests/console/TestoReportAction.kt | 5 +- .../tests/run/TestoRunConfigurationHandler.kt | 5 +- .../xepozz/testo/tests/run/TestoTagsField.kt | 3 - .../run/TestoTestRunConfigurationEditor.kt | 10 +- .../TestoCoverageByTestCodeVisionProvider.kt | 2 +- .../ui/TestoHistoryCodeVisionProvider.kt | 2 - .../testo/TestoRunConfigurationHandlerTest.kt | 26 +--- .../coverage/format/CoverageParserTest.kt | 5 +- 38 files changed, 156 insertions(+), 234 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bd4e9..b42cb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,67 +7,52 @@ ### Added - The run configuration chooses which coverage reports a Coverage run asks Testo for: Clover, Cobertura, coverage-xml. -- A Coverage run applies everything it produced on its own, one report per format, without a click. +- 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, a switch for the editor highlighting, badges naming the report formats - behind the shown coverage (kept at the right end of the toolbar), and a narrow column counting the tests that cover - each file. -- *Run Covering Tests* on the Coverage panel runs, with coverage, the tests that cover the selected row — a file's own - tests, or every test under a directory. -- *Select Opened File* on the Coverage panel selects the file open in the editor, which the platform's *Always select - opened element* never managed to do in a file-based coverage view. -- A gutter icon on every covered method, function and class lists the tests that cover it — all of them in one run - from the top of the list, or one at a time — and the Coverage panel has a switch for those icons. -- The popup on a covered line highlights the row under the pointer and ends with a button running all of that line's - covering tests. -- Every run is archived — its output, its reports and the parameters it ran with — and replays from the *Test History* - button as a full Testo console: channels, statuses, report buttons and that run's own coverage. -- *Show history* above a test replays the newest archived run containing that test and selects its node. -- How many archived runs to keep is set in *Tools | Testo*; the history list clears itself from its own menu. +- 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. +- Every run is archived — output, reports and parameters — and replays from *Test History* as a full Testo console. +- *Show history* above a test replays the newest archived run containing it and selects its node. +- 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 coverage run reruns with coverage. -- A *Replay* button on the test toolbar exports the run as a single archive, imports one back, shows the run's own - folder in the file manager, and says what the history may do with it: keep it, drop it, or lock it so retention - never touches it. The button wears the icon of what the run was — run, debug or coverage. -- Every report a run announces is archived with it — an HTML report travels with its assets — so a replayed run opens - its own reports rather than whatever the latest run left behind. -- The history list marks the run the tab is showing in bold, and puts a lock on the locked ones, and each entry wears - the icon of what the run was: run, debug or coverage. -- How many runs the history keeps is set from the history list itself, right above the button that clears it. -- An imported run comes in locked, so retention never deletes the one copy of a run carried in from elsewhere. -- The run configuration keeps its coverage settings in a group of their own, with the analysis level - (`--coverage-level`, or *auto* to leave it to testo.php) and options only a Coverage run adds — benchmarks are kept - out of coverage by default. -- Group and Exclude group are lists of tags, added from the `#[Group]` names the project declares rather than typed - with commas. -- Suite is a list of tags too — typed and added with Enter — and a run may now narrow to several suites at once, one - `--suite` flag each. A configuration saved with a single suite keeps it. -- The run configuration is laid out in groups: Parallel sits under Test Runner Options, then Run Options, Filter and +- 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`, so it never did anything. -- The Command field: a test run is always `testo run`, and other subcommands are what *Run Anything* is for. -- Parallel no longer sends a flag Testo does not have: the field is parked at 1 (no `--parallel`) until it does. +- 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 -- The *Log Levels* filter moved off the test toolbar onto the console's own toolbar, right of the channel tabs, and - wears a filter icon. +- The *Log Levels* filter moved onto the console's own toolbar, right of the channel tabs, with a filter icon. ### Fixed - 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: report paths now resolve off the UI thread. +- 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 now shows on the tab of the run it spares: its *Replay* menu says *Do not keep in history*, - where it used to keep claiming the run was being kept. -- The first coverage run of an IDE session paints the editor right away, instead of waiting for something else to - refresh the highlighting. -- Lists of covering tests name the test class without its namespace, which is the same for every row anyway. -- Excluding a group runs again: it goes out as `--group=!name`, the only form Testo's command line has. +- 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 diff --git a/CLAUDE.md b/CLAUDE.md index d89345c..1e1ea18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,11 +259,12 @@ 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`, - `--parallel`, plus one `--filter ` per entry in `rerunFilters`. `suites`, `groups`/`excludeGroups` are persisted +- 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. @@ -458,92 +459,70 @@ Non-obvious constraints already paid for in blood — read before touching the r - **`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`. -- **History is replayed, not imported.** The platform's import forces `ImportedTestConsoleProperties` and - `ImportedToGeneralTestEventsConverter`, so neither our console nor our converter runs — an imported tab is a - PHPUnit-looking tree with none of our toolbar. Replaying the archived teamcity stream through the *live* - properties (`TestoRunReplayProfile`) rebuilds everything instead, because every store is filled by the converter. - A replay is kept from acting like a run by three switches: `replayMode` (no re-recording), `getConfiguration()` - answering the replay profile (the platform's `addToHistory` saves only for a real `RunConfiguration`), and - `reportStore.startedAtOverride` (the captured report copies must pass the mtime-vs-start gate). +- **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. -- **The console's own vertical toolbar is reached through `ActionToolbar.getActionGroup()`.** `TestResultsPanel` takes - its console actions as a `protected final AnAction[]` and builds that toolbar once, from - `new DefaultActionGroup(myConsoleActions)` — so the array is closed, but the group it was copied into is not. The - log-level filter is appended there (after a separator) at install time, then `updateActionsAsync()`. The other seam, - `TabInfo.setTabPaneActions` (the entry-point strip at the right edge of the tab row, read off the **selected** tab — - so it would have to be set on every tab), was tried and dropped: it costs a row of its own. +- **The console's own vertical toolbar is reached through `ActionToolbar.getActionGroup()`.** `TestResultsPanel` + builds it once from a `DefaultActionGroup` copy of its `protected final` actions array — the array is closed, the + group is not. The log-level filter is appended there, then `updateActionsAsync()`. The tab-row seam + (`TabInfo.setTabPaneActions`, read off the selected tab only) was tried and dropped: it costs a row of its own. - **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`.** That array is the *only* source of the "Test History" - button above the test tree (`ToolbarPanel` adds nothing else of its own): `SMTRunnerConsoleProperties` returns - `ImportTestsGroup` + `ImportTestsFromFileAction` there, both opening a saved XML through the platform import — a - console with none of our UI. We return `TestoRunHistoryGroup` instead, which lists the run archive and replays it. - Actions without `RunTab.PREFERRED_PLACE = MORE_GROUP` land on the visible toolbar row, so no experimental key is - needed. Dropping `super` also drops the platform's "Import Test Results from file" from Testo tabs — deliberate: - our own export/import is the *Replay* group, and the platform's counterpart opens a console with none of our UI. - Its export half is gone for the same reason (`getConfiguration()` answers the replay profile, and `ToolbarPanel` - only builds `ExportTestResultsAction` for a real `RunConfiguration`). The same array is how expand/collapse reach - the visible row. The array is laid out right-to-left (listed first = furthest right), which is the only control - over placement there — everything in it lands after the platform's own actions. -- **What the platform put on the test toolbar cannot be moved or removed** — the sort popup, the separator after - *Show Ignored*, the expand/collapse inside the overflow group. `ToolbarPanel` builds those inline (no ids, no - extension point, no `CustomActionsSchema` entry) and then copies both of its groups into `actionsToMerge` / - `additionalActionsToMerge`, which is what `RunTab` rebuilds the tab's toolbar from — so mutating the live groups - afterwards changes nothing the user sees. An invisible action riding the toolbar was tried and reverted; the only - control we have there is our own `createImportActions` array. -- **Every executor but Run and Debug is hidden behind the "More Run/Debug" submenu** of a run-context popup, by - `ExecutorRegistryImpl` sorting them into `RunContextGroupMore` unless the `executor.actions.submenu` registry key - is off. That key is global and the actions are shared instances, so a copy of *Run with Coverage* at the popup's - own level 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 hands - `CoverageViewExtension.getElementToSelect` the PSI *leaf* under the caret and then looks for a tree node whose value - equals it, while every node here holds a `PsiFile` or a `PsiDirectory` — so nothing ever matches (PhpStorm's own - coverage has the same dead button). That mapper, the view's select call and its tree are all `@ApiStatus.Internal`, - and overriding the mapper fails `verifyPlugin`. Hence `TestoCoverageSelectOpenedFile`: our own toggle, which follows - the editor off the message bus and walks the tree with `TreeUtil.promiseSelect`. The tree is reached through the - toolbar's target component, handed over from the action's `update`. +- **`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)`**, so a column whose values are not percentages must - still answer there — the *Tests* column returns its count, or the view sizes it for "100% (1234/1234)". The width the - user ends up with is then remembered in `CoverageViewManager.StateBean.myColumnSize` (`@Internal`), which wins over - the computed one whenever the column count matches. -- **`CoverageViewExtension` is instantiated three times per view** — `CoverageView`, `CoverageTableModel` and - `CoverageViewTreeStructure` each call `createCoverageViewExtension`. Nothing one of them stores in a field is - visible to another, so 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 - when a bundle is *reloaded or closed* — the first `chooseSuitesBundle` of a session never calls it, so installing - there alone left the very first coverage run of an IDE session unpainted until something else forced a refresh. +- **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`. -- **Suite and group names are lists everywhere.** `TestoRunnerSettings` persists `suites`, `groups`/`excludeGroups` - via `@XCollection` and the editor shows them as tags (`TestoTagsField` — groups pick from what `TestoGroupsIndex` - found, suites are typed and added with Enter), so a name is never split on anything: a comma or a space inside one - survives. The comma lives only in the pre-list persisted form, and a legacy `suite="x"` becomes a one-item list - without being split at all. `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. +- **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.** `PhpTestRunConfigurationEditor` hands out the whole panel - and nothing smaller, and its *Test Runner options* row is a one-row `GridLayoutManager` built by the UI designer, so - a row cannot be added to it. `injectParallelRow` finds that row by its label (the bundle string carries a `&` - mnemonic the rendered label does not), rebuilds the row's layout with a second row and re-adds the existing children - with the constraints `getConstraintsForComponent` gives back — which is what keeps the label column shared, and the - two rows aligned. It falls back to a row of our own below the panel, so a PhpStorm form change only moves the field. +- **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/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt index f91c8ca..d21bae4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageActivation.kt @@ -16,7 +16,7 @@ import java.nio.file.Path 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 (architecture §10): one suite per + * 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt index 2758c9e..13d2398 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt @@ -26,9 +26,8 @@ fun dedupeCoverageByFormat( } /** - * The Coverage executor's closing move: once the process exits, resolve every announced coverage report, dedupe by format - * (flags first), respect the grouped button's checkboxes, and apply the survivors as one merged bundle — the same - * [applyTestoCoverage] the button uses, no click needed. + * 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 { 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 ba8b9bb..ac1b61c 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageEngine.kt @@ -2,7 +2,6 @@ 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.coverage.format.PerTestCoverage import com.github.xepozz.testo.tests.run.TestoRunConfiguration import com.intellij.coverage.CoverageAnnotator import com.intellij.coverage.CoverageEngine @@ -31,12 +30,11 @@ class TestoCoverageEnabledConfiguration( /** * Carries the parsed report's format-dependent side-data — the format (which decides the CLI flag and how the runner - * reads the file), whether it holds branch data, and the coverage-xml per-test overlay for later features (arch §7). + * 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 : BaseCoverageSuite { var format: CoverageFormat = CoverageFormat.CLOVER - var perTest: PerTestCoverage? = null /** * Per-file line tallies keyed like the `ClassData` entries, for reports that state how many executable lines a file @@ -58,9 +56,8 @@ class TestoCoverageSuite : BaseCoverageSuite { timeStamp: Long, ) : super(name, project, coverageRunner, fileProvider, timeStamp) - fun applyParsed(hasBranches: Boolean, perTest: PerTestCoverage?, lineTotals: Map) { + fun applyParsed(hasBranches: Boolean, lineTotals: Map) { this.branchCoverage = hasBranches - this.perTest = perTest this.lineTotals = lineTotals } 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 a0bf905..325403f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -165,7 +165,6 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { } } - // Local interpreter: the report path is the same on both sides. Remote: map it into the execution environment. private fun toTargetPath(runConfiguration: TestoRunConfiguration, interpreter: PhpInterpreter, localCoverage: String): String { val data = interpreter.phpSdkAdditionalData if (data is RemoteSdkAdditionalData) { diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt index 229757c..ab55dc9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProjectData.kt @@ -13,7 +13,7 @@ import com.intellij.rt.coverage.data.ProjectData * 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. See `docs/coverage/report-formats.md` §2 and architecture §14.2. + * decision line green rather than partial. */ fun ParsedReport.toProjectData(keyFor: (String) -> String = { it }): ProjectData { val projectData = ProjectData() diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt index 96a4a62..d1a9d9a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageRunner.kt @@ -43,13 +43,9 @@ class TestoCoverageRunner : CoverageRunner() { 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, report.perTest, lineTotals.toMap()) + suite?.applyParsed(report.hasBranches, lineTotals.toMap()) LOG.info("Testo coverage loaded: ${report.format} ${projectData.classes.size} files from $sessionDataFile") SuccessCoverageLoadingResult(projectData) - } catch (e: CoverageParseException) { - LOG.warn("Failed to load Testo coverage from $sessionDataFile", e) - reporter.reportError(e) - FailedCoverageLoadingResult(e, true) } catch (e: Exception) { LOG.warn("Failed to load Testo coverage from $sessionDataFile", e) reporter.reportError(e) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt index bbd123f..2909d71 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageViewExtension.kt @@ -67,7 +67,6 @@ class TestoCoverageViewExtension( 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. @@ -86,7 +85,6 @@ class TestoCoverageViewExtension( private fun hasPerTestData(): Boolean = mySuitesBundle.suites.filterIsInstance().any { it.format == CoverageFormat.COVERAGE_XML } - /** Distinct covering tests: the file's own set, a directory as the union over the files beneath it. */ 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. 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 index fe7e83a..4d95e9d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageEditorHighlighter.kt @@ -29,7 +29,7 @@ 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 (arch §4.2). Same primitives the platform uses underneath: + * `@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 @@ -63,7 +63,7 @@ class TestoCoverageEditorHighlighter(private val project: Project) : Disposable private class AnnotatedDocument(val highlighters: MutableList, val listenerDisposable: Disposable) - /** Idempotent; safe off the EDT. Registers the suite/editor listeners once and reconciles the current state. */ + /** Idempotent; safe off the EDT. */ fun install() { if (installed.compareAndSet(false, true)) { CoverageDataManager.getInstance(project).addSuiteListener(object : CoverageSuiteListener { 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 index cd35891..9f38829 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt @@ -39,11 +39,11 @@ 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`, arch §4.2). Same geometry: `Position.LEFT`, 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 (§9). + * 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, @@ -77,8 +77,7 @@ internal class TestoCoverageGutterRenderer( showPopup(RelativePoint(e)) } - // The platform coverage popup's shape ("Hits: N" under a toolbar): one metric per row, then the covering tests - // right below — a click or Enter navigates. Rows the line has no data for are simply absent. + // 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) 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 index 9fa1360..a85a0ae 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt @@ -52,7 +52,7 @@ class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { AllIcons.Toolwindows.ToolWindowRunWithCoverage, { label }, { event, _ -> - TestoCoveringTestsPopup.show(project, tests, subject, null, RelativePoint(event)) + TestoCoveringTestsPopup.show(project, tests, subject, RelativePoint(event)) }, GutterIconRenderer.Alignment.LEFT, { label }, 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 index 528f4a0..a93cb5d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CloverCoverageParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CloverCoverageParser.kt @@ -4,7 +4,7 @@ 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. See report-formats §1. + * (`count>=1`) and uncovered (`count=0`) executable lines are emitted. No branch data. */ object CloverCoverageParser : TestoCoverageParser { override val format = CoverageFormat.CLOVER 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 index 7a38b60..51ed094 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt @@ -5,7 +5,6 @@ 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)"`. - * See report-formats §2. */ object CoberturaCoverageParser : TestoCoverageParser { override val format = CoverageFormat.COBERTURA 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 index df08dfa..88f0e70 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageModel.kt @@ -2,7 +2,7 @@ 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). See `docs/coverage/report-formats.md`. + * `##teamcity[testoReport …]` announce (and by the CLI flag that produced them). */ enum class CoverageFormat(val id: String) { CLOVER("clover"), @@ -32,7 +32,7 @@ data class TestId(val fqcn: String, val method: String) { /** 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 (see arch §14.2). */ +/** `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) @@ -43,7 +43,7 @@ data class LineCoverage(val line: Int, val hits: Int, val branch: BranchCoverage */ 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 (arch §6). */ +/** 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) /** @@ -59,7 +59,7 @@ data class PerTestCoverage( } } -/** The parsed report, format-neutral. Turned into a platform `ProjectData` by the coverage runner (arch §4.1, Phase 2). */ +/** The parsed report, format-neutral. Turned into a platform `ProjectData` by the coverage runner. */ data class ParsedReport( val format: CoverageFormat, val files: List, 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 index 1f7a809..09db42e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt @@ -8,7 +8,7 @@ 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 `/`. See report-formats §3. + * is `/`; the per-file XML sits at `/`. */ object CoverageXmlParser : TestoCoverageParser { override val format = CoverageFormat.COVERAGE_XML 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 index 84c7809..3a4c346 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestData.kt @@ -7,9 +7,8 @@ import com.github.xepozz.testo.coverage.format.TestId 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. The - * substrate for "how many tests cover this" (arch §9) and the TIA seam (§8). File keys are normalized on the way in, - * so callers may pass raw paths. + * 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 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 index 339584d..c8bdbdb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoverageByTestIndex.kt @@ -6,12 +6,10 @@ import com.intellij.openapi.components.service import com.intellij.openapi.project.Project /** - * Holds the latest per-test coverage for the project so consumers (the "how many tests cover this" lens, arch §9; - * later TIA, §8) can read it with no active coverage session. Populated by [com.github.xepozz.testo.coverage. - * TestoCoverageRunner] when a `coverage-xml` report is loaded. + * 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 for now — it survives until the IDE closes or the next coverage-xml run replaces it. Cross-restart - * persistence keyed by report mtime is the open item in architecture §14.4. + * In-memory: survives until the IDE closes or the next coverage-xml run replaces it. */ @Service(Service.Level.PROJECT) class TestoCoverageByTestIndex { 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 index 48635b2..e19c85f 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTestsPopup.kt @@ -3,7 +3,6 @@ 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.editor.Editor import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.SimpleListCellRenderer @@ -21,7 +20,7 @@ internal object TestoCoveringTestsPopup { private class Row(val test: TestId?, val label: String, val icon: Icon) - fun show(project: Project, tests: List, subject: String, editor: Editor?, at: RelativePoint?) { + 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)) @@ -40,6 +39,6 @@ internal object TestoCoveringTestsPopup { TestoCoveringTestsLauncher.run(project, chosen, TestoCoveringTestsLauncher.runName(name, chosen.size)) } .createPopup() - if (at != null) popup.show(at) else if (editor != null) popup.showInBestPositionFor(editor) + 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 index 6785abd..6db694d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoTestIdentityMapper.kt @@ -9,8 +9,8 @@ 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 navigation (§9) and a future TIA rerun (§8) 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]. + * 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). */ diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt index ae22a89..d1333fe 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoReplayGroup.kt @@ -56,7 +56,6 @@ class TestoReplayGroup( RetentionOption(RunRetention.AUTO, "testo.runs.replay.keep"), RetentionOption(RunRetention.DISCARD, "testo.runs.replay.discard"), RetentionOption(RunRetention.LOCKED, "testo.runs.replay.lock"), - // The file gestures are one block — a run leaves as a zip, comes back as one, and lies on disk meanwhile. Separator.getInstance(), ExportAction(), ImportAction(), diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt index 5acb4f7..cfcadc0 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunArchiver.kt @@ -1,7 +1,6 @@ package com.github.xepozz.testo.runs import com.github.xepozz.testo.coverage.dedupeCoverageByFormat -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.TestoHistoryIndex diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt index 5dcab53..df95a3e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -118,7 +118,6 @@ internal fun runKindIcon(kind: TestoRunKind): Icon = when (kind) { TestoRunKind.RUN -> AllIcons.Toolwindows.ToolWindowRun } -/** The history entry's icon: what the run was, wearing a lock when the user locked it out of the rotation. */ internal fun runHistoryIcon(manifest: TestoRunManifest): Icon { val base = runKindIcon(runKindOf(manifest.executorId)) if (manifest.retention != RunRetention.LOCKED) return base @@ -147,8 +146,9 @@ internal fun replayNewestRunWithTest(project: Project, url: String) { 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 match = store.listRuns().firstOrNull { (dir, _) -> - store.readLocations(dir).any { it == key || it.startsWith(key) } + store.readLocations(dir).any { it == key || it.startsWith("$key::") } } ApplicationManager.getApplication().invokeLater( { diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index 8fdbfca..a8d41ad 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -15,7 +15,7 @@ import kotlin.io.path.isDirectory import kotlin.io.path.name /** - * The Testo run archive (arch §10a): one directory per run under the IDE system dir, holding the raw teamcity output + * 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. @@ -49,7 +49,7 @@ class TestoRunStore(private val project: Project) { /** 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 run still in flight has none yet — that choice rides on the recording. */ + /** 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)) } @@ -131,7 +131,6 @@ class TestoRunStore(private val project: Project) { 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) - // Thrown away by hand — it goes whatever its age, and the locked ones never do. manifest.retention == RunRetention.DISCARD -> delete(dir) manifest.retention == RunRetention.LOCKED -> Unit else -> rotating += dir to manifest.startedAt @@ -150,8 +149,11 @@ class TestoRunStore(private val project: Project) { */ 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) 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 e88655f..60f7b7a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -132,19 +132,13 @@ class TestoConsoleProperties( // console toolbar too. public override fun createImportActions(): Array = arrayOf( - // Laid out from the right edge inwards: listed first = furthest right. So this array reads right to left — - // the replay group sits at the right end, expand/collapse at its left, next to the separator that follows - // Show Passed / Show Ignored. - // This run's own archive: export it, decide what retention may do with it, load an exported one. + // 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 keeps its own expand/collapse in the toolbar's overflow group; on a test tree they are used - // constantly, so ours sit on the visible row. The platform's stay where they are: `ToolbarPanel` copies - // both of its groups into arrays before `RunTab` rebuilds the toolbar from them, so nothing the platform - // put there can be moved or removed afterwards. + // 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, 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 d5bff21..325f352 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 @@ -51,7 +51,6 @@ internal fun ExecutionEnvironment.testoRerunExecutorId(): String? { return archived ?: executor.id } -/** Whether this tab is a replayed archive — a rerun there launches the tests rather than replaying the log again. */ internal fun ExecutionEnvironment.isTestoReplay(): Boolean = runProfile is TestoRunReplayProfile /** 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 8121f95..333e0e7 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 @@ -37,7 +37,8 @@ internal object TestoHistoryIndex { val snapshot = cache[key] if (snapshot == null || snapshot.generation != current) scheduleRebuild(project, key, current) val urls = snapshot?.urls ?: cache[key]?.urls ?: return false - return url in urls || urls.any { it.startsWith(url) } + // `startsWith("$url::")`, not `startsWith(url)`: `…::testPay` must not answer for `…::testPayment`. + return url in urls || urls.any { it.startsWith("$url::") } } private fun scheduleRebuild(project: Project, key: String, generation: Long) { 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 35cf499..0fce43d 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 @@ -16,8 +16,7 @@ import com.intellij.openapi.project.DumbAware * 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. * - * Lives on the console's own vertical toolbar, right of the channel tabs (see [TestoChannelsUi]) — beside the output - * it filters, rather than on the test results toolbar which has nothing to do with channel output. + * Lives on the console's own vertical toolbar, beside the output it filters (installed by [TestoChannelsUi]). */ class TestoLogLevelFilterAction( private val filter: LogLevelFilter, 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 66c4d44..0707938 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 @@ -10,6 +10,7 @@ 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 @@ -49,6 +50,8 @@ class TestoOutputToGeneralEventsConverter( 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. @@ -59,20 +62,26 @@ class TestoOutputToGeneralEventsConverter( // 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) 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) - }.getOrNull()?.also { props.recording = it } + }.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) } + 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) { 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 index 07ba5cb..85f3147 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReplaySelection.kt @@ -3,7 +3,6 @@ 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.openapi.application.ApplicationManager import com.intellij.util.Alarm /** @@ -23,7 +22,9 @@ internal object TestoReplaySelection { * before we are handed the console, and its events are then already fired and missed. */ private fun whenTreeStable(console: SMTRunnerConsoleView, action: (SMTestProxy?) -> Unit) { - val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, console) + // 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 @@ -41,7 +42,7 @@ internal object TestoReplaySelection { private fun select(console: SMTRunnerConsoleView, root: SMTestProxy, url: String) { val form = console.resultsViewer as? SMTestRunnerResultsForm ?: return val match = findByLocationUrl(root, url) ?: return - ApplicationManager.getApplication().invokeLater { form.selectAndNotify(match) } + form.selectAndNotify(match) } private fun countDescendants(node: SMTestProxy): Int { @@ -57,16 +58,18 @@ internal object TestoReplaySelection { } } - // 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. + // 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.startsWith(url)) prefixMatch = 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 0824f9f..2549bf8 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 @@ -405,14 +405,13 @@ class TestoReportsAction( return } // resolveCoverageDataFile goes through the PHP path mapper and touches the filesystem — forbidden on the - // EDT, and this runs off a Swing timer on the EDT. Resolve on a pooled thread, apply back on the EDT. + // EDT, and this runs off a Swing timer on the EDT. if (resolving) return resolving = true val startedAt = reports.runStartedAt - val snapshot = coverage ApplicationManager.getApplication().executeOnPooledThread { val found = LinkedHashMap() - for (ref in snapshot) { + for (ref in coverage) { resolveCoverageDataFile(ref, project, mapToLocal, startedAt)?.let { found[ref.path] = it } } ApplicationManager.getApplication().invokeLater( 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 43cd6ad..c31ef39 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 @@ -50,10 +50,7 @@ class TestoRunConfigurationHandler : PhpTestRunConfigurationHandler { arguments.add("--group") arguments.add(if (group.startsWith("!")) group else "!$group") } - if (runner.parallel != 1) { - arguments.add("--parallel") - arguments.add(runner.parallel.toString()) - } + // 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/TestoTagsField.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt index d1396fd..8f01481 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt @@ -22,8 +22,6 @@ 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. - * - * Names either come from a popup of what the project declares ([suggestions]) or are typed into an inline field. */ class TestoTagsField( private val emptyLabel: String, @@ -90,7 +88,6 @@ class TestoTagsField( return button } - /** The no-suggestions half: a name is typed and Enter turns it into a tag, ready for the next one. */ private fun inlineInput(): JBTextField = JBTextField(INPUT_COLUMNS).apply { emptyText.text = addTooltip addActionListener { 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 27b3b39..ff08d6e 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 @@ -72,7 +72,6 @@ class TestoTestRunConfigurationEditor( toolTipText = "Not supported by Testo yet" } - /** Parallel belongs beside the other runner flags, so it is put into the PHP form's own Test Runner options row. */ private val parallelInjected = injectParallelRow() private val myMainPanel = panel { @@ -249,12 +248,9 @@ class TestoTestRunConfigurationEditor( /** * 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 `GridLayoutManager` form built by PhpStorm with exactly one row and no seam to extend — the - * editor exposes the whole panel and nothing smaller. The row is found by the label the platform gave it, its - * layout is rebuilt with a second row, and its children are re-added with the constraints they already had, so - * the label column stays shared and the two rows line up. Anything unexpected and the caller falls back to a - * row of our own below the panel; nothing here can leave the form half-built, since the swap is one step. + * 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index c490037..cb7c477 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -31,7 +31,7 @@ import java.awt.event.MouseEvent * 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" (arch §9): the native `CoverageEngine.getTestsForLine` + * 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. */ 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 80b0b3b..8708d01 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt @@ -53,7 +53,6 @@ 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 for a test some archived run can replay. if (!TestoHistoryIndex.contains(file.project, url)) return null return historyHint(url) } @@ -71,7 +70,6 @@ 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) - // The most recent run that actually holds this test, not merely the globally latest one. replayNewestRunWithTest(element.project, url) } diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt index 87ab479..bf929f5 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoRunConfigurationHandlerTest.kt @@ -202,26 +202,14 @@ class TestoRunConfigurationHandlerTest : TestCase() { 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]) - } - - fun testPrepareArguments_defaultParallel_skipped() { - val settings = TestoRunConfigurationSettings() - settings.runnerSettings.parallel = 1 - val arguments = mutableListOf() - - TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - - assertTrue("One worker is the default and needs no flag", arguments.isEmpty()) + assertTrue("Testo's CLI has no --parallel; a legacy value must not break the run", arguments.isEmpty()) } /** The coverage-only options belong to the Coverage runner alone and must never reach an ordinary run. */ @@ -246,7 +234,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { TestoRunConfigurationHandler.INSTANCE.prepareArguments(arguments, settings) - assertEquals(10, arguments.size) + assertEquals(8, arguments.size) assertTrue(arguments.contains("--type")) assertTrue(arguments.contains("bench")) assertTrue(arguments.contains("--suite")) @@ -254,8 +242,7 @@ class TestoRunConfigurationHandlerTest : TestCase() { assertTrue(arguments.contains("--group")) assertTrue(arguments.contains("db")) assertTrue(arguments.contains("!slow")) - assertTrue(arguments.contains("--parallel")) - assertTrue(arguments.contains("4")) + assertFalse(arguments.contains("--parallel")) } fun testPrepareArguments_withSingleRerunFilter() { @@ -316,19 +303,16 @@ class TestoRunConfigurationHandlerTest : TestCase() { settings.runnerSettings.testoType = "bench" 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/coverage/format/CoverageParserTest.kt b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt index 60756ad..fc65800 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/format/CoverageParserTest.kt @@ -73,10 +73,9 @@ class CoverageParserTest { } @Test - fun coberturaWithoutBranchLinesReportsNoBranches() { - // MultipleResult + DataCross classes alone carry no branch="true" line. + fun coberturaBranchFlagTracksAnyBranchLine() { val report = CoberturaCoverageParser.parse(dir.resolve("cobertura.xml")) - assertTrue(report.hasBranches) // the interceptor class does; sanity that the flag tracks any branch line + assertTrue(report.hasBranches) } // ---- coverage-xml --------------------------------------------------------------------------------------------- From 80cc2c8194e5e32f053433bbf6238a9d39f92014 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:00:08 +0400 Subject: [PATCH 26/41] fix(runs): reject an imported run.json that omits the version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every TestoRunManifest field has a default, so Gson turns a bare `{}` — or any foreign JSON object — into a valid-looking v=VERSION manifest, and importRun would file any zip carrying any JSON object as a locked run. Require `v` to be present; a manifest the plugin itself wrote always carries it, so legacy archives still load. Assisted-By: Claude Opus 4.8 (1M context) --- .../kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index a8d41ad..fdd81f5 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -1,6 +1,7 @@ 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 @@ -97,8 +98,11 @@ class TestoRunStore(private val project: Project) { fun readManifest(dir: Path): TestoRunManifest? = runCatching { val file = dir.resolve(TestoRunRecording.MANIFEST_FILE) if (!file.exists()) return null - gson.fromJson(Files.readString(file, StandardCharsets.UTF_8), TestoRunManifest::class.java) - ?.takeIf { it.v >= 1 } + // 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. */ From a912796cd3d0cdb579ba667c8581d17bd0491d30 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:01:07 +0400 Subject: [PATCH 27/41] fix(runs): write run.json atomically so a concurrent reader never sees it half-written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest was written with a plain Files.writeString, while listRuns/prune/retentionOf read it from other pooled threads. A reader hitting the write window got null from readManifest, and in prune a null manifest on a run older than a day means delete — so a retention toggle racing a prune could destroy a day-old run. Write to a temp file and atomically move it into place, in the one helper both writers now share. Assisted-By: Claude Opus 4.8 (1M context) --- .../xepozz/testo/runs/TestoRunRecording.kt | 22 ++++++++++++++++--- .../github/xepozz/testo/runs/TestoRunStore.kt | 4 +--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt index 8b7c0ba..f6ec1a9 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt @@ -5,6 +5,7 @@ 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 /** @@ -62,9 +63,7 @@ class TestoRunRecording internal constructor( } } - fun writeManifest(manifest: TestoRunManifest) { - Files.writeString(dir.resolve(MANIFEST_FILE), gson.toJson(manifest), StandardCharsets.UTF_8) - } + fun writeManifest(manifest: TestoRunManifest) = writeManifestFile(dir, manifest, gson) fun writeLocations() { val snapshot = synchronized(lock) { locations.toList() } @@ -98,3 +97,20 @@ class TestoRunRecording internal constructor( */ internal fun normalizeRunLocation(hint: String): String = hint.substringBefore('#').substringBefore(" with data set").trim() + +/** + * 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/TestoRunStore.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt index fdd81f5..7d0f7e4 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunStore.kt @@ -91,9 +91,7 @@ class TestoRunStore(private val project: Project) { return candidate } - private fun writeManifest(dir: Path, manifest: TestoRunManifest) { - Files.writeString(dir.resolve(TestoRunRecording.MANIFEST_FILE), gson.toJson(manifest), StandardCharsets.UTF_8) - } + private fun writeManifest(dir: Path, manifest: TestoRunManifest) = writeManifestFile(dir, manifest, gson) fun readManifest(dir: Path): TestoRunManifest? = runCatching { val file = dir.resolve(TestoRunRecording.MANIFEST_FILE) From fc2721c70f1cd8a1a4591ff803bc3730d8159852 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:01:28 +0400 Subject: [PATCH 28/41] fix(coverage): filter auto-apply reports by their checkboxes before the per-format dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduping first let an unchecked flag-written report win its format and then be filtered out, dropping the format even when a checked testo.php-configured report of the same format existed — where a manual click on the grouped button would have applied it. The archiver keeps deduping all announced reports on purpose: it stores every report, it does not honour the checkboxes. Assisted-By: Claude Opus 4.8 (1M context) --- .../github/xepozz/testo/coverage/TestoCoverageAutoApply.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt index 13d2398..f8871cb 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageAutoApply.kt @@ -37,8 +37,10 @@ internal fun autoApplyCoverage(project: Project, props: TestoConsoleProperties, resolveCoverageDataFile(ref, project, mapToLocal, writtenAfter)?.let { ref to it } } val flagKeys = flagLocalPaths.map { TestoCoverageKeys.normalize(it.toString()) }.toSet() - val chosen = dedupeCoverageByFormat(resolved, flagKeys) - .filter { props.reportStore.isCoverageChecked(it.first.path) } + // 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) From b1ebc0c05e2f64b2c6a2dd06cbbc6327002ae703 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:02:27 +0400 Subject: [PATCH 29/41] fix(runs): debounce the rerun buttons so a double-click cannot launch two runs at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two launches a millisecond apart start in the same millisecond, and TestoRunStore.beginRun keys the run directory by configuration name and start time — so a double-click interleaved two runs into one output.log and one console. A per-button cooldown swallows the second firing; a human cannot hit two different buttons that fast, so the same-button double-click is the only sub-millisecond case to guard. Assisted-By: Claude Opus 4.8 (1M context) --- .../actions/TestoRerunWithExecutorAction.kt | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 325f352..05a2507 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 @@ -79,6 +79,24 @@ private fun settingsFor(environment: ExecutionEnvironment, target: RunProfile): 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, @@ -86,6 +104,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) { @@ -106,6 +126,7 @@ 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 relaunchTesto(e, environment, target, executorId) @@ -136,6 +157,8 @@ 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) { @@ -144,7 +167,9 @@ class TestoRerunCurrentAction : AnAction(), DumbAware { if (environment != null) e.presentation.icon = rerunIcon(environment) } - override fun actionPerformed(e: AnActionEvent) = rerunCurrent(e) + override fun actionPerformed(e: AnActionEvent) { + if (throttle.tryLaunch()) rerunCurrent(e) + } } /** The icon of the executor a rerun would use — the archived one on a replayed tab, this tab's otherwise. */ @@ -213,6 +238,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) { @@ -230,5 +257,7 @@ class TestoAwareRerunAction : AnAction(), DumbAware { e.presentation.icon = rerunIcon(environment) } - override fun actionPerformed(e: AnActionEvent) = rerunCurrent(e) + override fun actionPerformed(e: AnActionEvent) { + if (throttle.tryLaunch()) rerunCurrent(e) + } } From 23356805372af9d69e34eb37be946ee7afce0462 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:03:47 +0400 Subject: [PATCH 30/41] i18n(coverage): route the covering-tests lens and the runner error through TestoBundle The code-vision lens name, its "N covering tests" hints, the chooser titles and the tooltip, plus the program runner''s unsupported-profile error, were hardcoded English while every sibling in the feature goes through TestoBundle. Assisted-By: Claude Opus 4.8 (1M context) --- .../testo/coverage/TestoCoverageProgramRunner.kt | 3 ++- .../ui/TestoCoverageByTestCodeVisionProvider.kt | 14 +++++++++----- src/main/resources/messages/TestoBundle.properties | 7 +++++++ 3 files changed, 18 insertions(+), 6 deletions(-) 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 325403f..7e2b8dc 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -1,5 +1,6 @@ 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 @@ -55,7 +56,7 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { override fun doExecute(state: RunProfileState, env: ExecutionEnvironment): RunContentDescriptor? { FileDocumentManager.getInstance().saveAllDocuments() val runConfiguration = env.runProfile as? TestoRunConfiguration - ?: throw ExecutionException("Coverage is not supported for the selected run profile.") + ?: throw ExecutionException(TestoBundle.message("testo.coverage.run.unsupported.profile")) val interpreter = runConfiguration.interpreter ?: throw ExecutionException(PhpCommandSettingsBuilder.getInterpreterNotFoundError()) diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index cb7c477..d892166 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -1,5 +1,6 @@ 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.TestoCoverageByTestIndex @@ -39,7 +40,7 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { override val id: String = "testo.coverage.byTest" - override val name: String = "Testo tests covering code" + override val name: String = TestoBundle.message("testo.coverage.byTest.name") override val relativeOrderings: List = listOf(CodeVisionRelativeOrdering.CodeVisionRelativeOrderingFirst) @@ -54,8 +55,8 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { val count = coveringTests(element as? Function ?: return null, file).size return when (count) { 0 -> null - 1 -> "1 covering test" - else -> "$count covering tests" + 1 -> TestoBundle.message("testo.coverage.byTest.hint.one") + else -> TestoBundle.message("testo.coverage.byTest.hint.many", count) } } @@ -68,7 +69,10 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { val mapper = TestoTestIdentityMapper.getInstance() val popup = JBPopupFactory.getInstance() .createPopupChooserBuilder(tests) - .setTitle(if (tests.size == 1) "1 Covering Test" else "${tests.size} Covering Tests") + .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("") { shortTestLabel(it) }) .setItemChosenCallback { id -> (mapper.resolve(id, project) as? Navigatable)?.takeIf { it.canNavigate() }?.navigate(true) @@ -112,7 +116,7 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { onClick, TestoIcons.TESTO, hint, - "Show the Testo tests that cover this declaration", + TestoBundle.message("testo.coverage.byTest.tooltip"), ) ) } diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 3590cab..864fa43 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -74,6 +74,13 @@ testo.coverage.view.run.covering.count=Run Covering Tests ({0}) testo.coverage.gutter.run.covering=Run covering tests ({0}) testo.coverage.popup.title=Tests covering {0} testo.coverage.covering.run.name=Tests covering {0} ({1}) +testo.coverage.byTest.name=Testo tests covering code +testo.coverage.byTest.hint.one=1 covering test +testo.coverage.byTest.hint.many={0} covering tests +testo.coverage.byTest.chooser.one=1 Covering Test +testo.coverage.byTest.chooser.many={0} Covering Tests +testo.coverage.byTest.tooltip=Show the Testo tests that cover this declaration +testo.coverage.run.unsupported.profile=Coverage is not supported for the selected run profile. testo.coverage.editor.status.full=Line covered testo.coverage.editor.status.partial=Line partially covered testo.coverage.editor.status.none=Line not covered From 2d9cdc48e34d2e7b724c50cf71e1a62b1c19ba51 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:09:18 +0400 Subject: [PATCH 31/41] refactor(coverage): collapse the duplicated covering-tests lookups and report resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration-range → covering-tests walk was copied verbatim in the gutter line marker and the code-vision lens, the "class::method" sort key was spelled three times, and the resolve-off-EDT scaffolding (busy guard, started-at snapshot, rerun-drop) stood twice in TestoReportAction. Extract `testsCoveringElement` + `TEST_ID_ORDER` and a `resolveReportOffEdt` helper, so a fix to any of them lands once. Assisted-By: Claude Opus 4.8 (1M context) --- .../editor/TestoCoverageGutterRenderer.kt | 3 +- .../TestoCoveringTestsLineMarkerProvider.kt | 20 +---- .../coverage/perTest/TestoCoveringTests.kt | 24 +++++ .../testo/tests/console/TestoReportAction.kt | 88 ++++++++++--------- .../TestoCoverageByTestCodeVisionProvider.kt | 20 +---- 5 files changed, 80 insertions(+), 75 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/coverage/perTest/TestoCoveringTests.kt 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 index 9f38829..ed471c6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoverageGutterRenderer.kt @@ -2,6 +2,7 @@ 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 @@ -162,7 +163,7 @@ internal class TestoCoverageGutterRenderer( private fun coveringTests(): List = TestoCoverageByTestIndex.getInstance(project).data() .testsCoveringLine(filePath, lineData.lineNumber) - .sortedBy { "${it.fqcn}::${it.method}" } + .sortedWith(TEST_ID_ORDER) private fun statusText(): String = coverageLineStatusText(lineData) 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 index a85a0ae..91139ec 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/editor/TestoCoveringTestsLineMarkerProvider.kt @@ -1,9 +1,9 @@ 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.TestoCoverageByTestIndex +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 @@ -12,7 +12,6 @@ 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.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.util.elementType import com.intellij.ui.awt.RelativePoint @@ -41,7 +40,7 @@ class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { val project = element.project if (!TestoCoveringTestsGutter.getInstance(project).enabled) return null - val tests = coveringTests(owner).sortedBy { "${it.fqcn}::${it.method}" } + 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 @@ -58,19 +57,6 @@ class TestoCoveringTestsLineMarkerProvider : LineMarkerProvider { { label }, ) } - - /** The tests that touched any line of the declaration — for a class, the union over everything it holds. */ - private fun coveringTests(owner: PhpNamedElement): Set { - val file = owner.containingFile ?: return emptySet() - val virtualFile = file.virtualFile ?: return emptySet() - val data = TestoCoverageByTestIndex.getInstance(owner.project).data() - val document = PsiDocumentManager.getInstance(owner.project).getDocument(file) ?: return emptySet() - val range = owner.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 data.testsCoveringRange(virtualFile.path, first..last) - } } /** The user's switch for those gutter icons, off the Coverage view's toolbar. Per project, remembered. */ 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 0000000..fc3c033 --- /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/tests/console/TestoReportAction.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt index 2549bf8..4fb388c 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 @@ -43,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 @@ -167,7 +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 var resolving = 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() @@ -203,29 +204,16 @@ 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 - if (!finished) { + if (!reports.runFinished) { applyResolved(null, false) return } - // resolveReport goes through the PHP path mapper, whose getLocalPath hits the file index — a slow operation - // forbidden on the EDT, and this runs off a Swing timer on the EDT. - if (resolving) return - resolving = true - val startedAt = reports.runStartedAt val cellRef = ref - ApplicationManager.getApplication().executeOnPooledThread { - val found = resolveReport(cellRef, project, mapToLocal, startedAt) - ApplicationManager.getApplication().invokeLater( - { - resolving = false - // A rerun may have started while this resolved; applying then would auto-open the previous - // run's report and mark the new run as already opened. Drop it — the next tick sees the run. - if (reports.runStartedAt == startedAt && reports.runFinished) applyResolved(found, true) - }, - ModalityState.any(), - ) { project.isDisposed } - } + resolveReportOffEdt( + project, reports, resolving, + resolve = { startedAt -> resolveReport(cellRef, project, mapToLocal, startedAt) }, + apply = { applyResolved(it, true) }, + ) } private fun applyResolved(found: Path?, finished: Boolean) { @@ -377,7 +365,7 @@ class TestoReportsAction( private var runWasFinished = false private var refreshed = false private var hovered = false - private var resolving = false + private val resolving = AtomicBoolean() override fun getFont(): Font = UIUtil.getLabelFont() @@ -399,29 +387,21 @@ class TestoReportsAction( fun refresh(coverage: List) { refs = coverage - val finished = reports.runFinished - if (!finished) { + if (!reports.runFinished) { applyResolved(emptyMap(), false) return } - // resolveCoverageDataFile goes through the PHP path mapper and touches the filesystem — forbidden on the - // EDT, and this runs off a Swing timer on the EDT. - if (resolving) return - resolving = true - val startedAt = reports.runStartedAt - ApplicationManager.getApplication().executeOnPooledThread { - val found = LinkedHashMap() - for (ref in coverage) { - resolveCoverageDataFile(ref, project, mapToLocal, startedAt)?.let { found[ref.path] = it } - } - ApplicationManager.getApplication().invokeLater( - { - resolving = false - if (reports.runStartedAt == startedAt && reports.runFinished) applyResolved(found, true) - }, - ModalityState.any(), - ) { project.isDisposed } - } + 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) { @@ -683,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/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index d892166..9423c71 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -3,9 +3,10 @@ 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.TestoCoverageByTestIndex +import com.github.xepozz.testo.coverage.perTest.TEST_ID_ORDER 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.codeInsight.codeVision.CodeVisionEntry import com.intellij.codeInsight.codeVision.CodeVisionRelativeOrdering @@ -16,7 +17,6 @@ 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.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.SmartPointerManager @@ -52,7 +52,7 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { override fun acceptsElement(element: PsiElement): Boolean = element is Function override fun getHint(element: PsiElement, file: PsiFile): String? { - val count = coveringTests(element as? Function ?: return null, file).size + val count = testsCoveringElement(element as? Function ?: return null).size return when (count) { 0 -> null 1 -> TestoBundle.message("testo.coverage.byTest.hint.one") @@ -63,7 +63,7 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { override fun handleClick(editor: Editor, element: PsiElement, event: MouseEvent?) { val function = element as? Function ?: return val project = function.project - val tests = coveringTests(function, function.containingFile).sortedBy { "${it.fqcn}::${it.method}" } + val tests = testsCoveringElement(function).sortedWith(TEST_ID_ORDER) if (tests.isEmpty()) return val mapper = TestoTestIdentityMapper.getInstance() @@ -81,18 +81,6 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { if (event != null) popup.show(RelativePoint(event)) else popup.showInBestPositionFor(editor) } - private fun coveringTests(function: Function, file: PsiFile): Set { - val virtualFile = file.virtualFile ?: return emptySet() - val project = file.project - val data = TestoCoverageByTestIndex.getInstance(project).data() - val document = PsiDocumentManager.getInstance(project).getDocument(file) ?: return emptySet() - val range = function.textRange - // 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 data.testsCoveringRange(virtualFile.path, first..last) - } - // 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> { From 7d39be9300171998cc62cb81232d55242e74dc0f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:13:19 +0400 Subject: [PATCH 32/41] fix: assorted small cleanups from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(run): drop the dead "Reports" group — one permanently disabled checkbox, nothing read or persisted. refactor(run): testoRerunExecutorId is never null (its fallback is the non-null executor.id), so declare it String and drop the dead null checks in rerunCurrent and rerunIcon. perf(run): compute the group-name suggestions under a modal progress instead of on the EDT — TestoGroupsIndex.allGroups queries the file index per key and would freeze a large project on the add-tag click. fix(coverage): count only a Cobertura class's own , not nested under — a foreign (PHPUnit) report would otherwise double every line. fix(coverage): a blank coverage-xml no longer yields keys with a leading slash, matching the Cobertura parser's guard. Assisted-By: Claude Opus 4.8 (1M context) --- .../coverage/format/CoberturaCoverageParser.kt | 4 +++- .../testo/coverage/format/CoverageXmlParser.kt | 3 ++- .../tests/actions/TestoRerunWithExecutorAction.kt | 11 +++++------ .../xepozz/testo/tests/run/TestoTagsField.kt | 12 +++++++++++- .../tests/run/TestoTestRunConfigurationEditor.kt | 14 -------------- src/main/resources/messages/TestoBundle.properties | 1 + 6 files changed, 22 insertions(+), 23 deletions(-) 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 index 51ed094..d82a25e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoberturaCoverageParser.kt @@ -21,7 +21,9 @@ object CoberturaCoverageParser : TestoCoverageParser { val filename = classEl.getAttribute("filename").ifBlank { continue } val path = if (source.isEmpty()) filename else "$source/$filename" val lines = byFile.getOrPut(path) { mutableListOf() } - for (lineEl in classEl.descendants("line")) { + // 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") { 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 index 09db42e..8ae2abf 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/format/CoverageXmlParser.kt @@ -28,7 +28,8 @@ object CoverageXmlParser : TestoCoverageParser { val href = entry.getAttribute("href").ifBlank { continue } val perFilePath = indexDir.resolve(href) if (!Files.exists(perFilePath)) continue - val path = "$source/${href.removeSuffix(".xml")}" + val relative = href.removeSuffix(".xml") + val path = if (source.isEmpty()) relative else "$source/$relative" val fileRoot = readXmlRoot(perFilePath) val coverage = fileRoot.descendants("coverage").firstOrNull() 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 05a2507..5419680 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 @@ -43,9 +43,9 @@ internal fun ExecutionEnvironment.isTestoRunTab(): Boolean = testoRunProfile() ! /** * 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). Null when the environment names no executor we can run. + * coverage). */ -internal fun ExecutionEnvironment.testoRerunExecutorId(): String? { +internal fun ExecutionEnvironment.testoRerunExecutorId(): String { val archived = (runProfile as? TestoRunReplayProfile)?.executorId ?.takeIf { ExecutorRegistry.getInstance().getExecutorById(it) != null } return archived ?: executor.id @@ -175,7 +175,7 @@ class TestoRerunCurrentAction : AnAction(), DumbAware { /** The icon of the executor a rerun would use — the archived one on a replayed tab, this tab's otherwise. */ internal fun rerunIcon(environment: ExecutionEnvironment): Icon { val executorId = environment.testoRerunExecutorId() - return executorId?.let { ExecutorRegistry.getInstance().getExecutorById(it)?.icon } + return ExecutorRegistry.getInstance().getExecutorById(executorId)?.icon ?: environment.executor.icon ?: AllIcons.Actions.Restart } @@ -187,9 +187,8 @@ internal fun rerunIcon(environment: ExecutionEnvironment): Icon { internal fun rerunCurrent(e: AnActionEvent) { val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) ?: return val target = environment.testoRunProfile() - val executorId = environment.testoRerunExecutorId() - if (environment.isTestoReplay() && target != null && executorId != null) { - relaunchTesto(e, environment, target, executorId) + if (environment.isTestoReplay() && target != null) { + relaunchTesto(e, environment, target, environment.testoRerunExecutorId()) return } ExecutionManager.getInstance(environment.project).restartRunProfile(environment) 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 index 8f01481..c3bfa9b 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTagsField.kt @@ -2,6 +2,7 @@ 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 @@ -100,7 +101,16 @@ class TestoTagsField( } private fun showSuggestions(anchor: Component) { - val known = suggestions?.invoke().orEmpty().filterNot { it in names } + 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() 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 ff08d6e..598b616 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 @@ -66,12 +66,6 @@ class TestoTestRunConfigurationEditor( private val coverageLevelField = ComboBox(TestoRunnerSettings.COVERAGE_LEVELS.toTypedArray()) private val coverageOptionsField = JBTextField() - // Held disabled until Testo can be asked for the report the plugin needs; nothing is persisted meanwhile. - private val htmlReportBox = JBCheckBox("Build an HTML report").apply { - isEnabled = false - toolTipText = "Not supported by Testo yet" - } - private val parallelInjected = injectParallelRow() private val myMainPanel = panel { @@ -118,14 +112,6 @@ class TestoTestRunConfigurationEditor( .rowComment("One --group=! per tag: the CLI reads the ! prefix as an exclusion") } - group("Reports") { - row { - cell(htmlReportBox) - } - .layout(RowLayout.PARENT_GRID) - .rowComment("Not supported by Testo yet") - } - group("Coverage") { row { label("Preferred engine") diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 864fa43..2939a35 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -143,6 +143,7 @@ actions.new.test.action.description=Creates new Testo Test php.testo.run.configuration.rerun.incorrect.configuration=Expected Testo run-configuration type, got: ''{0}'' testo.tags.remove=Remove "{0}" +testo.tags.loading=Loading group names… testo.tags.custom=Type a name… testo.tags.custom.prompt=Group name: testo.tags.groups.empty=No groups From 67362fa44ce869b7bf1aefa5433726e7efcaa924 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 10:42:27 +0400 Subject: [PATCH 33/41] feat(run): write HTML and JUnit reports into the run history on every run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testo grew --log-html and --log-junit. The run configuration now offers both as checkboxes (HTML on, JUnit off) and emits them, pointed at an IDE-managed folder, the same way a Coverage run points its --coverage-* flags: Testo writes there, announces the report, and the archiver copies it into history. HTML opens in a tab; JUnit is left unshown — it exists for external tooling (mutation testing). The flags go out from createCommand, the one chokepoint every executor (Run/Debug/Coverage) passes, rather than prepareArguments, which has neither the project nor the interpreter the IDE-managed path and the local-only gate need. A remote interpreter would write these to a host path that never maps back, so there they are left to whatever testo.php configures — no worse than before the flags existed. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ CLAUDE.md | 3 ++ .../testo/tests/run/TestoReportFlags.kt | 36 +++++++++++++++++++ .../testo/tests/run/TestoRunConfiguration.kt | 22 ++++++++++++ .../testo/tests/run/TestoRunnerSettings.kt | 10 ++++++ .../run/TestoTestRunConfigurationEditor.kt | 21 +++++++++++ .../xepozz/testo/TestoReportFlagsTest.kt | 32 +++++++++++++++++ 7 files changed, 126 insertions(+) create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/run/TestoReportFlags.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoReportFlagsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b42cb02..4035e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### 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. diff --git a/CLAUDE.md b/CLAUDE.md index 1e1ea18..24c092d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,6 +268,9 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor **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 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 0000000..1248e2a --- /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 477f907..1bdd1bd 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, @@ -176,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) @@ -194,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/TestoRunnerSettings.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt index d0d321a..b3bf53e 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoRunnerSettings.kt @@ -45,6 +45,14 @@ class TestoRunnerSettings( */ @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) @@ -148,6 +156,8 @@ class TestoRunnerSettings( 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/TestoTestRunConfigurationEditor.kt b/src/main/kotlin/com/github/xepozz/testo/tests/run/TestoTestRunConfigurationEditor.kt index 598b616..de2e10d 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 @@ -60,6 +60,8 @@ 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") @@ -112,6 +114,17 @@ class TestoTestRunConfigurationEditor( .rowComment("One --group=! per tag: the CLI reads the ! prefix as an exclusion") } + group("Reports") { + row { + label("Write") + .gap(RightGap.COLUMNS) + cell(htmlReportBox) + cell(junitReportBox) + } + .layout(RowLayout.PARENT_GRID) + .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("Preferred engine") @@ -162,6 +175,8 @@ class TestoTestRunConfigurationEditor( 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() } @@ -177,6 +192,8 @@ class TestoTestRunConfigurationEditor( || 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 @@ -192,6 +209,8 @@ class TestoTestRunConfigurationEditor( 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 @@ -223,6 +242,8 @@ class TestoTestRunConfigurationEditor( 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 diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportFlagsTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportFlagsTest.kt new file mode 100644 index 0000000..a46735b --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportFlagsTest.kt @@ -0,0 +1,32 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.tests.run.TestoReportFlags +import junit.framework.TestCase + +class TestoReportFlagsTest : TestCase() { + + fun testBothReportsEmitBothFlags() { + assertEquals( + listOf("--log-html=/ide/report.html", "--log-junit=/ide/junit.xml"), + TestoReportFlags.reportFlagArguments(true, true, "/ide/report.html", "/ide/junit.xml"), + ) + } + + fun testHtmlOnlyEmitsHtmlFlag() { + assertEquals( + listOf("--log-html=/ide/report.html"), + TestoReportFlags.reportFlagArguments(true, false, "/ide/report.html", "/ide/junit.xml"), + ) + } + + fun testJunitOnlyEmitsJunitFlag() { + assertEquals( + listOf("--log-junit=/ide/junit.xml"), + TestoReportFlags.reportFlagArguments(false, true, "/ide/report.html", "/ide/junit.xml"), + ) + } + + fun testNeitherEmitsNothing() { + assertTrue(TestoReportFlags.reportFlagArguments(false, false, "/ide/report.html", "/ide/junit.xml").isEmpty()) + } +} From 71a46127d844608977d053d3f918abbb4d64915a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 11:35:49 +0400 Subject: [PATCH 34/41] feat(coverage): auto level collects branches when Xdebug meets Cobertura MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch coverage needs the Xdebug engine (PCOV collects lines only) and a Cobertura report to carry it. When both are on and the level is left at auto, resolveCoverageLevel now sends --coverage-level=branch instead of leaving the level to testo.php — so the default Coverage run (Xdebug + Cobertura + auto) gathers branches. An explicit level still wins, and auto with PCOV or without Cobertura sends nothing. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + CLAUDE.md | 6 ++-- .../coverage/TestoCoverageProgramRunner.kt | 17 ++++++++-- .../run/TestoTestRunConfigurationEditor.kt | 2 +- .../coverage/TestoCoverageArgumentsTest.kt | 32 ++++++++++++++----- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4035e3e..4528272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ ### 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 moved onto the console's own toolbar, right of the channel tabs, with a filter icon. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 24c092d..32ab60b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,8 +276,10 @@ Requires IDEA Ultimate or PhpStorm — the plugin cannot load without PHP suppor `Method` → `--path --filter [--data-provider ]`; `ConfigurationFile` → nothing (the config file argument alone drives the run). - Coverage adds one `--coverage-=` per checked report (or bare `--coverage` if no path), - `--coverage-level=` unless the level is *auto*, the configuration's own coverage-only options - (`coverageOptions`, `--type=!bench` by default), plus Xdebug or PCOV INI options depending on `coverageEngine`. + `--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`. `methodName` is an encoded selector, not just a name: 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 7e2b8dc..36146fc 100644 --- a/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt +++ b/src/main/kotlin/com/github/xepozz/testo/coverage/TestoCoverageProgramRunner.kt @@ -114,14 +114,25 @@ open class TestoCoverageProgramRunner : GenericProgramRunner() { /** * The analysis level and the configuration's coverage-only options — everything a Coverage run adds beyond the - * report flags. The level is left out when set to auto: the one configured in testo.php then stands. + * report flags. */ fun extraCoverageArguments(settings: TestoRunnerSettings): List = buildList { - val level = settings.coverageLevel.trim() - if (level.isNotEmpty() && level != TestoRunnerSettings.COVERAGE_LEVEL_AUTO) add("--coverage-level=$level") + 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" 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 de2e10d..61a5de3 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 @@ -141,7 +141,7 @@ class TestoTestRunConfigurationEditor( cell(coverageLevelField) } .layout(RowLayout.PARENT_GRID) - .rowComment("--coverage-level=; auto leaves the level to testo.php. Branch and path need Xdebug") + .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") 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 25ea58a..0818c5e 100644 --- a/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/coverage/TestoCoverageArgumentsTest.kt @@ -2,6 +2,7 @@ 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 @@ -73,31 +74,46 @@ class TestoCoverageArgumentsTest : TestoCoverageProgramRunner() { @Test fun coverageOnlyOptionsDefaultToExcludingBenchmarks() { - assertEquals(listOf("--type=!bench"), extraCoverageArguments(TestoRunnerSettings())) + // 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"""") + val settings = TestoRunnerSettings(coverageOptions = """--type=!bench --filter "a b"""", coverageCobertura = false) assertEquals(listOf("--type=!bench", "--filter", "a b"), extraCoverageArguments(settings)) } @Test fun emptyCoverageOnlyOptionsAddNothing() { - assertTrue(extraCoverageArguments(TestoRunnerSettings(coverageOptions = " ", coverageLevel = "auto")).isEmpty()) + val settings = TestoRunnerSettings(coverageOptions = " ", coverageLevel = "auto", coverageCobertura = false) + assertTrue(extraCoverageArguments(settings).isEmpty()) } @Test - fun autoLevelSendsNoLevelFlag() { - assertTrue(extraCoverageArguments(TestoRunnerSettings()).none { it.startsWith("--coverage-level") }) + fun autoWithXdebugAndCoberturaCollectsBranches() { + assertEquals("branch", resolveCoverageLevel(TestoRunnerSettings())) } @Test - fun chosenLevelLeadsTheCoverageOnlyArguments() { - val settings = TestoRunnerSettings(coverageLevel = "branch") + 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=branch", "--type=!bench"), extraCoverageArguments(settings)) + assertEquals(listOf("--coverage-level=line", "--type=!bench"), extraCoverageArguments(settings)) } @Test From 9d8412384c37118f154857378466461d55a93109 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 14:27:46 +0400 Subject: [PATCH 35/41] fix(debug): keep the restart button on the debug toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(debug): wear the restart-debugger icon while the session runs The debug tab''s restart button is the platform Rerun, which TestoAwareRerunAction overrides; in SPLIT_BUTTON mode it hid itself on every Testo tab, deferring to the split button — but the split button lives on RunTab.TopToolbar, which the debug tab does not use, so its restart button vanished. Gate that hide on not being the Debug executor. A descriptor''s restart actions are constructor-only, so the debug descriptor cannot be handed its own. While the debug process is alive its restart button shows AllIcons.Actions.RestartDebugger, read off the run-content descriptor''s process handler. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ CLAUDE.md | 8 +++++-- .../actions/TestoRerunWithExecutorAction.kt | 24 ++++++++++++++----- .../testo/tests/run/TestoDebugRunner.kt | 5 ++-- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4528272..ad93517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ ### Fixed +- 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. - 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. diff --git a/CLAUDE.md b/CLAUDE.md index 32ab60b..9527fd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -508,8 +508,12 @@ Non-obvious constraints already paid for in blood — read before touching the r - **`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`. + 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 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 5419680..450a2f2 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 @@ -21,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 @@ -164,7 +165,7 @@ class TestoRerunCurrentAction : AnAction(), DumbAware { override fun update(e: AnActionEvent) { val environment = e.getData(ExecutionDataKeys.EXECUTION_ENVIRONMENT) e.presentation.isEnabledAndVisible = environment != null - if (environment != null) e.presentation.icon = rerunIcon(environment) + if (environment != null) e.presentation.icon = rerunIcon(e, environment) } override fun actionPerformed(e: AnActionEvent) { @@ -172,14 +173,23 @@ class TestoRerunCurrentAction : AnAction(), DumbAware { } } -/** The icon of the executor a rerun would use — the archived one on a replayed tab, this tab's otherwise. */ -internal fun rerunIcon(environment: ExecutionEnvironment): Icon { +/** + * 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. @@ -247,13 +257,15 @@ 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 = rerunIcon(environment) + e.presentation.icon = rerunIcon(e, environment) } override fun actionPerformed(e: AnActionEvent) { 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 61de2df..b84a7be 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 @@ -91,9 +91,8 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi 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. + // The debug toolbar's restart button is the platform Rerun (overridden by TestoAwareRerunAction); + // nothing to wire here. return PhpDebugProcessFactory.forPhpTests( session, sessionId, From 44327607f0c42317f49611db9633a527c16dff23 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 14:40:51 +0400 Subject: [PATCH 36/41] fix(debug): take the run descriptor off the session builder result XDebugSession.getRunContentDescriptor() is deprecated and logs a "split debugger" error under the 262 split-debugger runtime. Go through XDebuggerManager.newSessionBuilder(...).startSession() like PhpTestDebugRunner does and read the descriptor off its XSessionStartedResult, whose accessor is not deprecated. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ .../xepozz/testo/tests/run/TestoDebugRunner.kt | 15 +++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad93517..45e56f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ - 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. 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 b84a7be..842a46f 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 @@ -86,13 +86,10 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi } }) - 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 debug toolbar's restart button is the platform Rerun (overridden by TestoAwareRerunAction); - // nothing to wire here. return PhpDebugProcessFactory.forPhpTests( session, sessionId, @@ -102,9 +99,15 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi pathProcessor, ) } - }) + } + // Go through the session builder like PhpTestDebugRunner does: its result carries the descriptor. + // XDebugSession.getRunContentDescriptor() itself is deprecated and logs an error under the split debugger. + val descriptor = XDebuggerManager.getInstance(project).newSessionBuilder(starter) + .environment(env) + .startSession() + .runContentDescriptor processHandler.startNotify() - return debugSession.runContentDescriptor + return descriptor } catch (e: ExecutionException) { debugServer.unregisterSessionHandler(sessionId) throw e From 72369b0f24b3e843ca4cab9ef9ff1bfcdf6f9bf6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 15:12:30 +0400 Subject: [PATCH 37/41] feat(console): Log Levels filter is a minimum-level combo on the channel tabs row The old per-level checkbox dropdown sat on the console's vertical strip and did not open. Replace it with a labeled ComboBoxAction reading `info +` (the chosen level, and everything above it) that lives in the channel tabs' entry-point action group, at the right edge of the tab row. The level set is the fixed eight PSR-3 levels; a message shows when its level is at or above the chosen minimum, default `info`. The "levels seen this run" registry is gone, so the converter no longer records them. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +- CLAUDE.md | 10 +-- .../testo/tests/TestoConsoleProperties.kt | 1 - .../testo/tests/console/LogLevelFilter.kt | 73 ++++++------------ .../testo/tests/console/TestoChannelsUi.kt | 26 ++----- .../console/TestoLogLevelFilterAction.kt | 74 ++++++------------- .../TestoOutputToGeneralEventsConverter.kt | 3 - .../resources/messages/TestoBundle.properties | 3 +- 8 files changed, 59 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e56f6..9cf78a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,8 @@ ### 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 moved onto the console's own toolbar, right of the channel tabs, with a filter icon. +- 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. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 9527fd0..f1080d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,8 +146,8 @@ 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 # dropdown for the filter, on the console's own vertical toolbar +│ │ ├── 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 │ │ ├── TestoReplaySelection.kt # selects a test's node once the replayed tree stops growing @@ -450,6 +450,8 @@ Non-obvious constraints already paid for in blood — read before touching the r 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 is the channel tabs' `entryPointActionGroup`** (right edge of the tab row): a `protected open` + val re-read by `updateEntryPointToolbar` on every tab change, so overriding it on the `JBEditorTabs` subclass suffices. - **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 @@ -471,10 +473,6 @@ Non-obvious constraints already paid for in blood — read before touching the r - **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. -- **The console's own vertical toolbar is reached through `ActionToolbar.getActionGroup()`.** `TestResultsPanel` - builds it once from a `DefaultActionGroup` copy of its `protected final` actions array — the array is closed, the - group is not. The log-level filter is appended there, then `updateActionsAsync()`. The tab-row seam - (`TabInfo.setTabPaneActions`, read off the selected tab only) was tried and dropped: it costs a row of its own. - **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`, 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 60f7b7a..3478e49 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -97,7 +97,6 @@ class TestoConsoleProperties( testFrameworkName, consoleProperties, channelStore, - levelFilter, statusStore, runTimings, targetStore, 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 af065c1..c174a8e 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/TestoChannelsUi.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoChannelsUi.kt index 984cf48..7ac7f34 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,7 +31,6 @@ 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.ActionToolbar import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.fileEditor.FileDocumentManager @@ -1103,14 +1102,17 @@ 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 is its entry-point + // group: the toolbar JBTabs paints at the right edge of that row. + val entryPoint = DefaultActionGroup(TestoLogLevelFilterAction(levelFilter)) + val tabbed = object : JBEditorTabs(project, this@ChannelTabsController) { + override val entryPointActionGroup: DefaultActionGroup get() = entryPoint + } tabbed.addListener(object : com.intellij.ui.tabs.TabsListener { override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) = buildLazyTab(newSelection) }) addComponentTab(tabbed, OUTPUT_TAB, AllIcons.Debugger.Console, original) holder.add(tabbed.component, BorderLayout.CENTER) - installLevelFilter(holder) holder.revalidate() holder.repaint() tabs = tabbed @@ -1118,22 +1120,6 @@ object TestoChannelsUi { return tabbed } - // The log-level filter goes onto the vertical strip already sitting to the right of the output (print, clear, - // scroll to end) rather than onto a row of its own: it filters what that area shows, and the strip costs no - // extra space. TestResultsPanel wraps its console actions in a plain DefaultActionGroup and keeps no other way - // in — the array itself is `protected final`, and the toolbar is built from it once, at construction. - private fun installLevelFilter(holder: java.awt.Container) { - val toolbar = holder.components.firstNotNullOfOrNull { it as? ActionToolbar } - val group = toolbar?.actionGroup as? DefaultActionGroup - if (group == null) { - thisLogger().warn("Testo log level filter disabled: no console action toolbar beside the output") - return - } - group.addSeparator() - group.add(TestoLogLevelFilterAction(levelFilter)) - toolbar.updateActionsAsync() - } - companion object { private const val ALL_TAB = "All" private const val OUTPUT_TAB = "Output" 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 0fce43d..43fc724 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,80 +1,52 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.TestoBundle -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.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 /** - * 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. * - * Lives on the console's own vertical toolbar, beside the output it filters (installed by [TestoChannelsUi]). + * Right-aligned on the channel tabs row (installed by [TestoChannelsUi] as the tabs' entry-point action group). */ class TestoLogLevelFilterAction( private val filter: LogLevelFilter, -) : ActionGroup(), DumbAware { +) : ComboBoxAction(), DumbAware { init { - isPopup = true - templatePresentation.icon = AllIcons.General.Filter - 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 getChildren(e: AnActionEvent?): 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() + override fun update(e: AnActionEvent) { + e.presentation.text = filter.label() } - private inner class AllToggle : ToggleAction(TestoBundle.message("testo.console.loglevel.filter.all")), DumbAware { - override fun getActionUpdateThread() = ActionUpdateThread.EDT - override fun isSelected(e: AnActionEvent) = filter.isAllEnabled() - override fun setSelected(e: AnActionEvent, state: Boolean) { - 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) = !filter.isHidden(level) + override fun isSelected(e: AnActionEvent) = filter.minLevel == level override fun setSelected(e: AnActionEvent, state: Boolean) { - filter.setHidden(level, !state) + if (!state) return + filter.setMinLevel(level) filter.fireChange() } } - companion object { - // 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 0707938..1fcd5ad 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 @@ -19,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, @@ -126,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) diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 2939a35..375fb8a 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -19,8 +19,7 @@ action.testo.rerun.split.text=Rerun action.testo.rerunStyle.mirror.text=Rerun Toolbar: Mirror-aware Trio (A) action.testo.rerunStyle.split.text=Rerun Toolbar: Split Button (B) -testo.console.loglevel.filter.title=Log Levels -testo.console.loglevel.filter.all=All +testo.console.loglevel.filter.title=Minimum log level shown testo.status.passed=passed testo.status.failed=failed From aae1aee7b91d541abd7fb35184f5dfb61e267da2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 16:40:18 +0400 Subject: [PATCH 38/41] feat(coverage): run all covering tests from the per-test lens popup The covering-tests Code Vision lens listed its tests only to navigate. Its popup now leads with a *Run all covering tests* row that launches them with coverage through the same TestoCoveringTestsLauncher the gutter uses; the per-test rows still jump to source. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../TestoCoverageByTestCodeVisionProvider.kt | 26 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf78a1..c4c70be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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 replays the newest archived run containing it and selects its node. - How many runs the history keeps is set in *Tools | Testo* or from the history list itself, which also clears it. diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt index 9423c71..e1dc227 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCoverageByTestCodeVisionProvider.kt @@ -4,10 +4,12 @@ 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 @@ -27,6 +29,7 @@ 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 @@ -67,20 +70,35 @@ class TestoCoverageByTestCodeVisionProvider : CodeVisionProviderBase() { 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(tests) + .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("") { shortTestLabel(it) }) - .setItemChosenCallback { id -> - (mapper.resolve(id, project) as? Navigatable)?.takeIf { it.canNavigate() }?.navigate(true) + .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> { From feb5f97d629258657d357af93652f6ce96e1f569 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 16:42:51 +0400 Subject: [PATCH 39/41] fix(history): the Show history lens works and opens a run picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(inlay): name the Code Vision lenses in Inlay Hints settings feat(history): the Show history popup lists archived runs with inline Load replay / Repeat run The lens never matched on Windows: it built its location from the OS-native path (D:\…) while the archive stored Testo's D:/…, so nothing matched. Locations now compare through runLocationKey, which folds path separators. The archive index is also built synchronously on first lookup, so the first code-vision pass over a freshly opened file already answers instead of waiting on a repaint a daemon-bound provider does not reliably get. A click now pops up the runs holding the test (the one already open in a tab in bold) rather than replaying the newest at once; each row carries Load-replay and Repeat-run inline buttons, so a bare click launches nothing. Repeat run re-executes the archived configuration restored from its manifest, with its original executor. The two Code Vision lenses gained a CodeVisionGroupSettingProvider each, so their Inlay Hints settings show a name and description instead of blank. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +- CLAUDE.md | 14 ++- .../testo/runs/TestoRunHistoryActions.kt | 119 ++++++++++++++++-- .../xepozz/testo/runs/TestoRunRecording.kt | 7 ++ .../testo/tests/console/TestoHistoryIndex.kt | 52 +++----- .../testo/ui/TestoCodeVisionGroupSettings.kt | 22 ++++ .../ui/TestoHistoryCodeVisionProvider.kt | 8 +- src/main/resources/META-INF/plugin.xml | 5 + .../resources/messages/TestoBundle.properties | 7 ++ .../xepozz/testo/runs/TestoRunStoreTest.kt | 11 ++ 10 files changed, 201 insertions(+), 53 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/ui/TestoCodeVisionGroupSettings.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c70be..6489575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ - 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 replays the newest archived run containing it and selects its node. +- *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. @@ -50,6 +52,11 @@ ### 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 diff --git a/CLAUDE.md b/CLAUDE.md index f1080d9..230faec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,6 +204,7 @@ src/main/kotlin/com/github/xepozz/testo/ └── 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 @@ -463,9 +464,18 @@ 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`. + 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 diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt index df95a3e..bbcbfcd 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunHistoryActions.kt @@ -2,9 +2,20 @@ 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 @@ -12,18 +23,27 @@ 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 @@ -138,36 +158,109 @@ internal fun runResultSummary(manifest: TestoRunManifest): String { } /** - * "Show history" for one test: replay the newest archived run that actually holds it (not merely the latest run), and - * select that test's node once the tree is rebuilt. Scans the archive off the EDT, launches on it. + * "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 replayNewestRunWithTest(project: Project, url: String) { - val key = normalizeRunLocation(url) +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 match = store.listRuns().firstOrNull { (dir, _) -> - store.readLocations(dir).any { it == key || it.startsWith("$key::") } - } + 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 (match == null) { + if (project.isDisposed) return@invokeLater + if (entries.isEmpty()) { NotificationGroupManager.getInstance().getNotificationGroup("Testo") - ?.createNotification( - TestoBundle.message("testo.runs.history.none"), - NotificationType.INFORMATION, - ) + ?.createNotification(TestoBundle.message("testo.runs.history.none"), NotificationType.INFORMATION) ?.notify(project) return@invokeLater } - TestoRunReplayProfile.replay(project, match.first, match.second, url) + 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 { diff --git a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt index f6ec1a9..a06420a 100644 --- a/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt +++ b/src/main/kotlin/com/github/xepozz/testo/runs/TestoRunRecording.kt @@ -98,6 +98,13 @@ class TestoRunRecording internal constructor( 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 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 333e0e7..7437d20 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,6 +1,7 @@ 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.openapi.application.ApplicationManager @@ -14,57 +15,42 @@ import java.util.concurrent.atomic.AtomicLong * 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 and never blocks: [contains] answers from the last snapshot while a rebuild - * is in flight. It is invalidated by exactly one event — an archived run becoming complete - * ([com.github.xepozz.testo.runs.TestoRunArchiver], which also prunes) — so the lookup itself touches no filesystem. + * 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 data class Snapshot(val generation: Long, val urls: Set) private val generation = AtomicLong() private val cache = ConcurrentHashMap() - private val building = ConcurrentHashMap.newKeySet() - /** The archive changed: rebuild on the next lookup. */ + /** The archive changed: the next lookup rebuilds. Callers repaint open editors via [refreshLens] themselves. */ fun invalidate() { generation.incrementAndGet() } /** 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 key = project.locationHash - val current = generation.get() - val snapshot = cache[key] - if (snapshot == null || snapshot.generation != current) scheduleRebuild(project, key, current) - val urls = snapshot?.urls ?: cache[key]?.urls ?: return false - // `startsWith("$url::")`, not `startsWith(url)`: `…::testPay` must not answer for `…::testPayment`. - return url in urls || urls.any { it.startsWith("$url::") } + 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, generation: Long) { - if (!building.add(key)) return - ApplicationManager.getApplication().executeOnPooledThread { - try { - if (project.isDisposed) return@executeOnPooledThread - val store = TestoRunStore.getInstance(project) - val urls = HashSet() - store.listRuns().forEach { (dir, _) -> urls.addAll(store.readLocations(dir)) } - // Only restart the daemon when the lenses would actually change: a rebuild also runs on the very first - // lookup, and restarting then interrupts an in-flight highlighting pass for nothing. - val previous = cache.put(key, Snapshot(generation, 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/ui/TestoCodeVisionGroupSettings.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoCodeVisionGroupSettings.kt new file mode 100644 index 0000000..fef3684 --- /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/TestoHistoryCodeVisionProvider.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt index 8708d01..cdc6357 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoHistoryCodeVisionProvider.kt @@ -4,7 +4,7 @@ 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.replayNewestRunWithTest +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 @@ -28,8 +28,8 @@ import java.awt.event.MouseEvent * right where the green gutter run icons live. * * The lens reads "Show history" and is shown only for tests the run archive - * ([com.github.xepozz.testo.runs.TestoRunStore]) holds; clicking it replays the newest archived run containing that - * test into a full Testo run tab. The pass/total (N/M) count is intentionally NOT computed yet — see [historyHint]. + * ([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() { @@ -70,7 +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) - replayNewestRunWithTest(element.project, url) + showRunHistoryForTest(element.project, url, editor, event) } /** diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 34db959..2e78452 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -42,6 +42,11 @@ implementationClass="com.github.xepozz.testo.ui.TestoHistoryCodeVisionProvider"/> + + + diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 375fb8a..5f74bb0 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -79,6 +79,10 @@ testo.coverage.byTest.hint.many={0} covering tests testo.coverage.byTest.chooser.one=1 Covering Test testo.coverage.byTest.chooser.many={0} Covering Tests testo.coverage.byTest.tooltip=Show the Testo tests that cover this declaration +testo.coverage.byTest.settings.description=Above every covered method or function, shows how many Testo tests cover it; clicking lists them and jumps to the chosen one. Appears after a coverage run that recorded per-test data. + +testo.codeVision.history.name=Testo test history +testo.codeVision.history.description=Above every Testo test that has a recorded run, shows a Show history link; clicking replays the newest archived run containing that test. testo.coverage.run.unsupported.profile=Coverage is not supported for the selected run profile. testo.coverage.editor.status.full=Line covered testo.coverage.editor.status.partial=Line partially covered @@ -95,6 +99,9 @@ testo.runs.history.action=Testo Run History… testo.runs.history.title=Testo Run History testo.runs.history.empty=No archived Testo runs yet — run some tests first testo.runs.history.none=No archived Testo run contains this test yet — run it to record one. +testo.runs.history.forTest.title=Runs with this test +testo.runs.history.forTest.loadReplay=Load replay +testo.runs.history.forTest.repeat=Repeat run testo.runs.history.group=Test History testo.runs.history.clear=Clear Testo Run History testo.runs.history.clear.confirm=Delete the archived Testo runs of this project, including the reports captured with \ diff --git a/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt index 3e75a07..7a8daa3 100644 --- a/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/runs/TestoRunStoreTest.kt @@ -78,6 +78,17 @@ class TestoRunStoreTest { ) } + @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( From c623132207958f6c1784365aeeab83b172616082 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 17:03:40 +0400 Subject: [PATCH 40/41] fix(debug): reach newSessionBuilder by reflection so the 252 build compiles XDebuggerManager.newSessionBuilder exists only on 2026.2, so the direct call added with the split-debugger fix compiled on 262 but broke compileKotlin on the 252 variant in CI. The builder path is now reflective (methods resolved off the public interfaces), with the classic startSession fallback on 252, where the deprecated descriptor accessor is safe with no split debugger. Assisted-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 ++ .../testo/tests/run/TestoDebugRunner.kt | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 230faec..5fbb6e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ gone. `phpApi` is now purely a build selector (platform version, since/until, pe `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 `pluginVersion`. The API goes in as a fourth component rather than a `-252` suffix: it sorts above the bare version 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 842a46f..de31fdd 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 @@ -100,12 +101,7 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi ) } } - // Go through the session builder like PhpTestDebugRunner does: its result carries the descriptor. - // XDebugSession.getRunContentDescriptor() itself is deprecated and logs an error under the split debugger. - val descriptor = XDebuggerManager.getInstance(project).newSessionBuilder(starter) - .environment(env) - .startSession() - .runContentDescriptor + val descriptor = startDebugDescriptor(project, env, starter) processHandler.startNotify() return descriptor } catch (e: ExecutionException) { @@ -113,4 +109,29 @@ class TestoDebugRunner : PhpTestDebugRunner(TestoRunConfi 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 + } } From 8b7b149b274e61de68f7e32461ae40b90dd318e4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 17 Aug 2026 19:04:33 +0400 Subject: [PATCH 41/41] fix(console): keep the open channel across log-level and test changes, lead with Output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(console): host the log-level filter on public tabPaneActions, not the internal entry-point group The channel console rebuilds its tabs on a log-level toggle or a test switch, which dropped the selection back to Output. It now remembers the selected tab's title and reselects the same-named channel after the rebuild (Output when that channel is gone), and Output leads the tabs, apart from the channel-aggregating All. The filter itself used to override JBTabsImpl.getEntryPointActionGroup(), which is @ApiStatus.Internal and fails verifyPlugin's default failureLevel — a red neither buildPlugin nor test surfaces. It now rides on each tab's public TabInfo.setTabPaneActions; the platform feeds the selected tab's group into the same entry-point toolbar unchanged. Assisted-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + CLAUDE.md | 7 +++- .../testo/tests/console/TestoChannelsUi.kt | 42 ++++++++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6489575..8260ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 5fbb6e2..7e0c274 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -453,8 +453,11 @@ Non-obvious constraints already paid for in blood — read before touching the r 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 is the channel tabs' `entryPointActionGroup`** (right edge of the tab row): a `protected open` - val re-read by `updateEntryPointToolbar` on every tab change, so overriding it on the `JBEditorTabs` subclass suffices. +- **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 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 7ac7f34..c30ee38 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 @@ -157,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 @@ -190,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() @@ -205,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)) { @@ -228,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 @@ -244,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 @@ -253,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) { @@ -295,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 } @@ -313,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) } @@ -1102,12 +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. The log-level filter is its entry-point - // group: the toolbar JBTabs paints at the right edge of that row. - val entryPoint = DefaultActionGroup(TestoLogLevelFilterAction(levelFilter)) - val tabbed = object : JBEditorTabs(project, this@ChannelTabsController) { - override val entryPointActionGroup: DefaultActionGroup get() = entryPoint - } + // 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) })