From ed7beef0ce18fa9e4ef92e239e7273a98ace5191 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 01:36:17 +0400 Subject: [PATCH 01/10] feat: report button on the test toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: keep the run clock stopped when the process outlives the output buffer A run that exits in under a second gets processTerminated before the platform has worked through its output, so onTestingStarted arrived after the clock had already been stopped, was read as a second session, and restarted a clock that nothing was left to stop. The new-session gate is now the results form's own finish event, whose two events are ordered per session. Testo announces every report it writes with `##teamcity[testoReport …]`, before the first test — after the root suite closes the platform stops feeding the converter, so anything later would vanish. The button therefore waits for the file itself and an activity bump after the run re-asks about it, and it stays visible-but-disabled because RunTab snapshots the toolbar's actions and a button hidden at that moment never gets a component. Only formats the button can show as a page are offered; the store keeps the rest so a coverage report can later fill the Coverage tool window instead. JCEF is declared for both platforms (bundled plugin on 262, monolith module on 252) yet asked for by reflection: a named reference to an absent JBCefApp throws at class verification, before any try can catch it, which took the whole action group down with it. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 ++ CLAUDE.md | 29 ++- gradle.properties | 4 +- .../testo/tests/TestoConsoleProperties.kt | 21 +- .../tests/console/TestoConsoleAugmenter.kt | 20 +- .../TestoOutputToGeneralEventsConverter.kt | 15 ++ .../tests/console/TestoProgressAction.kt | 17 +- .../testo/tests/console/TestoReportAction.kt | 183 ++++++++++++++++++ .../testo/tests/console/TestoReportStore.kt | Bin 0 -> 6870 bytes .../xepozz/testo/ui/TestoReportEditor.kt | 133 +++++++++++++ src/main/resources/META-INF/jcef.xml | 5 + src/main/resources/META-INF/plugin.xml | 13 ++ .../resources/messages/TestoBundle.properties | 9 + .../testo/TestoReportConverterPsiTest.kt | 51 +++++ .../xepozz/testo/TestoReportStoreTest.kt | 181 +++++++++++++++++ 15 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportStore.kt create mode 100644 src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt create mode 100644 src/main/resources/META-INF/jcef.xml create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoReportConverterPsiTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ebffa62..1ea7831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ## [Unreleased] +### Added + +- A report button at the right end of the test toolbar, past the run summary. It sits there through the run and becomes + clickable once Testo announces a report it generated — + `##teamcity[testoReport format='html' path='…' relativePath='…' name='…' schemaVersion='…']` — and the file is + actually on disk; a run without a report renderer leaves it disabled, saying so on hover. +- Clicking it opens the report in an editor tab rendered by JCEF; the dropdown also offers the external browser and + copying the report's path. Reopening an already-open report reloads it, so the tab shows the run that just finished + rather than the one it was opened for. +- The announced path is absolute inside the *execution* environment, so it is looked for through the PHP path mapper + first, then as-is, then as `relativePath` under the project root — which is what makes a report written inside a + container or behind a remote interpreter reachable. + ## [2026.4.262] - 2026-08-10 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 958f844..3e9584f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,8 @@ Dependabot bumps these regularly — read the files rather than trusting this ta `phpstorm-remote-interpreter`, `php.codeception`, `php.behat`, `gherkin`, `xepozz.ide.introspector` (+ `hackathon.indices.viewer` on 252 only — it has no 262 build). `platformBundledModules`: `intellij.platform.coverage`, `intellij.spellchecker` (+ `intellij.platform.smRunner`, -`intellij.platform.testRunner` on 262, which split them out of the monolith). +`intellij.platform.testRunner`, `intellij.platform.ui.jcef` on 262, which split them out of the monolith — asking for +the JCEF module by name on 252 fails to resolve). ### Two build variants (`phpApi`) @@ -151,6 +152,8 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoTargetStore.kt # rerun targets of the current run, keyed by node id │ │ ├── TestoNodeIndex.kt # SMTestProxy → nodeId, off the platform's own node events │ │ ├── TestoProgressAction.kt # right-aligned toolbar summary: ring, fraction, status counters, elapsed +│ │ ├── TestoReportStore.kt # reports announced by `##teamcity[testoReport …]` + where to look for them +│ │ ├── TestoReportAction.kt # right-aligned split button: WebView / browser / copy path │ │ ├── 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 +183,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 + ├── TestoReportEditor.kt # JCEF editor tab for a generated report (light file + provider) └── TestoStackTraceConsoleFolding.kt # folds `[internal function]` frame runs src/main/resources/ @@ -386,6 +390,11 @@ it keeps everything the class holds (a `#[Test]` class typed as `test` would dro 9. **Navigation & output cleanup** — `TestoTestLocator` (click a node → source), `TestoStackTraceParser` (failed line + text), two console foldings, and `PhpBacktraceFileFilter` for hyperlinks in raw output. +10. **Generated reports** — Testo announces a report it wrote with the non-standard `##teamcity[testoReport …]`; + `TestoReportStore` keeps them and `TestoReportAction` is the split button past the run summary, opening the report + in a JCEF tab (`ui/TestoReportEditor.kt`) or the external browser. The spec for the report itself lives in the Testo + repository (`docs/spec/html-report.md`). + ## Implementation notes & gotchas Non-obvious constraints already paid for in blood — read before touching the relevant area. @@ -461,6 +470,24 @@ Non-obvious constraints already paid for in blood — read before touching the r `ConfigurationFile`, no config file, non-empty `group`) trips the platform's "Configuration file is not specified" `RuntimeConfigurationError`, though Testo needs no config file. The error is matched by message text (`PhpBundle`), so a platform rewording fails closed — the validation error merely comes back. +- **The report button stays visible and merely goes disabled.** RunTab snapshots the toolbar's actions, and a button + hidden at that moment — which is every moment before a run ends — never gets a component to show later. +- **A report must be announced while the run is still going, not once it is written.** Everything after the root + `testSuiteFinished` is past the point where the platform still feeds the converter: such a line reaches neither our + branch nor the console, it simply vanishes. So Testo announces a report when it *starts* writing it, and the button + polls for the file — `update` resolves the path on every toolbar refresh (on BGT, hence the filesystem touch). +- **JCEF is declared twice and still never trusted.** On 262 it is the bundled `com.intellij.modules.jcef` plugin + (`` + `jcef.xml`), on 252 a module inside the monolith (v2 ``); compiling + against it proves nothing about runtime visibility. `TestoReportViewer.isAvailable` therefore asks by **reflection**: + a named reference to `JBCefApp` throws `NoClassDefFoundError` when the *enclosing* method's class is verified, before + any `try` around the call can catch it — which is how one absent class took down the whole toolbar action group. No + JCEF type may be mentioned outside a class that loads only after `isAvailable` answers true. +- **The report tab is our own `FileEditorProvider`, not the platform's `HTMLEditorProvider`** — that one is + `@ApiStatus.Internal`. It accepts nothing but `TestoReportVirtualFile`, and `TestoReportViewer` keeps one such file + per path so re-opening a report returns to its tab (the platform keys tabs by identity, not equality) and reloads it. +- **A report path is resolved with `pathMapper.getLocalPath`, never `getLocalFile`** — the file was written moments + before the message arrived and the VFS need not know it yet. `relativePath` under the project root is the last + candidate, and the only one that works when the run's filesystem shares nothing with the host's. ## Testing diff --git a/gradle.properties b/gradle.properties index 80e9fec..9b14341 100644 --- a/gradle.properties +++ b/gradle.properties @@ -42,8 +42,10 @@ platformPlugins.262=com.jetbrains.php:262.9437.22,org.jetbrains.plugins.phpstorm platformBundledPlugins = # Example: platformBundledModules = intellij.spellchecker # 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 +platformBundledModules.262 = intellij.platform.coverage,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/tests/TestoConsoleProperties.kt b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt index 85d04d5..6880eb0 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -6,6 +6,8 @@ import com.github.xepozz.testo.tests.console.LogLevelFilter import com.github.xepozz.testo.tests.console.TestoNodeIndex import com.github.xepozz.testo.tests.console.TestoOutputToGeneralEventsConverter import com.github.xepozz.testo.tests.console.TestoProgressAction +import com.github.xepozz.testo.tests.console.TestoReportAction +import com.github.xepozz.testo.tests.console.TestoReportStore import com.github.xepozz.testo.tests.console.TestoRunTimings import com.github.xepozz.testo.tests.console.TestoStatusStore import com.github.xepozz.testo.tests.console.TestoTargetStore @@ -46,8 +48,14 @@ class TestoConsoleProperties( val targetStore = TestoTargetStore(nodeIndex) + // Reports Testo announced this run; empty unless the run had a report reporter configured. + val reportStore = TestoReportStore() + val progressAction = TestoProgressAction() + // getLocalPath, not getLocalFile: the report was written moments ago and the VFS may not know the file yet. + val reportAction = TestoReportAction(reportStore, project) { pathMapper.getLocalPath(it) } + // 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 @@ -65,6 +73,7 @@ class TestoConsoleProperties( runTimings, targetStore, nodeIndex, + reportStore, ) override fun getTestStackTraceParser(url: String, proxy: SMTestProxy, project: Project) = @@ -92,11 +101,17 @@ class TestoConsoleProperties( // 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. - public override fun createImportActions(): Array = - arrayOf( + public override fun createImportActions(): Array { + com.intellij.openapi.diagnostic.logger() + .info("Testo console actions created (report button included)") + return arrayOf( com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), *(super.createImportActions() ?: emptyArray()), - // Last, though the order hardly matters: the widget is right-aligned and lands past everything anyway. + // Both are right-aligned, and the toolbar lays that group out from the right edge inwards — so the one + // listed first ends up furthest right. The report button therefore goes before the run summary here to sit + // past it on screen. + reportAction, progressAction, ) + } } 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 a2c03bb..cae5edd 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 @@ -3,6 +3,7 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.tests.TestoConsoleProperties import com.intellij.execution.ExecutionListener import com.intellij.execution.ExecutorRegistry +import com.intellij.ide.ActivityTracker import com.intellij.execution.process.OSProcessHandler import com.intellij.execution.process.ProcessHandler import com.intellij.execution.runners.ExecutionEnvironment @@ -44,7 +45,15 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { // 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) } + // + // The same nudges re-ask the toolbar about the report button: a report is announced when Testo starts + // writing it, so the file itself usually lands only around now, and without an activity bump nothing would + // re-run the action's update until the user touches something. + val refresh = Runnable { + TestoHistoryIndex.refreshLens(project) + ActivityTracker.getInstance().inc() + } + ActivityTracker.getInstance().inc() EdtScheduledExecutorService.getInstance().schedule(refresh, 1500, java.util.concurrent.TimeUnit.MILLISECONDS) EdtScheduledExecutorService.getInstance().schedule(refresh, 4000, java.util.concurrent.TimeUnit.MILLISECONDS) } @@ -95,7 +104,14 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { ) { 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, props.runTimings, props.targetStore, handler) + props.progressAction.attachTo( + console, + props.statusStore, + props.runTimings, + props.targetStore, + props.reportStore, + handler, + ) hideStatusLine(console) } 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 1dd87f2..95c78d6 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 @@ -20,12 +20,14 @@ class TestoOutputToGeneralEventsConverter( private val timings: TestoRunTimings, private val targetStore: TestoTargetStore, private val nodes: TestoNodeIndex, + private val reportStore: TestoReportStore, ) : OutputToGeneralTestEventsConverter(testFrameworkName, consoleProperties) { /** Hooked the moment the platform hands the processor over, which is before any output is read. */ override fun setProcessor(processor: GeneralTestEventsProcessor?) { super.setProcessor(processor) processor?.let { nodes.attachTo(it) } + LOG.info("Testo converter attached (processor=${processor?.javaClass?.simpleName})") } /** Version off the banner, kept only to name it in the too-old notification. */ @@ -112,6 +114,16 @@ class TestoOutputToGeneralEventsConverter( } } + // Testo's own message, naming a report of this run. It has to arrive while the run is still going — once the + // root suite closes the platform stops feeding this converter — so Testo announces a report when it starts + // writing it, and the button waits for the file to appear. Not forwarded, for the same reason as + // buildProblem: the platform would echo the raw line into the console. + TESTO_REPORT -> { + LOG.info("Testo report announced (service message): $attrs") + TestoReportRef.fromAttributes(attrs)?.let { reportStore.note(it) } + return + } + BUILD_PROBLEM -> { reportBuildProblem(attrs["description"].orEmpty(), attrs["identity"].orEmpty()) // Not forwarded: the visitor sends unknown names to handleUnexpectedServiceMessage, which echoes the @@ -215,5 +227,8 @@ class TestoOutputToGeneralEventsConverter( private const val TEST_FAILED = "testFailed" private const val TEST_IGNORED = "testIgnored" private const val BUILD_PROBLEM = "buildProblem" + private const val TESTO_REPORT = "testoReport" + + private val LOG = com.intellij.openapi.diagnostic.logger() } } 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 f8031d1..ac250c4 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 @@ -69,6 +69,10 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb /** What the tree is narrowed to right now; `null` means no filter of ours is applied. */ private var selected: TestoTestStatus? = null + /** Whether the results form has announced the end of a session — what makes the next start a new run, not a late + * event of this one. EDT-only, like the listener that maintains it. */ + private var formFinished = false + override fun getActionUpdateThread() = ActionUpdateThread.EDT override fun actionPerformed(e: AnActionEvent) = Unit @@ -84,6 +88,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb store: TestoStatusStore, clock: TestoRunTimings, targets: TestoTargetStore, + reports: TestoReportStore, handler: ProcessHandler?, ) { val viewer = console.resultsViewer @@ -106,9 +111,18 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb override fun onTestingStarted(viewer: TestResultsViewer) { // Only on a second session in the same console: wiping on every announcement would throw away what // the converter already reported for this run, since it reads the stream before the platform. - if (clock.isFinished()) { + // + // The gate is this form's own finish, not clock.isFinished(): a short run exits before the platform has + // worked through its output buffer, so processTerminated stops the clock and *then* this arrives — and + // restarting the clock there left it running forever, with no event left to stop it. The form's two + // events are strictly ordered per session, so they can tell a new run from a late announcement. + if (formFinished) { + formFinished = false store.clear() targets.clear() + // Reports are deliberately not cleared here: a report is announced before the first test, so this + // may well run after the announcement and would throw it away. A re-run writes the same path, and + // the store replaces by path, so nothing stale survives anyway. clock.clear() clock.noteStart() exitCode.set(null) @@ -133,6 +147,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb // The only safe moment to read the tree: nothing appends to it any more. runCatching { store.recountFrom(viewer.testsRootNode) } clock.noteFinish() + formFinished = 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 new file mode 100644 index 0000000..3f984b8 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt @@ -0,0 +1,183 @@ +package com.github.xepozz.testo.tests.console + +import com.github.xepozz.testo.TestoBundle +import com.github.xepozz.testo.ui.TestoReportViewer +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +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.RightAlignedToolbarAction +import com.intellij.openapi.actionSystem.SplitButtonAction +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import java.awt.datatransfer.StringSelection +import java.nio.file.Files +import java.nio.file.Path +import javax.swing.Icon + +/** + * The report button at the right end of the test toolbar, past the run summary. + * + * Appears only once Testo has announced a report and the file is actually there — a run configured without the reporter + * shows no button at all. Clicking opens it in an editor tab (JCEF); the dropdown offers the external browser, which is + * also what the main click falls back to where JCEF is unavailable. + */ +class TestoReportAction( + private val reports: TestoReportStore, + private val project: Project, + private val mapToLocal: (String) -> String?, +) : SplitButtonAction(ReportMenu(reports, project, mapToLocal)), RightAlignedToolbarAction { + + // Icon *and* text: a toolbar button draws only its icon unless SHOW_TEXT_IN_TOOLBAR says otherwise, which is how + // Testo's name for the report ended up in the tooltip and nowhere else. The presentation is rewritten in update() + // once a report is known. + private val mainAction = ReportTargetAction( + TestoBundle.message("testo.report.action.text"), + AllIcons.General.IndentDetected, + Mode.DEFAULT, + reports::primary, + project, + mapToLocal, + ).apply { templatePresentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true) } + + // Resolving a report means touching the filesystem, which must not happen on the EDT. + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + // The button always means the same thing, so it never redraws itself as whichever menu item ran last. + override fun useDynamicSplitButton(): Boolean = false + + override fun getMainAction(e: AnActionEvent): AnAction = mainAction + + override fun update(e: AnActionEvent) { + val report = reports.primary() + val located = report?.let { resolveReport(it, project, mapToLocal) } + logStateOnce(e.place, located) + super.update(e) + // Visible even with no report to open, and merely disabled: RunTab snapshots the toolbar's actions, so a button + // hidden at that moment — which is every moment before the run ends — never gets a component at all. + e.presentation.isVisible = true + e.presentation.isEnabled = located != null + // Testo's own name for the report, so the button says what it opens; the bundle only covers the empty case. + e.presentation.text = report?.name ?: TestoBundle.message("testo.report.action.text") + e.presentation.icon = AllIcons.General.IndentDetected + // Without this the row shows the icon alone and the text survives only as a tooltip. + e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true) + e.presentation.description = when (located) { + null -> TestoBundle.message("testo.report.action.description.none") + else -> TestoBundle.message("testo.report.action.description", located.toString()) + } + } + + /** What the last update saw, so `update` firing several times a second logs one line per real change. */ + private var lastLogged: String? = null + + private fun logStateOnce(place: String, located: Path?) { + val announced = reports.all() + val primary = reports.primary() + val candidates = primary?.let { reportPathCandidates(it, project.basePath, mapToLocal) }.orEmpty() + val digest = "place=$place announced=${announced.size} primary=${primary?.path} " + + "candidates=$candidates resolved=$located" + if (digest == lastLogged) return + lastLogged = digest + LOG.info("Testo report button: $digest") + } + + private companion object { + private val LOG = com.intellij.openapi.diagnostic.logger() + } +} + +/** What a click does; [DEFAULT] is the WebView with the browser as its fallback. */ +private enum class Mode { DEFAULT, WEB_VIEW, BROWSER, COPY_PATH } + +/** The dropdown: the other ways to open the primary report, plus one entry per extra report when a run wrote several. */ +private class ReportMenu( + private val reports: TestoReportStore, + private val project: Project, + private val mapToLocal: (String) -> String?, +) : ActionGroup(), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun getChildren(e: AnActionEvent?): Array { + val primary = reports.primary() + return buildList { + if (TestoReportViewer.isAvailable) { + add(item("testo.report.open.webview", AllIcons.Actions.Preview, Mode.WEB_VIEW) { primary }) + } + add(item("testo.report.open.browser", AllIcons.Nodes.PpWeb, Mode.BROWSER) { primary }) + add(item("testo.report.copy.path", AllIcons.Actions.Copy, Mode.COPY_PATH) { primary }) + // Viewable only, and only when there is a choice to make: a data document or a coverage report is announced + // too, and neither belongs behind "open this page". + reports.viewable().filter { it != primary }.forEach { ref -> + add( + ReportTargetAction( + ref.name ?: ref.format.uppercase(), + AllIcons.General.IndentDetected, + Mode.DEFAULT, + { ref }, + project, + mapToLocal, + ) + ) + } + }.toTypedArray() + } + + private fun item(key: String, icon: Icon, mode: Mode, target: () -> TestoReportRef?) = + ReportTargetAction(TestoBundle.message(key), icon, mode, target, project, mapToLocal) +} + +private class ReportTargetAction( + text: String, + icon: Icon?, + private val mode: Mode, + private val target: () -> TestoReportRef?, + private val project: Project, + private val mapToLocal: (String) -> String?, +) : AnAction(text, null, icon), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = target()?.let { resolveReport(it, project, mapToLocal) } != null + } + + override fun actionPerformed(e: AnActionEvent) { + val ref = target() ?: return + val path = resolveReport(ref, project, mapToLocal) ?: return + val label = ref.name ?: TestoBundle.message("testo.report.editor.name") + when (mode) { + Mode.BROWSER -> browseReport(path) + Mode.COPY_PATH -> CopyPasteManager.getInstance().setContents(StringSelection(path.toString())) + // Without JCEF a tab could only say as much, so the report opens where it can actually be read. + Mode.WEB_VIEW, Mode.DEFAULT -> + if (!TestoReportViewer.open(project, path, label)) browseReport(path) + } + } +} + +/** + * Hands the report to the external browser. + * + * Through `Path.toUri()`, not `browse(File)`: the latter goes by way of the Windows path, whose separators come out + * percent-encoded — `file:///D:/%5Cgit%5C…`, which no browser resolves. `toUri()` yields `file:///D:/git/…`. + */ +private fun browseReport(path: Path) = BrowserUtil.browse(path.toUri()) + +/** + * The announced report as a local file, or `null` while none of the candidates exists. + * + * Touches the filesystem — callers must be off the EDT. + */ +internal fun resolveReport(ref: TestoReportRef, project: Project, mapToLocal: (String) -> String?): Path? = + // The mapper is the PHP plugin's, over a path it may know nothing about: whatever it throws must not take the + // toolbar's update with it. + reportPathCandidates(ref, project.basePath) { runCatching { mapToLocal(it) }.getOrNull() } + .asSequence() + .mapNotNull { runCatching { Path.of(it) }.getOrNull() } + .firstOrNull { runCatching { Files.isRegularFile(it) }.getOrDefault(false) } 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 new file mode 100644 index 0000000000000000000000000000000000000000..3efa6025a6abd90409d255207a24ee806e1a1365 GIT binary patch literal 6870 zcmb7J?Q+{T7Tw=|3e?@HB*$U8`_qk+akB2mG)_9TC;8!eB8Y@6+7!TopcS>rOdn#O zVIQndvgce75+ylFtC?0ZLEO)C?>#_jo!{zfqjERTuFEiMC)qnwyWf7xf@y-2|2J9g zY~w1kw^z<<=YzV@cRI7B%cf;zvY&LA?d?4}IM`DM>O^_$$2%9?udaeW4~x zRhXu{wnrkgu5_3>KUcTbEgEHAm{Fgbsxlm^#>q=?B&69=o)J7YuhJc+*=(d0*ag$`Qfm2R3C zkLc9TW;)zc_#kF#JPY$`tb!}_@*8z)>Of<)nzo_!hU(`!s9ak$F?g#gl&&@6i>S`v zN0?#dx`hLXpi3nFkZHh!nQMZoVep1I ze6VZG1(Y{>4$pxts6blFJ9;i=Eu6n)T8q+~JUG8RREwFhOiqLBB@fDwDGFNXrJ4ZZ zF)~`sbzLJ)JEkNxgmNEqus4* zm1n2t=O^EtzI}Oec1m5nKHpO*gM_;IIgm0={n;B`s3v$_Ue5wrJ6g2@LJYe+5{ZT^ z$Q|3ZLQ9p#z~sv-Gqg^^)~2cw@iF0RO)wTHV<$tHfmVYFfx_x4(zD5q&yBw;b8{Al z#`uugR?Y$hMKQdV>zHi(7sWS@TETt$7)FISFS~!Ho2~W z_2{c$4yMjaD-#w2QDbf`=uriW3$WA*h29R$PLD&lVAHR_qTVwu!5?F8^eqv*8S5xPy)t;i!q8wZc@&FPcRfL=aQK>gECiu`Rnwyo) zOBYV(by%V&ef5?4f=@5MzQV$r8D~tba}$@k74^lj8e9!N9*QnRq$no=F+kCnW(X?N zn9XeVRw;Y54nWu5XsIx#tujx5%(eF=kYt@2bJC+i7qc>-u@(qiv;d`(2`nNRM;*f| zxY`Yd6Cs0a*TwmQ^m~Q>NH^(5`k;!*D>pUAho9HoYxt2QC8_-GJmS~xVxMSj zDeo_R+Js%PQV*NH@X_BDvq9*#%-dM|JA(W8WqdyD0mt2G=>l+!tP15AhezIePhKX6 zB(&7T_JpiC59W>52u27_3AP8C&sN%X^KNu;F!D+5U5+6&V5xIB;$f_PbPV~??Ib6f zXuo1D!fw>*e=b1v49JxTt6Y9d&FV+9#1!cIH~A#!Q!s(iy8hD2k@wSxn^`^}@6yhWjIUQ=ewB2D-a`4&t z-{W-s0*|xyNmpRxS?dvs^xfYTx=lzUEAkF4uf)4w%U_@fpu)paJr>56*jKNDQ=$t9`B4v_AZx+|*T#r&zROSd{x*%mzVg9?jW>BXro2skltEw3udtiKR>XC1 zJV3p!XAR%vhm;O)gQcqX@44@R`j>jrZH^3+3u0JYy%H+-%V5prKH?0;(-_o2vS!V1 z%R?v|WgvR=$qjUZ86m$#eEypgnuPd7bx zc4J8aiS%CLRp#;ifSYe5P#?ttH}*f`Ovc+KTMSzp4{Dpd_W2AGA=*UQS3P3em&D&t zeyny~<3jDH19!89r4|4G1ZKDonAtdF z;Jlg`d2@+H4%X;Na1kK)AcMs#dD<{`ZD<;$bYZtH(a95|U?=)S(qcA?BgGVAT111O zTsp$ATTa7%UDEk^_NUPnKKYChlABM z`x5uF@uJr2MO>R=`0@Upa=YmT782!m|85X3Q$TKcxAia`8{~#8jem}8s`RGezeVu= zF1jdJbWMv3oI8+H$se_>F1!18-I%bvgKn1Kt88uEgaK|>sl$FU&3B2MS@HmYL zM>s{&_!Btd^AyY`p2F^-p7B7_dGri7dfb() + + /** + * Whether a report can be shown in a tab at all. + * + * Asked by reflection, and that is the point: JCEF may be absent outright (module not visible, unsupported + * architecture, a remote-dev backend), and a *named* reference to `JBCefApp` in a method body throws + * `NoClassDefFoundError` when that body's class is verified — before any `try` around the call can catch it. That is + * what took the toolbar's whole action group down. Nothing else in this plugin mentions a JCEF type outside a class + * that loads only once this has answered `true`. + */ + val isAvailable: Boolean by lazy { + val supported = runCatching { + Class.forName("com.intellij.ui.jcef.JBCefApp", false, TestoReportViewer::class.java.classLoader) + .getMethod("isSupported") + .invoke(null) as Boolean + } + LOG.info("JCEF availability: ${supported.getOrNull() ?: "unavailable (${supported.exceptionOrNull()})"}") + supported.getOrDefault(false) + } + + /** `false` when there is no WebView to open, which is the caller's cue to fall back to the browser. */ + fun open(project: Project, reportPath: Path, label: String): Boolean { + if (!isAvailable) return false + val file = files.computeIfAbsent(reportPath.toString()) { TestoReportVirtualFile(reportPath, label) } + val manager = FileEditorManager.getInstance(project) + val wasOpen = manager.isFileOpen(file) + manager.openFile(file, true) + // The tab survives between runs, so the report it shows is the one loaded when it first opened. + if (wasOpen) manager.getEditors(file).filterIsInstance().forEach { it.reload() } + return true + } + + private val LOG = logger() +} diff --git a/src/main/resources/META-INF/jcef.xml b/src/main/resources/META-INF/jcef.xml new file mode 100644 index 0000000..0d5fdbc --- /dev/null +++ b/src/main/resources/META-INF/jcef.xml @@ -0,0 +1,5 @@ + + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 09226e2..8712573 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -8,6 +8,15 @@ com.jetbrains.php com.intellij.modules.coverage + + com.intellij.modules.jcef + + + + messages.TestoBundle @@ -31,6 +40,10 @@ + + + diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index cb78a55..722e712 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -44,6 +44,15 @@ testo.progress.elapsed.postprocessing=Post-processing testo.progress.elapsed.boost=Concurrency boost testo.progress.elapsed.boost.value=≥{0}x +testo.report.action.text=Report +testo.report.action.description=Open the report Testo generated for this run: {0} +testo.report.action.description.none=This run generated no report. Configure a report renderer in testo.php to get one +testo.report.open.webview=Open in WebView +testo.report.open.browser=Open in Browser +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. + notification.group=Testo notification.runner.too.old.title=Testo is too old for this plugin notification.runner.too.old=Testo {0} does not tag its service messages with node ids, so the test tree cannot be built. Update testo/testo to {1} or newer. diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportConverterPsiTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportConverterPsiTest.kt new file mode 100644 index 0000000..918c7da --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportConverterPsiTest.kt @@ -0,0 +1,51 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.tests.TestoConsoleProperties +import com.github.xepozz.testo.tests.run.TestoRunConfigurationType +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.php.util.pathmapper.PhpPathMapper + +/** + * The announcement has to survive the trip through the real converter, which is where it is picked up: Testo emits it + * before the first test, so nothing about the test tree exists yet when it arrives. + */ +class TestoReportConverterPsiTest : BasePlatformTestCase() { + + fun testAnnouncementBeforeAnyTestReachesTheStore() { + val properties = testoProperties() + val converter = properties.createTestEventsConverter("Testo", properties) + + // Verbatim what Testo writes, as its very first line of output. + converter.process( + "##teamcity[testoReport format='html' path='D:/git/testo/testo/runtime/report/index.html'" + + " relativePath='runtime/report/index.html' name='Testo HTML report' schemaVersion='1']\n", + ProcessOutputTypes.STDOUT, + ) + + val announced = properties.reportStore.primary() + assertNotNull("the report was not recorded", announced) + assertEquals("D:/git/testo/testo/runtime/report/index.html", announced!!.path) + assertEquals("runtime/report/index.html", announced.relativePath) + assertEquals("Testo HTML report", announced.name) + } + + fun testOrdinaryServiceMessagesLeaveTheStoreEmpty() { + val properties = testoProperties() + val converter = properties.createTestEventsConverter("Testo", properties) + + converter.process("Testo v0.10.39\n", ProcessOutputTypes.STDOUT) + + assertNull(properties.reportStore.primary()) + } + + private fun testoProperties(): TestoConsoleProperties { + val configuration = TestoRunConfigurationType().createTemplateConfiguration(project) + return TestoConsoleProperties( + configuration, + DefaultRunExecutor.getRunExecutorInstance(), + PhpPathMapper.create(project), + ) + } +} diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt new file mode 100644 index 0000000..d765099 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -0,0 +1,181 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.tests.console.TestoReportRef +import com.github.xepozz.testo.tests.console.TestoReportStore +import com.github.xepozz.testo.tests.console.reportPathCandidates +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import java.nio.file.Path +import org.junit.Test + +/** + * Plain JUnit4 tests for the `testoReport` message and where its report is looked for — neither needs the platform. + */ +class TestoReportStoreTest { + + @Test + fun readsEveryAttributeOfTheMessage() { + val ref = TestoReportRef.fromAttributes( + mapOf( + "format" to "html", + "path" to "D:/git/testo/testo/runtime/report/index.html", + "relativePath" to "runtime/report/index.html", + "name" to "Testo HTML report", + "schemaVersion" to "1", + ) + ) + + assertEquals("html", ref!!.format) + assertEquals("D:/git/testo/testo/runtime/report/index.html", ref.path) + assertEquals("runtime/report/index.html", ref.relativePath) + assertEquals("Testo HTML report", ref.name) + assertEquals("1", ref.schemaVersion) + assertTrue(ref.isViewable) + } + + @Test + fun pathIsTheOnlyRequiredAttribute() { + assertNull(TestoReportRef.fromAttributes(mapOf("format" to "html"))) + assertNull(TestoReportRef.fromAttributes(mapOf("path" to " "))) + + val ref = TestoReportRef.fromAttributes(mapOf("path" to "/tmp/report.html"))!! + assertEquals("html", ref.format) + assertNull(ref.name) + assertNull(ref.relativePath) + } + + @Test + fun readsTheMessageOffARawLineOfOutput() { + val ref = TestoReportRef.fromServiceMessageLine( + "##teamcity[testoReport format='html' path='D:/git/testo/testo/runtime/report/index.html'" + + " relativePath='runtime/report/index.html' name='Testo HTML report' schemaVersion='1']" + )!! + + assertEquals("D:/git/testo/testo/runtime/report/index.html", ref.path) + assertEquals("runtime/report/index.html", ref.relativePath) + assertEquals("Testo HTML report", ref.name) + assertEquals("1", ref.schemaVersion) + } + + @Test + fun findsTheMessageEvenBehindWhateverPrecedesIt() { + // A colour escape or output not terminated by a newline is exactly what stops the platform parsing the line. + val ref = TestoReportRef.fromServiceMessageLine( + "\u001B[32mdone\u001B[0m##teamcity[testoReport path='/tmp/report/index.html']" + ) + assertEquals("/tmp/report/index.html", ref!!.path) + } + + @Test + fun ignoresLinesThatAreNotThisMessage() { + assertNull(TestoReportRef.fromServiceMessageLine("just output")) + assertNull(TestoReportRef.fromServiceMessageLine("##teamcity[testStarted name='foo' nodeId='1']")) + // A longer name that merely starts the same must not be read as ours. + assertNull(TestoReportRef.fromServiceMessageLine("##teamcity[testoReportish path='/tmp/x.html']")) + // Ours, but without the one attribute that matters. + assertNull(TestoReportRef.fromServiceMessageLine("##teamcity[testoReport format='html']")) + } + + @Test + fun undoesTeamCityEscapingInValues() { + val ref = TestoReportRef.fromServiceMessageLine( + "##teamcity[testoReport path='/tmp/it||s/report.html' name='Line|nBreak' relativePath='a|]b']" + )!! + + assertEquals("/tmp/it|s/report.html", ref.path) + assertEquals("Line\nBreak", ref.name) + assertEquals("a]b", ref.relativePath) + } + + @Test + fun primaryIsTheLastViewableReportAnnounced() { + val store = TestoReportStore() + store.note(ref("/tmp/one/index.html")) + store.note(ref("/tmp/two/report.json", format = "json")) + store.note(ref("/tmp/three/index.html")) + + assertEquals("/tmp/three/index.html", store.primary()!!.path) + assertEquals(3, store.all().size) + assertEquals(2, store.viewable().size) + } + + @Test + fun reportsThatAreNotPagesNeverBecomeThePrimaryOne() { + val store = TestoReportStore() + // Everything Testo writes is announced — a data document for external tooling, coverage, and so on — but only a + // page is something this button can open. + store.note(ref("/tmp/report.json", format = "json")) + store.note(ref("/tmp/clover.xml", format = "clover")) + + assertNull(store.primary()) + assertTrue(store.viewable().isEmpty()) + assertEquals(2, store.all().size) + } + + @Test + fun reAnnouncedPathReplacesItsEarlierEntry() { + val store = TestoReportStore() + store.note(ref("/tmp/index.html", name = "first")) + store.note(ref("/tmp/index.html", name = "second")) + + assertEquals(1, store.all().size) + assertEquals("second", store.primary()!!.name) + } + + @Test + fun clearForgetsThePreviousRun() { + val store = TestoReportStore() + store.note(ref("/tmp/index.html")) + store.clear() + + assertNull(store.primary()) + assertTrue(store.all().isEmpty()) + } + + @Test + fun mappedPathIsTriedFirstThenTheRawOneThenTheProjectRelativeForm() { + val candidates = reportPathCandidates( + ref("/app/runtime/report/index.html", relativePath = "runtime/report/index.html"), + projectBasePath = "/home/me/project", + ) { "/home/me/project/runtime/report/index.html" } + + assertEquals( + listOf( + "/home/me/project/runtime/report/index.html", + "/app/runtime/report/index.html", + Path.of("/home/me/project", "runtime/report/index.html").toString(), + ), + candidates, + ) + } + + @Test + fun localRunCollapsesToASingleCandidate() { + // The mapper answers with the path itself and the relative form resolves to the same file. + val path = Path.of("/home/me/project", "runtime/report/index.html").toString() + val candidates = reportPathCandidates( + ref(path, relativePath = "runtime/report/index.html"), + projectBasePath = "/home/me/project", + ) { it } + + assertEquals(listOf(path), candidates) + } + + @Test + fun unmappedPathWithoutRelativeFormLeavesOnlyItself() { + val candidates = reportPathCandidates( + ref("/app/report/index.html"), + projectBasePath = null, + ) { null } + + assertEquals(listOf("/app/report/index.html"), candidates) + } + + private fun ref( + path: String, + format: String = "html", + relativePath: String? = null, + name: String? = null, + ) = TestoReportRef(format, path, relativePath, name, "1") +} From e52612213d365bcc5fcd6bfa434fe54b68126a58 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 02:17:47 +0400 Subject: [PATCH 02/10] feat: one report button per announced report, labelled with its name The toolbar has to show the name Testo announces, and no stock widget could: a plain button draws the icon alone and leaves the text as a tooltip, SplitButtonAction paints its own component without any text, ComboBoxAction turns the first click into a dropdown, and an expanded ActionGroup loses RightAlignedToolbarAction so its children land among the buttons on the left. Hence a panel of hand-drawn cells inside a single right-aligned action, the way the run summary beside it already works. The panel exists from the start and hides itself while empty, because RunTab snapshots the toolbar's actions; each cell polls for its file, which is what distinguishes announced-but-unwritten from ready and turns a button off again if the report is deleted. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 +- CLAUDE.md | 23 +- .../testo/tests/TestoConsoleProperties.kt | 8 +- .../testo/tests/console/TestoReportAction.kt | 351 +++++++++++++----- .../resources/messages/TestoBundle.properties | 2 +- 5 files changed, 278 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea7831..9bb08ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,15 @@ ### Added -- A report button at the right end of the test toolbar, past the run summary. It sits there through the run and becomes - clickable once Testo announces a report it generated — - `##teamcity[testoReport format='html' path='…' relativePath='…' name='…' schemaVersion='…']` — and the file is - actually on disk; a run without a report renderer leaves it disabled, saying so on hover. -- Clicking it opens the report in an editor tab rendered by JCEF; the dropdown also offers the external browser and +- Report buttons at the right end of the test toolbar, past the run summary — one per report Testo announces with + `##teamcity[testoReport format='html' path='…' relativePath='…' name='…' schemaVersion='…']`, labelled with the name + the announcement carries. A run that writes no report shows no button; a report announced but not yet written shows a + disabled one, saying so on hover; it lights up when the file appears and goes back to disabled if the file is removed. +- Clicking a button opens the report in an editor tab rendered by JCEF; the arrow on it offers the external browser and copying the report's path. Reopening an already-open report reloads it, so the tab shows the run that just finished rather than the one it was opened for. +- Only reports the button can show as a page are offered. Everything else Testo announces is kept, ready for formats the + plugin will handle differently. - The announced path is absolute inside the *execution* environment, so it is looked for through the PHP path mapper first, then as-is, then as `relativePath` under the project root — which is what makes a report written inside a container or behind a remote interpreter reachable. diff --git a/CLAUDE.md b/CLAUDE.md index 3e9584f..9b0bb83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoNodeIndex.kt # SMTestProxy → nodeId, off the platform's own node events │ │ ├── TestoProgressAction.kt # right-aligned toolbar summary: ring, fraction, status counters, elapsed │ │ ├── TestoReportStore.kt # reports announced by `##teamcity[testoReport …]` + where to look for them -│ │ ├── TestoReportAction.kt # right-aligned split button: WebView / browser / copy path +│ │ ├── TestoReportAction.kt # right-aligned panel of hand-drawn report buttons (WebView / browser / copy) │ │ ├── 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 @@ -390,10 +390,11 @@ it keeps everything the class holds (a `#[Test]` class typed as `test` would dro 9. **Navigation & output cleanup** — `TestoTestLocator` (click a node → source), `TestoStackTraceParser` (failed line + text), two console foldings, and `PhpBacktraceFileFilter` for hyperlinks in raw output. -10. **Generated reports** — Testo announces a report it wrote with the non-standard `##teamcity[testoReport …]`; - `TestoReportStore` keeps them and `TestoReportAction` is the split button past the run summary, opening the report - in a JCEF tab (`ui/TestoReportEditor.kt`) or the external browser. The spec for the report itself lives in the Testo - repository (`docs/spec/html-report.md`). +10. **Generated reports** — Testo announces each report with the non-standard `##teamcity[testoReport …]`; + `TestoReportStore` keeps them, and `TestoReportsAction` draws one button per viewable report past the run summary, + labelled with the announced name, opening it in a JCEF tab (`ui/TestoReportEditor.kt`) or the external browser. Its + four states are: not announced (no button), announced without a file (disabled), file present (enabled), file gone + (disabled again). The spec for the report itself lives in the Testo repository (`docs/spec/html-report.md`). ## Implementation notes & gotchas @@ -470,12 +471,16 @@ Non-obvious constraints already paid for in blood — read before touching the r `ConfigurationFile`, no config file, non-empty `group`) trips the platform's "Configuration file is not specified" `RuntimeConfigurationError`, though Testo needs no config file. The error is matched by message text (`PhpBundle`), so a platform rewording fails closed — the validation error merely comes back. -- **The report button stays visible and merely goes disabled.** RunTab snapshots the toolbar's actions, and a button - hidden at that moment — which is every moment before a run ends — never gets a component to show later. +- **The report buttons are drawn by hand, inside one right-aligned action.** Every platform widget failed a requirement: + a toolbar button shows the icon alone (text becomes a tooltip), `SplitButtonAction` paints its own component and drops + the text, `ComboBoxAction` turns the first click into a dropdown, and an expanded `ActionGroup` loses + `RightAlignedToolbarAction` — its children land among the buttons on the left. So `TestoReportsAction` owns a panel of + cells, the way `TestoProgressAction` does; RunTab snapshots the toolbar's actions, so the panel must exist from the + start and hide itself while it has no cells. - **A report must be announced while the run is still going, not once it is written.** Everything after the root `testSuiteFinished` is past the point where the platform still feeds the converter: such a line reaches neither our - branch nor the console, it simply vanishes. So Testo announces a report when it *starts* writing it, and the button - polls for the file — `update` resolves the path on every toolbar refresh (on BGT, hence the filesystem touch). + branch nor the console, it simply vanishes. So Testo announces a report when it *starts* writing it, and each cell + polls for the file twice a second — which is also how a deleted report turns its button off again. - **JCEF is declared twice and still never trusted.** On 262 it is the bundled `com.intellij.modules.jcef` plugin (`` + `jcef.xml`), on 252 a module inside the monolith (v2 ``); compiling against it proves nothing about runtime visibility. `TestoReportViewer.isAvailable` therefore asks by **reflection**: 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 6880eb0..65b4a24 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -6,8 +6,8 @@ import com.github.xepozz.testo.tests.console.LogLevelFilter import com.github.xepozz.testo.tests.console.TestoNodeIndex import com.github.xepozz.testo.tests.console.TestoOutputToGeneralEventsConverter import com.github.xepozz.testo.tests.console.TestoProgressAction -import com.github.xepozz.testo.tests.console.TestoReportAction import com.github.xepozz.testo.tests.console.TestoReportStore +import com.github.xepozz.testo.tests.console.TestoReportsAction import com.github.xepozz.testo.tests.console.TestoRunTimings import com.github.xepozz.testo.tests.console.TestoStatusStore import com.github.xepozz.testo.tests.console.TestoTargetStore @@ -54,7 +54,7 @@ class TestoConsoleProperties( val progressAction = TestoProgressAction() // getLocalPath, not getLocalFile: the report was written moments ago and the VFS may not know the file yet. - val reportAction = TestoReportAction(reportStore, project) { pathMapper.getLocalPath(it) } + val reportsAction = TestoReportsAction(reportStore, project) { pathMapper.getLocalPath(it) } // 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. @@ -108,9 +108,9 @@ class TestoConsoleProperties( com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), *(super.createImportActions() ?: emptyArray()), // Both are right-aligned, and the toolbar lays that group out from the right edge inwards — so the one - // listed first ends up furthest right. The report button therefore goes before the run summary here to sit + // listed first ends up furthest right. The report buttons therefore go before the run summary here to sit // past it on screen. - reportAction, + reportsAction, progressAction, ) } 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 3f984b8..5828bec 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 @@ -4,139 +4,292 @@ import com.github.xepozz.testo.TestoBundle import com.github.xepozz.testo.ui.TestoReportViewer import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil -import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.ActionPlaces 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.Presentation import com.intellij.openapi.actionSystem.RightAlignedToolbarAction -import com.intellij.openapi.actionSystem.SplitButtonAction -import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.actionSystem.ex.CustomComponentAction +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.JBColor +import com.intellij.util.ui.GraphicsUtil +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Font +import java.awt.Graphics +import java.awt.Graphics2D import java.awt.datatransfer.StringSelection +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import java.nio.file.Files import java.nio.file.Path import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.Timer /** - * The report button at the right end of the test toolbar, past the run summary. + * The report buttons at the far right of the test toolbar, past the run summary — one per report Testo announced. * - * Appears only once Testo has announced a report and the file is actually there — a run configured without the reporter - * shows no button at all. Clicking opens it in an editor tab (JCEF); the dropdown offers the external browser, which is - * also what the main click falls back to where JCEF is unavailable. + * Hand-drawn, for the same reasons the run summary beside it is: a plain toolbar button shows the icon alone (the text + * survives only as a tooltip), `SplitButtonAction` paints its own component and drops the text entirely, and + * `ComboBoxAction` turns every click into a dropdown. Only a custom component gives icon, name, a click that opens the + * report, and an arrow for the other ways to open it — and, being one right-aligned action rather than an expanded + * group, it actually lands to the right of the summary instead of among the buttons on the left. + * + * States, in the order a run walks through them: no announcement, no button; announced but not yet written, a disabled + * button; the file appears, the button lights up; the file is deleted, it goes back to disabled. */ -class TestoReportAction( +class TestoReportsAction( private val reports: TestoReportStore, private val project: Project, private val mapToLocal: (String) -> String?, -) : SplitButtonAction(ReportMenu(reports, project, mapToLocal)), RightAlignedToolbarAction { - - // Icon *and* text: a toolbar button draws only its icon unless SHOW_TEXT_IN_TOOLBAR says otherwise, which is how - // Testo's name for the report ended up in the tooltip and nowhere else. The presentation is rewritten in update() - // once a report is known. - private val mainAction = ReportTargetAction( - TestoBundle.message("testo.report.action.text"), - AllIcons.General.IndentDetected, - Mode.DEFAULT, - reports::primary, - project, - mapToLocal, - ).apply { templatePresentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true) } - - // Resolving a report means touching the filesystem, which must not happen on the EDT. - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +) : AnAction(), CustomComponentAction, RightAlignedToolbarAction, DumbAware { - // The button always means the same thing, so it never redraws itself as whichever menu item ran last. - override fun useDynamicSplitButton(): Boolean = false + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT - override fun getMainAction(e: AnActionEvent): AnAction = mainAction + override fun actionPerformed(e: AnActionEvent) = Unit override fun update(e: AnActionEvent) { - val report = reports.primary() - val located = report?.let { resolveReport(it, project, mapToLocal) } - logStateOnce(e.place, located) - super.update(e) - // Visible even with no report to open, and merely disabled: RunTab snapshots the toolbar's actions, so a button - // hidden at that moment — which is every moment before the run ends — never gets a component at all. - e.presentation.isVisible = true - e.presentation.isEnabled = located != null - // Testo's own name for the report, so the button says what it opens; the bundle only covers the empty case. - e.presentation.text = report?.name ?: TestoBundle.message("testo.report.action.text") - e.presentation.icon = AllIcons.General.IndentDetected - // Without this the row shows the icon alone and the text survives only as a tooltip. - e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true) - e.presentation.description = when (located) { - null -> TestoBundle.message("testo.report.action.description.none") - else -> TestoBundle.message("testo.report.action.description", located.toString()) - } + e.presentation.isEnabledAndVisible = true } - /** What the last update saw, so `update` firing several times a second logs one line per real change. */ - private var lastLogged: String? = null - - private fun logStateOnce(place: String, located: Path?) { - val announced = reports.all() - val primary = reports.primary() - val candidates = primary?.let { reportPathCandidates(it, project.basePath, mapToLocal) }.orEmpty() - val digest = "place=$place announced=${announced.size} primary=${primary?.path} " + - "candidates=$candidates resolved=$located" - if (digest == lastLogged) return - lastLogged = digest - LOG.info("Testo report button: $digest") - } + override fun createCustomComponent(presentation: Presentation, place: String): JComponent = ReportsPanel() - private companion object { - private val LOG = com.intellij.openapi.diagnostic.logger() + /** + * One cell per announced report, polled rather than subscribed: the store is written by the converter off the + * process's thread, and whether the file exists yet changes without anything telling us. + */ + private inner class ReportsPanel : JPanel() { + private val cells = LinkedHashMap() + private val timer = Timer(REFRESH_MS) { tick() } + + init { + isOpaque = false + // Laid out by hand, like the run summary: a LayoutManager caches size requirements, and this row is + // re-measured whenever a report appears or its name changes. + layout = null + border = JBUI.Borders.empty(0, 6, 0, 4) + isVisible = false + } + + override fun addNotify() { + super.addNotify() + timer.start() + } + + override fun removeNotify() { + timer.stop() + super.removeNotify() + } + + override fun getPreferredSize(): Dimension { + val insets = insets + var width = insets.left + insets.right + var height = 0 + for (child in components) { + if (!child.isVisible) continue + val size = child.preferredSize + width += size.width + height = maxOf(height, size.height) + } + return Dimension(width, height + insets.top + insets.bottom) + } + + override fun getMinimumSize(): Dimension = preferredSize + override fun getMaximumSize(): Dimension = preferredSize + + override fun doLayout() { + var x = insets.left + for (child in components) { + val size = child.preferredSize + child.setBounds(x, (height - size.height) / 2, size.width, size.height) + x += size.width + } + } + + private var laidOutWidth = -1 + + private fun tick() { + val announced = reports.viewable() + // Cells follow the announcements: added when a report shows up, dropped if the store is ever cleared. + announced.forEach { ref -> + cells.getOrPut(ref.path) { ReportCell(ref).also { add(it) } }.ref = ref + } + 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 width = preferredSize.width + if (width != laidOutWidth) { + laidOutWidth = width + revalidate() + } + repaint() + } } -} -/** What a click does; [DEFAULT] is the WebView with the browser as its fallback. */ -private enum class Mode { DEFAULT, WEB_VIEW, BROWSER, COPY_PATH } + /** Icon, the report's own name, and a dropdown arrow; the arrow's third of the cell opens the menu. */ + private inner class ReportCell(ref: TestoReportRef) : JComponent() { + var ref: TestoReportRef = ref + private var located: Path? = null + private var hovered = false + private var lastLogged: String? = null -/** The dropdown: the other ways to open the primary report, plus one entry per extra report when a run wrote several. */ -private class ReportMenu( - private val reports: TestoReportStore, - private val project: Project, - private val mapToLocal: (String) -> String?, -) : ActionGroup(), DumbAware { + // Asked for per paint: a font set once on a raw JComponent outlives a zoom, since there is no UI delegate to + // reinstall it, and the cell would keep the size it was built at. + override fun getFont(): Font = UIUtil.getLabelFont() - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + init { + isOpaque = false + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + hovered = true + repaint() + } - override fun getChildren(e: AnActionEvent?): Array { - val primary = reports.primary() - return buildList { - if (TestoReportViewer.isAvailable) { - add(item("testo.report.open.webview", AllIcons.Actions.Preview, Mode.WEB_VIEW) { primary }) + override fun mouseExited(e: MouseEvent) { + hovered = false + repaint() + } + + override fun mouseClicked(e: MouseEvent) { + if (located == null) return + if (e.x >= width - arrowZone()) showMenu() else openDefault() + } + }) + } + + private fun text(): String = ref.name ?: TestoBundle.message("testo.report.action.text") + + private fun arrowZone(): Int = ARROW.iconWidth + GAP + PADDING + + /** Re-resolves the file, which is what moves the cell between enabled and disabled. */ + fun refresh() { + val found = resolveReport(ref, project, mapToLocal) + val changed = found != located + located = found + cursor = if (found == null) Cursor.getDefaultCursor() else Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = when (found) { + null -> TestoBundle.message("testo.report.action.description.pending") + else -> TestoBundle.message("testo.report.action.description", found.toString()) } - add(item("testo.report.open.browser", AllIcons.Nodes.PpWeb, Mode.BROWSER) { primary }) - add(item("testo.report.copy.path", AllIcons.Actions.Copy, Mode.COPY_PATH) { primary }) - // Viewable only, and only when there is a choice to make: a data document or a coverage report is announced - // too, and neither belongs behind "open this page". - reports.viewable().filter { it != primary }.forEach { ref -> - add( - ReportTargetAction( - ref.name ?: ref.format.uppercase(), - AllIcons.General.IndentDetected, - Mode.DEFAULT, - { ref }, - project, - mapToLocal, - ) - ) + val digest = "report=${ref.path} resolved=$found" + if (digest != lastLogged) { + lastLogged = digest + LOG.info("Testo report button: $digest") } - }.toTypedArray() + if (changed) repaint() + } + + override fun getPreferredSize(): Dimension { + val metrics = getFontMetrics(font) + val width = PADDING + ICON.iconWidth + GAP + metrics.stringWidth(text()) + GAP + ARROW.iconWidth + PADDING + val height = maxOf(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 && located != null) { + g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val arc = JBUI.scale(6) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } + // Dimmed as a whole while the file is missing, so "announced" reads differently from "ready". + val enabled = located != null + val icon = if (enabled) ICON else disabledIcon(ICON) + icon.paintIcon(this, g2, PADDING, (height - icon.iconHeight) / 2) + + g2.font = font + g2.color = if (enabled) UIUtil.getLabelForeground() else DISABLED_TEXT + val metrics = g2.fontMetrics + val textX = PADDING + ICON.iconWidth + GAP + g2.drawString(text(), textX, (height - metrics.height) / 2 + metrics.ascent) + + val arrow = if (enabled) ARROW else disabledIcon(ARROW) + arrow.paintIcon(this, g2, width - PADDING - arrow.iconWidth, (height - arrow.iconHeight) / 2) + } finally { + g2.dispose() + } + } + + private fun openDefault() { + val path = located ?: return + val label = ref.name ?: TestoBundle.message("testo.report.editor.name") + if (!TestoReportViewer.open(project, path, label)) browseReport(path) + } + + private fun showMenu() { + val group = DefaultActionGroup( + buildList { + if (TestoReportViewer.isAvailable) { + add(item("testo.report.open.webview", AllIcons.Actions.Preview, Mode.WEB_VIEW)) + } + add(item("testo.report.open.browser", AllIcons.Nodes.PpWeb, Mode.BROWSER)) + add(item("testo.report.copy.path", AllIcons.Actions.Copy, Mode.COPY_PATH)) + } + ) + JBPopupFactory.getInstance() + .createActionGroupPopup( + null, + group, + DataManager.getInstance().getDataContext(this), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ActionPlaces.TOOLBAR, + ) + .showUnderneathOf(this) + } + + private fun item(key: String, icon: Icon, mode: Mode) = + ReportTargetAction(TestoBundle.message(key), icon, mode, { ref }, project, mapToLocal) } - private fun item(key: String, icon: Icon, mode: Mode, target: () -> TestoReportRef?) = - ReportTargetAction(TestoBundle.message(key), icon, mode, target, project, mapToLocal) + private companion object { + private const val REFRESH_MS = 500 + + private val ICON: Icon = AllIcons.General.IndentDetected + private val ARROW: Icon = AllIcons.General.LinkDropTriangle + + // 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) + + private val DISABLED_TEXT = JBColor.namedColor("Label.disabledForeground", JBColor(0x8C8C8C, 0x6F737A)) + + private val LOG = logger() + } } +/** The platform's own greying, so a disabled cell matches every other disabled control in the row. */ +private fun disabledIcon(icon: Icon): Icon = IconLoader.getDisabledIcon(icon) + +/** What a menu entry does; [WEB_VIEW] falls back to the browser where JCEF is unavailable. */ +private enum class Mode { WEB_VIEW, BROWSER, COPY_PATH } + private class ReportTargetAction( text: String, icon: Icon?, private val mode: Mode, - private val target: () -> TestoReportRef?, + private val target: () -> TestoReportRef, private val project: Project, private val mapToLocal: (String) -> String?, ) : AnAction(text, null, icon), DumbAware { @@ -144,19 +297,18 @@ private class ReportTargetAction( override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = target()?.let { resolveReport(it, project, mapToLocal) } != null + e.presentation.isEnabledAndVisible = resolveReport(target(), project, mapToLocal) != null } override fun actionPerformed(e: AnActionEvent) { - val ref = target() ?: return + val ref = target() + // Re-resolved rather than remembered: the report may have been deleted since the menu was drawn. val path = resolveReport(ref, project, mapToLocal) ?: return val label = ref.name ?: TestoBundle.message("testo.report.editor.name") when (mode) { Mode.BROWSER -> browseReport(path) Mode.COPY_PATH -> CopyPasteManager.getInstance().setContents(StringSelection(path.toString())) - // Without JCEF a tab could only say as much, so the report opens where it can actually be read. - Mode.WEB_VIEW, Mode.DEFAULT -> - if (!TestoReportViewer.open(project, path, label)) browseReport(path) + Mode.WEB_VIEW -> if (!TestoReportViewer.open(project, path, label)) browseReport(path) } } } @@ -172,7 +324,8 @@ private fun browseReport(path: Path) = BrowserUtil.browse(path.toUri()) /** * The announced report as a local file, or `null` while none of the candidates exists. * - * Touches the filesystem — callers must be off the EDT. + * Touches the filesystem. Called from the cell's timer on the EDT — three `stat`s twice a second, which is the price of + * noticing that the file has appeared without anything announcing it. */ internal fun resolveReport(ref: TestoReportRef, project: Project, mapToLocal: (String) -> String?): Path? = // The mapper is the PHP plugin's, over a path it may know nothing about: whatever it throws must not take the diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 722e712..15b511a 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -46,7 +46,7 @@ testo.progress.elapsed.boost.value=≥{0}x testo.report.action.text=Report testo.report.action.description=Open the report Testo generated for this run: {0} -testo.report.action.description.none=This run generated no report. Configure a report renderer in testo.php to get one +testo.report.action.description.pending=Testo announced this report but has not written the file yet testo.report.open.webview=Open in WebView testo.report.open.browser=Open in Browser testo.report.copy.path=Copy Report Path From 0ac54e2927967da568f50cd68c34a5eb1c93e603 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 02:28:46 +0400 Subject: [PATCH 03/10] fix: keep report buttons disabled until the run's process exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A report is announced when Testo starts writing it, over the path the previous run wrote to — so checking the disk during the run found that run's report and offered it as this one's, with the button already enabled at startup. The check now waits for processTerminated, and a second session in the same console puts the flag back. fix: no raw NUL in TestoReportStore `|x` unescaped to U+0000 rather than U+0085, and having the character in the source verbatim made git read the whole file as binary. Written as an escape, alongside the `|l`/`|p` that were missing. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++++-- CLAUDE.md | 5 +++++ .../tests/console/TestoProgressAction.kt | 10 +++++++-- .../testo/tests/console/TestoReportAction.kt | 19 +++++++++++------- .../testo/tests/console/TestoReportStore.kt | Bin 6870 -> 7690 bytes .../resources/messages/TestoBundle.properties | 1 + .../xepozz/testo/TestoReportStoreTest.kt | 17 ++++++++++++++++ 7 files changed, 47 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb08ae..ec7bb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ - Report buttons at the right end of the test toolbar, past the run summary — one per report Testo announces with `##teamcity[testoReport format='html' path='…' relativePath='…' name='…' schemaVersion='…']`, labelled with the name - the announcement carries. A run that writes no report shows no button; a report announced but not yet written shows a - disabled one, saying so on hover; it lights up when the file appears and goes back to disabled if the file is removed. + the announcement carries. A run that writes no report shows no button; an announced report shows a disabled one for + as long as the run lasts, saying so on hover; it lights up once the process has exited with the file in place, and + goes back to disabled if the file is removed. The file is deliberately not looked for any earlier — a report is + announced as Testo starts writing it, over the path the previous run wrote to. - Clicking a button opens the report in an editor tab rendered by JCEF; the arrow on it offers the external browser and copying the report's path. Reopening an already-open report reloads it, so the tab shows the run that just finished rather than the one it was opened for. diff --git a/CLAUDE.md b/CLAUDE.md index 9b0bb83..024beb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -481,6 +481,11 @@ Non-obvious constraints already paid for in blood — read before touching the r `testSuiteFinished` is past the point where the platform still feeds the converter: such a line reaches neither our branch nor the console, it simply vanishes. So Testo announces a report when it *starts* writing it, and each cell polls for the file twice a second — which is also how a deleted report turns its button off again. +- **No cell looks at the disk before the process exits** (`TestoReportStore.runFinished`, set from the process listener + in `TestoProgressAction.attachTo`). A report is announced at the start of the run over the path the *previous* run + wrote to, so an earlier check enabled the button on that run's report. `onTestingStarted` puts the flag back for a + second session in the same console, and a run already over by the time the listener lands is caught by + `isProcessTerminated`. - **JCEF is declared twice and still never trusted.** On 262 it is the bundled `com.intellij.modules.jcef` plugin (`` + `jcef.xml`), on 252 a module inside the monolith (v2 ``); compiling against it proves nothing about runtime visibility. `TestoReportViewer.isAvailable` therefore asks by **reflection**: 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 ac250c4..d8d8690 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 @@ -122,7 +122,9 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb targets.clear() // Reports are deliberately not cleared here: a report is announced before the first test, so this // may well run after the announcement and would throw it away. A re-run writes the same path, and - // the store replaces by path, so nothing stale survives anyway. + // the store replaces by path, so nothing stale survives anyway. Their buttons do go back to + // disabled, though — the file on disk is the *previous* run's until this one ends. + reports.noteRunStarted() clock.clear() clock.noteStart() exitCode.set(null) @@ -152,13 +154,17 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb }) // The verdict follows the process, not the tree: a run that dies before reporting anything is still red, and - // a run killed mid-flight stops the clock even though onTestingFinished never came. + // a run killed mid-flight stops the clock even though onTestingFinished never came. The report buttons wait for + // the same event — an announced report is only worth looking for once nothing is writing it any more. handler?.addProcessListener(object : ProcessListener { override fun processTerminated(event: ProcessEvent) { exitCode.set(event.exitCode) clock.noteFinish() + reports.noteRunFinished() } }) + // A run short enough to be over before this wiring lands gets no processTerminated at all. + if (handler?.isProcessTerminated == true) reports.noteRunFinished() } /** 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 5828bec..d7e190b 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 @@ -47,8 +47,9 @@ import javax.swing.Timer * report, and an arrow for the other ways to open it — and, being one right-aligned action rather than an expanded * group, it actually lands to the right of the summary instead of among the buttons on the left. * - * States, in the order a run walks through them: no announcement, no button; announced but not yet written, a disabled - * button; the file appears, the button lights up; the file is deleted, it goes back to disabled. + * States, in the order a run walks through them: no announcement, no button; announced, a disabled button for as long + * as the run lasts; the process exits with the file there, the button lights up; the file is deleted, it goes back to + * disabled. */ class TestoReportsAction( private val reports: TestoReportStore, @@ -178,15 +179,19 @@ class TestoReportsAction( /** Re-resolves the file, which is what moves the cell between enabled and disabled. */ fun refresh() { - val found = resolveReport(ref, project, mapToLocal) + // Not while the run is going: the report is announced as Testo starts writing it, over the path the + // previous run wrote to — so a check now would light the button up on a report that belongs to that run. + val finished = reports.runFinished + val found = if (finished) resolveReport(ref, project, mapToLocal) else null val changed = found != located located = found cursor = if (found == null) Cursor.getDefaultCursor() else Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - toolTipText = when (found) { - null -> TestoBundle.message("testo.report.action.description.pending") - else -> TestoBundle.message("testo.report.action.description", found.toString()) + toolTipText = when { + found != null -> TestoBundle.message("testo.report.action.description", found.toString()) + finished -> TestoBundle.message("testo.report.action.description.pending") + else -> TestoBundle.message("testo.report.action.description.running") } - val digest = "report=${ref.path} resolved=$found" + val digest = "report=${ref.path} finished=$finished resolved=$found" if (digest != lastLogged) { lastLogged = digest LOG.info("Testo report button: $digest") 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 3efa6025a6abd90409d255207a24ee806e1a1365..8c8c93b9c0e0ad1f5e4e220ce56b1f29c6e96494 100644 GIT binary patch delta 860 zcmaJ<&59F25I!gfIjjc{doA{o>=2W%u&~A-L|2aCDumn@qYxSIxv=WITIa zufBt@@8Sda0$x2@-Is&A*C?tT4nP!E%&1K~^Hl}6U+K^9nfmm-n; zD~7cw39{Gi_|yF5ov!Hpy$ zm6Mc;T^hhkS11yn?GI=aV+yVN`tB z(#BmSFU8nXV-0D{#_&kss4U!vTor^nlhWRmHm9@U z-+(>VKgd4p!y~#C{|}sfxwG;Yx_9~Wz~B0Z)RnsVxb*XML8OsogT$px;!)|{>O&hA`o4_{yV1u@eU^Z)<= delta 31 ncmeCOxn{cIJ>TR+!RpDKg7TZig-Y2cvr5%6GHl);6~zny!+8p= diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 15b511a..eb4fea2 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -47,6 +47,7 @@ testo.progress.elapsed.boost.value=≥{0}x testo.report.action.text=Report testo.report.action.description=Open the report Testo generated for this run: {0} testo.report.action.description.pending=Testo announced this report but has not written the file yet +testo.report.action.description.running=Testo is writing this report; it opens once the run finishes testo.report.open.webview=Open in WebView testo.report.open.browser=Open in Browser testo.report.copy.path=Copy Report Path diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt index d765099..1123486 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -4,6 +4,7 @@ import com.github.xepozz.testo.tests.console.TestoReportRef import com.github.xepozz.testo.tests.console.TestoReportStore import com.github.xepozz.testo.tests.console.reportPathCandidates import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import java.nio.file.Path @@ -123,6 +124,22 @@ class TestoReportStoreTest { assertEquals("second", store.primary()!!.name) } + @Test + fun aRunIsUnfinishedUntilItsProcessSaysOtherwise() { + // What keeps the button disabled: the announced path holds the previous run's report until this run ends. + val store = TestoReportStore() + store.note(ref("/tmp/index.html")) + assertFalse(store.runFinished) + + store.noteRunFinished() + assertTrue(store.runFinished) + + // A second session in the same console starts over, keeping the reports it was told about. + store.noteRunStarted() + assertFalse(store.runFinished) + assertEquals(1, store.all().size) + } + @Test fun clearForgetsThePreviousRun() { val store = TestoReportStore() From 084d8c7ca954fe130c665b1527d91726544b8ee3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 02:29:31 +0400 Subject: [PATCH 04/10] style: write the channel merge key's separator as an escape The NUL that separates channel from label was in the source verbatim; as an escape it is visible in a diff and cannot be lost to an editor. Assisted-By: Claude Opus 5 (1M context) --- .../com/github/xepozz/testo/tests/console/TestoChannelsUi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5113dfc..124f5a0 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 @@ -624,7 +624,7 @@ object TestoChannelsUi { if (released) return@Runnable // Consecutive format-less messages from the same channel/test fold into one canvas: append to the // previous card's editor instead of stacking another card. - val mergeKey = if (fileType == null) "${chunk.channel}$leafLabel" else null + val mergeKey = if (fileType == null) "${chunk.channel}\u0000$leafLabel" else null val target = if (mergeKey != null && mergeKey == lastMergeKey) { lastEditor?.takeUnless { it.isDisposed } } else { From fb6df27f915718728cc0af35d8ac9af9450504c9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 11:40:53 +0400 Subject: [PATCH 05/10] fix: drop the mandatory JCEF module dependency that stopped the 252 build loading fix: reload the report through JBCefBrowser instead of org.cef fix: read a report announcement off raw output as well refactor: drop the debug logging left around the report button perf: refresh a report cell only when its state changed docs: state the report notes as rules rather than as what was fixed intellij.platform.ui.jcef lives inside 262s bundled JCEF plugin, and on 252 JCEF is part of the monolith and needs no declaration at all, so naming the module in the shared left the 252 artifact with a missing mandatory dependency. verifyPlugin now reports Compatible on IU-252/253/261 and IU-262. org.cef is absent from the compile classpath: cefBrowser.reloadIgnoreCache() built only against a JDK that happens to bundle JCEF, which CIs Zulu 21 does not. TestoReportRef.fromServiceMessageLine was never called, so an announcement behind a colour escape reached the console as plain text and was lost. Assisted-By: Claude Opus 5 (1M context) --- CLAUDE.md | 17 +++++---- .../testo/tests/TestoConsoleProperties.kt | 7 ++-- .../tests/console/TestoConsoleAugmenter.kt | 11 +----- .../TestoOutputToGeneralEventsConverter.kt | 12 +++---- .../testo/tests/console/TestoReportAction.kt | 35 ++++++++----------- .../testo/tests/console/TestoReportStore.kt | 15 ++++---- .../xepozz/testo/ui/TestoReportEditor.kt | 10 ++++-- src/main/resources/META-INF/plugin.xml | 12 +++---- 8 files changed, 52 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 024beb2..f5879ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -481,17 +481,22 @@ Non-obvious constraints already paid for in blood — read before touching the r `testSuiteFinished` is past the point where the platform still feeds the converter: such a line reaches neither our branch nor the console, it simply vanishes. So Testo announces a report when it *starts* writing it, and each cell polls for the file twice a second — which is also how a deleted report turns its button off again. +- **The announcement is read twice, as a service message and off the raw text.** The platform parses a line only when it + *starts* with `##teamcity[`, so anything in front of it (a colour escape) would lose the report; + `TestoReportRef.fromServiceMessageLine` scans for it anywhere and the store deduplicates by path. - **No cell looks at the disk before the process exits** (`TestoReportStore.runFinished`, set from the process listener in `TestoProgressAction.attachTo`). A report is announced at the start of the run over the path the *previous* run wrote to, so an earlier check enabled the button on that run's report. `onTestingStarted` puts the flag back for a second session in the same console, and a run already over by the time the listener lands is caught by `isProcessTerminated`. -- **JCEF is declared twice and still never trusted.** On 262 it is the bundled `com.intellij.modules.jcef` plugin - (`` + `jcef.xml`), on 252 a module inside the monolith (v2 ``); compiling - against it proves nothing about runtime visibility. `TestoReportViewer.isAvailable` therefore asks by **reflection**: - a named reference to `JBCefApp` throws `NoClassDefFoundError` when the *enclosing* method's class is verified, before - any `try` around the call can catch it — which is how one absent class took down the whole toolbar action group. No - JCEF type may be mentioned outside a class that loads only after `isAvailable` answers true. +- **JCEF is declared for 262 only, and still never trusted.** There it is the bundled `com.intellij.modules.jcef` + plugin (`` + `jcef.xml`); on 252 it is part of the monolith and visible without a declaration. Its + 262 module `intellij.platform.ui.jcef` must never go in ``: that form is mandatory and the module does + not exist on 252, so the 252 build would not load at all. Compiling against JCEF proves nothing about runtime + visibility, so `TestoReportViewer.isAvailable` asks by **reflection** — a named reference to `JBCefApp` throws + `NoClassDefFoundError` when the enclosing method's class is verified, before any `try` can catch it. No JCEF type may + be mentioned outside a class that loads only after `isAvailable` answers true, and none outside `com.intellij.ui.jcef` + at all: `org.cef` is absent from the compile classpath. - **The report tab is our own `FileEditorProvider`, not the platform's `HTMLEditorProvider`** — that one is `@ApiStatus.Internal`. It accepts nothing but `TestoReportVirtualFile`, and `TestoReportViewer` keeps one such file per path so re-opening a report returns to its tab (the platform keys tabs by identity, not equality) and reloads it. 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 65b4a24..0fb497d 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -101,10 +101,8 @@ class TestoConsoleProperties( // 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. - public override fun createImportActions(): Array { - com.intellij.openapi.diagnostic.logger() - .info("Testo console actions created (report button included)") - return arrayOf( + public override fun createImportActions(): Array = + arrayOf( com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), *(super.createImportActions() ?: emptyArray()), // Both are right-aligned, and the toolbar lays that group out from the right edge inwards — so the one @@ -113,5 +111,4 @@ class TestoConsoleProperties( reportsAction, progressAction, ) - } } 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 cae5edd..a8c260c 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 @@ -3,7 +3,6 @@ package com.github.xepozz.testo.tests.console import com.github.xepozz.testo.tests.TestoConsoleProperties import com.intellij.execution.ExecutionListener import com.intellij.execution.ExecutorRegistry -import com.intellij.ide.ActivityTracker import com.intellij.execution.process.OSProcessHandler import com.intellij.execution.process.ProcessHandler import com.intellij.execution.runners.ExecutionEnvironment @@ -45,15 +44,7 @@ class TestoConsoleAugmenter(private val project: Project) : ExecutionListener { // 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). - // - // The same nudges re-ask the toolbar about the report button: a report is announced when Testo starts - // writing it, so the file itself usually lands only around now, and without an activity bump nothing would - // re-run the action's update until the user touches something. - val refresh = Runnable { - TestoHistoryIndex.refreshLens(project) - ActivityTracker.getInstance().inc() - } - ActivityTracker.getInstance().inc() + 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) } 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 95c78d6..7ea2603 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 @@ -27,7 +27,6 @@ class TestoOutputToGeneralEventsConverter( override fun setProcessor(processor: GeneralTestEventsProcessor?) { super.setProcessor(processor) processor?.let { nodes.attachTo(it) } - LOG.info("Testo converter attached (processor=${processor?.javaClass?.simpleName})") } /** Version off the banner, kept only to name it in the too-old notification. */ @@ -44,6 +43,9 @@ class TestoOutputToGeneralEventsConverter( override fun process(text: String, outputType: Key<*>) { if (runnerVersion == null) runnerVersion = TestoProtocolGate.parseVersion(text) + // The second route to an announcement: the platform parses a line only when it *starts* with the message, so + // one behind a colour escape or an unterminated line reaches it as plain text. The store dedups by path. + TestoReportRef.fromServiceMessageLine(text)?.let { reportStore.note(it) } super.process(text, outputType) } @@ -114,12 +116,8 @@ class TestoOutputToGeneralEventsConverter( } } - // Testo's own message, naming a report of this run. It has to arrive while the run is still going — once the - // root suite closes the platform stops feeding this converter — so Testo announces a report when it starts - // writing it, and the button waits for the file to appear. Not forwarded, for the same reason as - // buildProblem: the platform would echo the raw line into the console. + // Testo's own message, naming a report of this run. Not forwarded, for the same reason as buildProblem. TESTO_REPORT -> { - LOG.info("Testo report announced (service message): $attrs") TestoReportRef.fromAttributes(attrs)?.let { reportStore.note(it) } return } @@ -228,7 +226,5 @@ class TestoOutputToGeneralEventsConverter( private const val TEST_IGNORED = "testIgnored" private const val BUILD_PROBLEM = "buildProblem" private const val TESTO_REPORT = "testoReport" - - private val LOG = com.intellij.openapi.diagnostic.logger() } } 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 d7e190b..4b70489 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 @@ -13,7 +13,6 @@ import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.actionSystem.RightAlignedToolbarAction import com.intellij.openapi.actionSystem.ex.CustomComponentAction -import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project @@ -41,15 +40,11 @@ import javax.swing.Timer /** * The report buttons at the far right of the test toolbar, past the run summary — one per report Testo announced. * - * Hand-drawn, for the same reasons the run summary beside it is: a plain toolbar button shows the icon alone (the text - * survives only as a tooltip), `SplitButtonAction` paints its own component and drops the text entirely, and - * `ComboBoxAction` turns every click into a dropdown. Only a custom component gives icon, name, a click that opens the - * report, and an arrow for the other ways to open it — and, being one right-aligned action rather than an expanded - * group, it actually lands to the right of the summary instead of among the buttons on the left. + * Hand-drawn, like the run summary beside it: no platform widget gives icon, name, a click that opens the report and an + * arrow for the other ways to open it, and an expanded `ActionGroup` loses [RightAlignedToolbarAction] on its children. * - * States, in the order a run walks through them: no announcement, no button; announced, a disabled button for as long - * as the run lasts; the process exits with the file there, the button lights up; the file is deleted, it goes back to - * disabled. + * A run walks the cells through four states: not announced (no button), announced (disabled for as long as the run + * lasts), the process exits with the file there (enabled), the file is deleted (disabled again). */ class TestoReportsAction( private val reports: TestoReportStore, @@ -133,12 +128,14 @@ class TestoReportsAction( cells.values.forEach { it.refresh() } isVisible = cells.isNotEmpty() + // Only when the row itself changed shape — a cell that merely lit up repaints itself, and this runs + // twice a second for as long as the tab is open. val width = preferredSize.width if (width != laidOutWidth) { laidOutWidth = width revalidate() + repaint() } - repaint() } } @@ -146,8 +143,10 @@ class TestoReportsAction( private inner class ReportCell(ref: TestoReportRef) : JComponent() { var ref: TestoReportRef = ref private var located: Path? = null + private var runWasFinished = false + // 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 lastLogged: String? = null // Asked for per paint: a font set once on a raw JComponent outlives a zoom, since there is no UI delegate to // reinstall it, and the cell would keep the size it was built at. @@ -183,20 +182,18 @@ class TestoReportsAction( // previous run wrote to — so a check now would light the button up on a report that belongs to that run. val finished = reports.runFinished val found = if (finished) resolveReport(ref, project, mapToLocal) else null - val changed = found != located + // Cursor and tooltip only on a real change: setCursor repaints the pointer, and this runs twice a second. + if (refreshed && found == located && finished == runWasFinished) return + refreshed = true located = found + runWasFinished = finished cursor = if (found == null) Cursor.getDefaultCursor() else Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) toolTipText = when { found != null -> TestoBundle.message("testo.report.action.description", found.toString()) finished -> TestoBundle.message("testo.report.action.description.pending") else -> TestoBundle.message("testo.report.action.description.running") } - val digest = "report=${ref.path} finished=$finished resolved=$found" - if (digest != lastLogged) { - lastLogged = digest - LOG.info("Testo report button: $digest") - } - if (changed) repaint() + repaint() } override fun getPreferredSize(): Dimension { @@ -279,8 +276,6 @@ class TestoReportsAction( private val GAP get() = JBUI.scale(4) private val DISABLED_TEXT = JBColor.namedColor("Label.disabledForeground", JBColor(0x8C8C8C, 0x6F737A)) - - private val LOG = logger() } } 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 8c8c93b..6497ad7 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,10 +21,8 @@ data class TestoReportRef( /** * Whether this is a report the button can show as a page. * - * Testo announces every report it writes, and they are not all pages: a data document for external tooling, or a - * coverage report, has nothing to open in a browser. Formats the plugin can do something *else* with get their own - * handling — a coverage report, for instance, is an opportunity to fill the Coverage tool window without re-running - * under coverage — and until then they are simply not what this button offers. + * Testo announces every report it writes, and not all are pages — a data document for external tooling has nothing + * to open in a browser. Such formats are kept for handling of their own, not offered here. */ val isViewable: Boolean get() = VIEWABLE_FORMATS.any { format.equals(it, ignoreCase = true) } @@ -36,12 +34,11 @@ data class TestoReportRef( private const val MESSAGE_NAME = "testoReport" /** - * The message read straight off a line of output, or `null` when the line holds none. + * The message read straight off raw output, or `null` when it holds none. * - * The converter also sees it as a parsed `ServiceMessage`, but only if the platform recognised the line — - * anything in front of `##teamcity[` (a colour escape, output not terminated by a newline) and it is handed to - * the console as plain text instead. So the line is scanned for the message anywhere in it, and the store - * deduplicates by path when both routes deliver. + * The platform parses a line only when it *starts* with `##teamcity[`, so anything in front of it (a colour + * escape, output not terminated by a newline) leaves the announcement to reach the console as plain text. Hence + * the scan for the message anywhere in the text; the store deduplicates by path when both routes deliver. */ fun fromServiceMessageLine(line: String): TestoReportRef? { val start = line.indexOf("##teamcity[$MESSAGE_NAME") diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt index 1cbae43..ebd2420 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt @@ -72,9 +72,15 @@ class TestoReportFileEditor(private val file: TestoReportVirtualFile) : UserData override fun removePropertyChangeListener(listener: PropertyChangeListener) = Unit - /** Re-reads the report from disk — what a second click on the toolbar button means after a new run. */ + /** + * Re-reads the report from disk — what a second click on the toolbar button means after a new run. + * + * Through `loadURL`, not `cefBrowser.reloadIgnoreCache()`: `CefBrowser` lives in `org.cef`, which the platform + * artifact does not put on the compile classpath — it compiles only against a JDK that happens to bundle JCEF, and + * CI's does not. `JBCefBrowser` is the module we do depend on, and navigating to the same `file://` URL re-reads it. + */ fun reload() { - browser?.cefBrowser?.reloadIgnoreCache() + browser?.loadURL(file.reportUrl) } override fun dispose() { diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 8712573..7c454bf 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -8,14 +8,12 @@ com.jetbrains.php com.intellij.modules.coverage - + com.intellij.modules.jcef - - - messages.TestoBundle From b15acb0883afa8160a9c2f820a47bcd2374bb0b9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 14:45:27 +0400 Subject: [PATCH 06/10] fix: offer a report only when this run wrote it A stopped run kills Testo before it rewrites the report, and the path never changes between runs, so the file already sitting there was enabling the button as if it belonged to the run just cancelled. It now has to be no older than the run start, which also covers a Testo that died before its reporter ran. The mark is floored to a whole second: a filesystem that keeps mtime at that granularity would date a report written moments after the start before it. Assisted-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- CLAUDE.md | 4 +++ .../tests/console/TestoProgressAction.kt | 2 ++ .../testo/tests/console/TestoReportAction.kt | 27 ++++++++++++---- .../testo/tests/console/TestoReportStore.kt | 15 ++++++++- .../resources/messages/TestoBundle.properties | 2 +- .../xepozz/testo/TestoReportStoreTest.kt | 32 +++++++++++++++++++ 7 files changed, 75 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7bb60..302a9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ the announcement carries. A run that writes no report shows no button; an announced report shows a disabled one for as long as the run lasts, saying so on hover; it lights up once the process has exited with the file in place, and goes back to disabled if the file is removed. The file is deliberately not looked for any earlier — a report is - announced as Testo starts writing it, over the path the previous run wrote to. + announced as Testo starts writing it, over the path the previous run wrote to. A file older than the run itself is + not offered either, so stopping a run leaves the button disabled rather than opening the previous report. - Clicking a button opens the report in an editor tab rendered by JCEF; the arrow on it offers the external browser and copying the report's path. Reopening an already-open report reloads it, so the tab shows the run that just finished rather than the one it was opened for. diff --git a/CLAUDE.md b/CLAUDE.md index f5879ca..1e283ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -489,6 +489,10 @@ Non-obvious constraints already paid for in blood — read before touching the r wrote to, so an earlier check enabled the button on that run's report. `onTestingStarted` puts the flag back for a second session in the same console, and a run already over by the time the listener lands is caught by `isProcessTerminated`. +- **A report also has to be newer than the run** (`TestoReportStore.runStartedAt`, floored to a whole second for + filesystems that keep mtime by the second). The path is the same every run, so a run that was stopped — or that died + before its reporter ran — leaves the previous report in place, and the process having exited says nothing about who + wrote that file. - **JCEF is declared for 262 only, and still never trusted.** There it is the bundled `com.intellij.modules.jcef` plugin (`` + `jcef.xml`); on 252 it is part of the monolith and visible without a declaration. Its 262 module `intellij.platform.ui.jcef` must never go in ``: that form is mandatory and the module does 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 d8d8690..4a41cdc 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 @@ -105,7 +105,9 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb console.properties.addListener(TestConsoleProperties.HIDE_PASSED_TESTS, onToggle) console.properties.addListener(TestConsoleProperties.HIDE_IGNORED_TEST, onToggle) // Called from the augmenter's processStarted, so this is as close to the real start as the plugin can get. + // The reports take the same mark: it is what tells this run's report from the one already at that path. clock.noteStart() + reports.noteRunStarted() viewer.addEventsListener(object : TestResultsViewer.EventsListener { override fun onTestingStarted(viewer: TestResultsViewer) { 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 4b70489..b0217ee 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 @@ -181,7 +181,7 @@ class TestoReportsAction( // Not while the run is going: the report is announced as Testo starts writing it, over the path the // previous run wrote to — so a check now would light the button up on a report that belongs to that run. val finished = reports.runFinished - val found = if (finished) resolveReport(ref, project, mapToLocal) else null + val found = if (finished) resolveReport(ref, project, mapToLocal, reports.runStartedAt) else null // Cursor and tooltip only on a real change: setCursor repaints the pointer, and this runs twice a second. if (refreshed && found == located && finished == runWasFinished) return refreshed = true @@ -262,7 +262,7 @@ class TestoReportsAction( } private fun item(key: String, icon: Icon, mode: Mode) = - ReportTargetAction(TestoBundle.message(key), icon, mode, { ref }, project, mapToLocal) + ReportTargetAction(TestoBundle.message(key), icon, mode, { ref }, project, mapToLocal, reports) } private companion object { @@ -292,18 +292,19 @@ private class ReportTargetAction( private val target: () -> TestoReportRef, private val project: Project, private val mapToLocal: (String) -> String?, + private val reports: TestoReportStore, ) : AnAction(text, null, icon), DumbAware { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = resolveReport(target(), project, mapToLocal) != null + e.presentation.isEnabledAndVisible = resolve() != null } override fun actionPerformed(e: AnActionEvent) { val ref = target() // Re-resolved rather than remembered: the report may have been deleted since the menu was drawn. - val path = resolveReport(ref, project, mapToLocal) ?: return + val path = resolve() ?: return val label = ref.name ?: TestoBundle.message("testo.report.editor.name") when (mode) { Mode.BROWSER -> browseReport(path) @@ -311,6 +312,8 @@ private class ReportTargetAction( Mode.WEB_VIEW -> if (!TestoReportViewer.open(project, path, label)) browseReport(path) } } + + private fun resolve(): Path? = resolveReport(target(), project, mapToLocal, reports.runStartedAt) } /** @@ -322,15 +325,25 @@ private class ReportTargetAction( private fun browseReport(path: Path) = BrowserUtil.browse(path.toUri()) /** - * The announced report as a local file, or `null` while none of the candidates exists. + * The announced report as a local file this run wrote, or `null` while there is none. * * Touches the filesystem. Called from the cell's timer on the EDT — three `stat`s twice a second, which is the price of * noticing that the file has appeared without anything announcing it. */ -internal fun resolveReport(ref: TestoReportRef, project: Project, mapToLocal: (String) -> String?): Path? = +internal fun resolveReport( + ref: TestoReportRef, + project: Project, + mapToLocal: (String) -> String?, + writtenAfter: Long, +): Path? = // The mapper is the PHP plugin's, over a path it may know nothing about: whatever it throws must not take the // toolbar's update with it. reportPathCandidates(ref, project.basePath) { runCatching { mapToLocal(it) }.getOrNull() } .asSequence() .mapNotNull { runCatching { Path.of(it) }.getOrNull() } - .firstOrNull { runCatching { Files.isRegularFile(it) }.getOrDefault(false) } + .firstOrNull { isReportOf(it, writtenAfter) } + +/** A file left by an earlier run reads as this one's, since the path never changes — hence the timestamp. */ +internal fun isReportOf(path: Path, writtenAfter: Long): Boolean = runCatching { + Files.isRegularFile(path) && Files.getLastModifiedTime(path).toMillis() >= writtenAfter +}.getOrDefault(false) 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 6497ad7..2971626 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 @@ -83,8 +83,20 @@ class TestoReportStore { var runFinished: Boolean = false private set - fun noteRunStarted() { + /** + * When the current run began, floored to a whole second. + * + * A report older than this is the *previous* run's: the path is the same every run, so a run stopped before Testo + * wrote its report leaves the old file in place. Floored because a filesystem that keeps mtime by the second would + * otherwise date a report written moments after the start before it. + */ + @Volatile + var runStartedAt: Long = 0 + private set + + fun noteRunStarted(now: Long = System.currentTimeMillis()) { runFinished = false + runStartedAt = now - now % 1000 } fun noteRunFinished() { @@ -97,6 +109,7 @@ class TestoReportStore { fun clear() { runFinished = false + runStartedAt = 0 synchronized(reports) { reports.clear() } } diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index eb4fea2..76dbeff 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -46,7 +46,7 @@ testo.progress.elapsed.boost.value=≥{0}x testo.report.action.text=Report testo.report.action.description=Open the report Testo generated for this run: {0} -testo.report.action.description.pending=Testo announced this report but has not written the file yet +testo.report.action.description.pending=Testo announced this report but did not write it in this run testo.report.action.description.running=Testo is writing this report; it opens once the run finishes testo.report.open.webview=Open in WebView testo.report.open.browser=Open in Browser diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt index 1123486..d737fd5 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -2,12 +2,15 @@ package com.github.xepozz.testo import com.github.xepozz.testo.tests.console.TestoReportRef import com.github.xepozz.testo.tests.console.TestoReportStore +import com.github.xepozz.testo.tests.console.isReportOf import com.github.xepozz.testo.tests.console.reportPathCandidates import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import java.nio.file.Files import java.nio.file.Path +import java.nio.file.attribute.FileTime import org.junit.Test /** @@ -140,6 +143,35 @@ class TestoReportStoreTest { assertEquals(1, store.all().size) } + @Test + fun theRunStartIsFlooredToAWholeSecond() { + // A filesystem that keeps mtime by the second would date a report written moments after the start before it. + val store = TestoReportStore() + store.noteRunStarted(1_700_000_123_456) + + assertEquals(1_700_000_123_000, store.runStartedAt) + } + + @Test + fun aFileLeftByAnEarlierRunIsNotThisRunsReport() { + // What a stopped run leaves behind: Testo is killed before rewriting the report, so the path still holds the + // previous one. + val file = Files.createTempFile("testo-report", ".html") + try { + Files.setLastModifiedTime(file, FileTime.fromMillis(1_000)) + + assertFalse(isReportOf(file, writtenAfter = 2_000)) + assertTrue(isReportOf(file, writtenAfter = 1_000)) + } finally { + Files.deleteIfExists(file) + } + } + + @Test + fun aMissingReportIsNoReport() { + assertFalse(isReportOf(Path.of("no", "such", "report.html"), writtenAfter = 0)) + } + @Test fun clearForgetsThePreviousRun() { val store = TestoReportStore() From 137691333af4ad8cfa19b828b2fd18b7e4c803f9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 18:05:08 +0400 Subject: [PATCH 07/10] feat: a report can open on its own once the run delivers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: the report menu shows the file in the file manager The choices — WebView and browser independently, for this run, this project or every project — are keyed by the report's format and name, its identity across runs. The button stays live the whole run: a click before delivery arms the open, a second click silences everything for this run behind one flag without unchecking the standing choices. The icon's colour carries the state: grey with nothing to open, blue once delivered, green while an open stands scheduled. Assisted-By: Claude Fable 5 --- CHANGELOG.md | 26 +- CLAUDE.md | 9 +- .../testo/tests/console/TestoReportAction.kt | 248 ++++++++++++++---- .../tests/console/TestoReportAutoOpen.kt | 59 +++++ .../testo/tests/console/TestoReportStore.kt | 47 ++++ .../resources/messages/TestoBundle.properties | 8 +- .../xepozz/testo/TestoReportStoreTest.kt | 61 +++++ 7 files changed, 400 insertions(+), 58 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 302a9b4..d7a3358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,24 @@ - Report buttons at the right end of the test toolbar, past the run summary — one per report Testo announces with `##teamcity[testoReport format='html' path='…' relativePath='…' name='…' schemaVersion='…']`, labelled with the name - the announcement carries. A run that writes no report shows no button; an announced report shows a disabled one for - as long as the run lasts, saying so on hover; it lights up once the process has exited with the file in place, and - goes back to disabled if the file is removed. The file is deliberately not looked for any earlier — a report is - announced as Testo starts writing it, over the path the previous run wrote to. A file older than the run itself is - not offered either, so stopping a run leaves the button disabled rather than opening the previous report. -- Clicking a button opens the report in an editor tab rendered by JCEF; the arrow on it offers the external browser and - copying the report's path. Reopening an already-open report reloads it, so the tab shows the run that just finished - rather than the one it was opened for. + the announcement carries. A run that writes no report shows no button. The report counts as delivered once the + process has exited with the file in place and no older than the run — deliberately not any earlier, since a report is + announced as Testo starts writing it, over the path the previous run wrote to; so stopping a run never opens the + previous run's report. +- Clicking a button opens the report in an editor tab rendered by JCEF; the arrow on it offers the external browser, + showing the file in the file manager, and copying the report's path. Reopening an already-open report reloads it, so + the tab shows the run that just finished rather than the one it was opened for. +- The button is live the whole time: a click before the report is delivered is kept and replayed once the run delivers + the file, marked by the button's icon turning green and by the *Open When This Run Finishes* checkmark + under the arrow's *Open in WebView* / *Open in Browser* entries. A second click un-presses it — every way of opening + is silenced for this run alone, while the standing project- and application-wide checkmarks stay put; pressed again, + they resume, and with none of them checked the press schedules the WebView for this run. The icon's colour tells + the report's state — grey with nothing to open, blue with the run's report on disk, green with an open scheduled — + and the tooltip spells it out: still being written, delivered, or never written in this run. +- Those entries also take a standing choice: *Always Open in This Project* or *Always Open in Every Project*, remembered + per report format and name, opening the report the chosen way as each run delivers it. Every checkmark stands on its + own — the WebView's and the browser's don't steal from each other, and both checked opens the report both ways. + Checked after the report already arrived, it starts with the next run rather than popping the current one open. - Only reports the button can show as a page are offered. Everything else Testo announces is kept, ready for formats the plugin will handle differently. - The announced path is absolute inside the *execution* environment, so it is looked for through the PHP path mapper diff --git a/CLAUDE.md b/CLAUDE.md index 1e283ea..9a48842 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,7 @@ src/main/kotlin/com/github/xepozz/testo/ │ │ ├── TestoNodeIndex.kt # SMTestProxy → nodeId, off the platform's own node events │ │ ├── TestoProgressAction.kt # right-aligned toolbar summary: ring, fraction, status counters, elapsed │ │ ├── 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) │ │ ├── TestoTestTreeDecorator.kt # wraps the tree's cell renderer: status icons + description tooltips │ │ ├── TestoRepeatedFrameFolding.kt # folds repeated `#N frame` lines @@ -392,9 +393,11 @@ it keeps everything the class holds (a `#[Test]` class typed as `test` would dro 10. **Generated reports** — Testo announces each report with the non-standard `##teamcity[testoReport …]`; `TestoReportStore` keeps them, and `TestoReportsAction` draws one button per viewable report past the run summary, - labelled with the announced name, opening it in a JCEF tab (`ui/TestoReportEditor.kt`) or the external browser. Its - four states are: not announced (no button), announced without a file (disabled), file present (enabled), file gone - (disabled again). The spec for the report itself lives in the Testo repository (`docs/spec/html-report.md`). + labelled with the announced name, opening it in a JCEF tab (`ui/TestoReportEditor.kt`) or the external browser. The + button is always enabled: with the file delivered a click opens it, before that it is kept as a deferred open and + replayed when the run delivers the file — marked by a clock badge on the icon, with the tooltip carrying the state. + `TestoReportAutoOpen` keeps that arm and the standing per-project/per-application auto-open choices, keyed by the + report's format + name. The spec for the report itself lives in the Testo repository (`docs/spec/html-report.md`). ## Implementation notes & gotchas 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 b0217ee..47d05fa 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 @@ -5,6 +5,7 @@ import com.github.xepozz.testo.ui.TestoReportViewer import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil import com.intellij.ide.DataManager +import com.intellij.ide.actions.RevealFileAction import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction @@ -12,13 +13,14 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup 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.ide.CopyPasteManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.util.IconLoader import com.intellij.ui.JBColor +import com.intellij.util.IconUtil import com.intellij.util.ui.GraphicsUtil import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -43,8 +45,10 @@ import javax.swing.Timer * Hand-drawn, like the run summary beside it: no platform widget gives icon, name, a click that opens the report and an * arrow for the other ways to open it, and an expanded `ActionGroup` loses [RightAlignedToolbarAction] on its children. * - * A run walks the cells through four states: not announced (no button), announced (disabled for as long as the run - * lasts), the process exits with the file there (enabled), the file is deleted (disabled again). + * A cell always looks and acts enabled; what the click does is decided by whether the file is there yet. Present, fresh + * and the process exited — it opens; otherwise the click is kept as a deferred open, replayed once the run delivers the + * file (see [TestoReportAutoOpen]). A scheduled open wears a clock badge on the cell's icon, and the tooltip tells the + * rest: writing, delivered, or never written in this run. */ class TestoReportsAction( private val reports: TestoReportStore, @@ -75,10 +79,19 @@ class TestoReportsAction( // Laid out by hand, like the run summary: a LayoutManager caches size requirements, and this row is // re-measured whenever a report appears or its name changes. layout = null - border = JBUI.Borders.empty(0, 6, 0, 4) + // The left inset holds the separator that parts the reports from the run summary. + border = JBUI.Borders.empty(0, 10, 0, 4) isVisible = false } + // A platform Separator can't sit here: it is not right-aligned, so it would land among the left buttons. + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + g.color = JBColor.border() + val inset = JBUI.scale(4) + g.fillRect(JBUI.scale(2), inset, JBUI.scale(1), height - 2 * inset) + } + override fun addNotify() { super.addNotify() timer.start() @@ -144,6 +157,10 @@ class TestoReportsAction( var ref: TestoReportRef = ref private var located: Path? = null private var runWasFinished = false + private var willAutoOpen = false + // The run whose report this cell has already handed to maybeAutoOpen, so one run opens it at most once — + // a report deleted and rewritten within the run must not pop the viewer open again. + private var autoOpenedRun = -1L // 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 @@ -154,6 +171,8 @@ class TestoReportsAction( init { isOpaque = false + // Always the hand: a cell whose file is not there yet still takes the click, as a deferred open. + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) addMouseListener(object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { hovered = true @@ -166,8 +185,11 @@ class TestoReportsAction( } override fun mouseClicked(e: MouseEvent) { - if (located == null) return - if (e.x >= width - arrowZone()) showMenu() else openDefault() + when { + e.x >= width - arrowZone() -> showMenu() + located != null -> open(defaultWay(), located!!) + else -> toggleScheduled() + } } }) } @@ -176,26 +198,47 @@ class TestoReportsAction( private fun arrowZone(): Int = ARROW.iconWidth + GAP + PADDING - /** Re-resolves the file, which is what moves the cell between enabled and disabled. */ + /** Re-resolves the file, which is what moves the cell between lit and dimmed. */ fun refresh() { // Not while the run is going: the report is announced as Testo starts writing it, over the path the // previous run wrote to — so a check now would light the button up on a report that belongs to that run. val finished = reports.runFinished val found = if (finished) resolveReport(ref, project, mapToLocal, reports.runStartedAt) else null - // Cursor and tooltip only on a real change: setCursor repaints the pointer, and this runs twice a second. - if (refreshed && found == located && finished == runWasFinished) return + maybeAutoOpen(found, finished) + // Scheduled only while the run still goes: what remains scheduled after it (a stopped run's arm) has + // nothing left to fire it, and must not keep wearing the colour of a promise. + val willOpen = !finished && TestoReportAutoOpen.decide(project, reports, ref).isNotEmpty() + // Tooltip and repaint only on a real change: this runs twice a second for as long as the tab is open. + if (refreshed && found == located && finished == runWasFinished && willOpen == willAutoOpen) return refreshed = true located = found runWasFinished = finished - cursor = if (found == null) Cursor.getDefaultCursor() else Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + willAutoOpen = willOpen toolTipText = when { - found != null -> TestoBundle.message("testo.report.action.description", found.toString()) + found != null -> TestoBundle.message("testo.report.action.description") + willOpen -> TestoBundle.message("testo.report.action.description.armed") finished -> TestoBundle.message("testo.report.action.description.pending") else -> TestoBundle.message("testo.report.action.description.running") } repaint() } + /** + * The deferred click: the first time this run's report is there, every standing choice opens it its own way. + * Marked per run whether a choice exists or not, so checking "always open" *after* the report arrived starts + * with the next run instead of popping this one open under the user. + */ + private fun maybeAutoOpen(found: Path?, finished: Boolean) { + if (found == null || !finished || autoOpenedRun == reports.runStartedAt) return + autoOpenedRun = reports.runStartedAt + val key = TestoReportAutoOpen.keyOf(ref) + TestoReportAutoOpen.decide(project, reports, ref).forEach { way -> + // The run's own arm is one-shot; a project- or application-wide choice stays for the next run. + reports.armAutoOpen(key, way, false) + open(way, found) + } + } + override fun getPreferredSize(): Dimension { val metrics = getFontMetrics(font) val width = PADDING + ICON.iconWidth + GAP + metrics.stringWidth(text()) + GAP + ARROW.iconWidth + PADDING @@ -210,43 +253,83 @@ class TestoReportsAction( val g2 = g.create() as Graphics2D try { GraphicsUtil.setupAAPainting(g2) - if (hovered && located != null) { + if (hovered) { g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() val arc = JBUI.scale(6) g2.fillRoundRect(0, 0, width, height, arc, arc) } - // Dimmed as a whole while the file is missing, so "announced" reads differently from "ready". - val enabled = located != null - val icon = if (enabled) ICON else disabledIcon(ICON) + // Grey while there is nothing to open, blue once this run's report is on disk, green while the run + // still goes but an auto-open stands scheduled. + val icon = when { + located != null -> READY_ICON + willAutoOpen -> SCHEDULED_ICON + else -> ICON + } icon.paintIcon(this, g2, PADDING, (height - icon.iconHeight) / 2) g2.font = font - g2.color = if (enabled) UIUtil.getLabelForeground() else DISABLED_TEXT + g2.color = UIUtil.getLabelForeground() val metrics = g2.fontMetrics val textX = PADDING + ICON.iconWidth + GAP g2.drawString(text(), textX, (height - metrics.height) / 2 + metrics.ascent) - val arrow = if (enabled) ARROW else disabledIcon(ARROW) - arrow.paintIcon(this, g2, width - PADDING - arrow.iconWidth, (height - arrow.iconHeight) / 2) + ARROW.paintIcon(this, g2, width - PADDING - ARROW.iconWidth, (height - ARROW.iconHeight) / 2) } finally { g2.dispose() } } - private fun openDefault() { - val path = located ?: return - val label = ref.name ?: TestoBundle.message("testo.report.editor.name") - if (!TestoReportViewer.open(project, path, label)) browseReport(path) + private fun defaultWay(): ReportOpenWay = + if (TestoReportViewer.isAvailable) ReportOpenWay.WEB_VIEW else ReportOpenWay.BROWSER + + /** Opens the report when it is there; otherwise keeps the click, to be replayed once the run delivers it. */ + private fun openOrArm(way: ReportOpenWay) { + val path = located + if (path != null) { + open(way, path) + } else { + reports.armAutoOpen(TestoReportAutoOpen.keyOf(ref), way, true) + refresh() + } + } + + /** + * The button un-pressed and pressed again. Un-pressing silences every way of opening for this run behind one + * flag — the standing checkmarks stay put; pressing back lifts the flag so they resume, and with none of them + * checked it schedules the one thing a bare press can mean: the WebView, for this run. + */ + private fun toggleScheduled() { + val key = TestoReportAutoOpen.keyOf(ref) + if (TestoReportAutoOpen.decide(project, reports, ref).isNotEmpty()) { + reports.muteAutoOpen(key, true) + } else { + reports.muteAutoOpen(key, false) + if (TestoReportAutoOpen.decide(project, reports, ref).isEmpty()) { + reports.armAutoOpen(key, defaultWay(), true) + } + } + refresh() + } + + private fun open(way: ReportOpenWay, path: Path) { + when (way) { + ReportOpenWay.BROWSER -> browseReport(path) + ReportOpenWay.WEB_VIEW -> { + val label = ref.name ?: TestoBundle.message("testo.report.editor.name") + if (!TestoReportViewer.open(project, path, label)) browseReport(path) + } + } } private fun showMenu() { val group = DefaultActionGroup( buildList { if (TestoReportViewer.isAvailable) { - add(item("testo.report.open.webview", AllIcons.Actions.Preview, Mode.WEB_VIEW)) + add(openGroup("testo.report.open.webview", AllIcons.Actions.Preview, ReportOpenWay.WEB_VIEW)) } - add(item("testo.report.open.browser", AllIcons.Nodes.PpWeb, Mode.BROWSER)) - add(item("testo.report.copy.path", AllIcons.Actions.Copy, Mode.COPY_PATH)) + add(openGroup("testo.report.open.browser", AllIcons.Nodes.PpWeb, ReportOpenWay.BROWSER)) + add(RevealReportAction({ ref }, project, mapToLocal, reports)) + add(CopyReportPathAction({ ref }, project, mapToLocal, reports)) } ) JBPopupFactory.getInstance() @@ -261,8 +344,8 @@ class TestoReportsAction( .showUnderneathOf(this) } - private fun item(key: String, icon: Icon, mode: Mode) = - ReportTargetAction(TestoBundle.message(key), icon, mode, { ref }, project, mapToLocal, reports) + private fun openGroup(key: String, icon: Icon, way: ReportOpenWay) = + OpenReportGroup(TestoBundle.message(key), icon, way, { ref }, project, reports, ::openOrArm) } private companion object { @@ -271,46 +354,121 @@ class TestoReportsAction( private val ICON: Icon = AllIcons.General.IndentDetected private val ARROW: Icon = AllIcons.General.LinkDropTriangle + /** The icon's three colours: grey (nothing to open), blue (this run's report is on disk), green (scheduled). */ + private val READY_ICON: Icon = IconUtil.colorize(ICON, JBColor(0x3574F0, 0x548AF7)) + private val SCHEDULED_ICON: Icon = IconUtil.colorize(ICON, JBColor(0x59A869, 0x499C54)) + // 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) - - private val DISABLED_TEXT = JBColor.namedColor("Label.disabledForeground", JBColor(0x8C8C8C, 0x6F737A)) } } -/** The platform's own greying, so a disabled cell matches every other disabled control in the row. */ -private fun disabledIcon(icon: Icon): Icon = IconLoader.getDisabledIcon(icon) +/** + * "Open in …" as a perform group: clicking the entry opens the report — or arms its way's deferred open while there + * is nothing to open yet — and its submenu chooses when the report opens on its own. + */ +private class OpenReportGroup( + text: String, + icon: Icon, + private val way: ReportOpenWay, + target: () -> TestoReportRef, + project: Project, + reports: TestoReportStore, + private val openOrArm: (ReportOpenWay) -> Unit, +) : DefaultActionGroup(text, null, icon), DumbAware { + + init { + templatePresentation.isPopupGroup = true + templatePresentation.isPerformGroup = true + add(toggle("testo.report.autoopen.run", AutoOpenScope.THIS_RUN, target, project, reports)) + add(toggle("testo.report.autoopen.project", AutoOpenScope.PROJECT, target, project, reports)) + add(toggle("testo.report.autoopen.application", AutoOpenScope.APPLICATION, target, project, reports)) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT -/** What a menu entry does; [WEB_VIEW] falls back to the browser where JCEF is unavailable. */ -private enum class Mode { WEB_VIEW, BROWSER, COPY_PATH } + override fun actionPerformed(e: AnActionEvent) = openOrArm(way) -private class ReportTargetAction( + private fun toggle( + key: String, + scope: AutoOpenScope, + target: () -> TestoReportRef, + project: Project, + reports: TestoReportStore, + ) = AutoOpenToggle(TestoBundle.message(key), scope, way, target, project, reports, openOrArm) +} + +/** One scope of [TestoReportAutoOpen] under one way of opening — every (way, scope) checkmark stands on its own. */ +private class AutoOpenToggle( text: String, - icon: Icon?, - private val mode: Mode, + private val scope: AutoOpenScope, + private val way: ReportOpenWay, + private val target: () -> TestoReportRef, + private val project: Project, + private val reports: TestoReportStore, + private val openOrArm: (ReportOpenWay) -> Unit, +) : ToggleAction(text), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun isSelected(e: AnActionEvent): Boolean = + TestoReportAutoOpen.isSet(scope, project, reports, key(), way) + + override fun setSelected(e: AnActionEvent, state: Boolean) { + // Checking this-run goes through openOrArm, so a choice over a report already delivered opens it right away + // instead of arming a click nothing is left to replay. + if (state && scope == AutoOpenScope.THIS_RUN) { + openOrArm(way) + } else { + TestoReportAutoOpen.set(scope, project, reports, key(), way, state) + } + } + + private fun key(): String = TestoReportAutoOpen.keyOf(target()) +} + +/** Shows the report in the file manager, named whatever this OS calls it — "Show in Explorer", "Reveal in Finder". */ +private class RevealReportAction( private val target: () -> TestoReportRef, private val project: Project, private val mapToLocal: (String) -> String?, private val reports: TestoReportStore, -) : AnAction(text, null, icon), DumbAware { +) : AnAction(RevealFileAction.getActionName(), null, AllIcons.Nodes.Folder), DumbAware { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = resolve() != null + e.presentation.isVisible = RevealFileAction.isSupported() + e.presentation.isEnabled = e.presentation.isVisible && resolve() != null } override fun actionPerformed(e: AnActionEvent) { - val ref = target() // Re-resolved rather than remembered: the report may have been deleted since the menu was drawn. val path = resolve() ?: return - val label = ref.name ?: TestoBundle.message("testo.report.editor.name") - when (mode) { - Mode.BROWSER -> browseReport(path) - Mode.COPY_PATH -> CopyPasteManager.getInstance().setContents(StringSelection(path.toString())) - Mode.WEB_VIEW -> if (!TestoReportViewer.open(project, path, label)) browseReport(path) - } + RevealFileAction.openFile(path) + } + + private fun resolve(): Path? = resolveReport(target(), project, mapToLocal, reports.runStartedAt) +} + +private class CopyReportPathAction( + private val target: () -> TestoReportRef, + private val project: Project, + private val mapToLocal: (String) -> String?, + private val reports: TestoReportStore, +) : AnAction(TestoBundle.message("testo.report.copy.path"), null, AllIcons.Actions.Copy), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = resolve() != null + } + + override fun actionPerformed(e: AnActionEvent) { + // Re-resolved rather than remembered: the report may have been deleted since the menu was drawn. + val path = resolve() ?: return + CopyPasteManager.getInstance().setContents(StringSelection(path.toString())) } private fun resolve(): Path? = resolveReport(target(), project, mapToLocal, reports.runStartedAt) diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt new file mode 100644 index 0000000..6aabf87 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt @@ -0,0 +1,59 @@ +package com.github.xepozz.testo.tests.console + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.project.Project + +/** How a report is opened: the JCEF editor tab, or handed to the external browser. */ +enum class ReportOpenWay { WEB_VIEW, BROWSER } + +/** How long an auto-open choice lives. */ +enum class AutoOpenScope { THIS_RUN, PROJECT, APPLICATION } + +/** + * When a report opens without being clicked. + * + * Every (way, scope) pair is its own independent flag — checking the browser does not uncheck the WebView, and both + * checked opens the report both ways. The flags are keyed by the report's format and name — its identity across runs, + * since the path changes with the execution environment. THIS_RUN lives in the run's own [TestoReportStore] and is + * what a click on a button whose report is still being written arms; the other two persist through + * [PropertiesComponent]. + */ +object TestoReportAutoOpen { + fun keyOf(ref: TestoReportRef): String = "${ref.format}/${ref.name.orEmpty()}" + + fun isSet(scope: AutoOpenScope, project: Project, store: TestoReportStore, key: String, way: ReportOpenWay): Boolean = + when (scope) { + AutoOpenScope.THIS_RUN -> store.isAutoOpenArmed(key, way) + AutoOpenScope.PROJECT -> PropertiesComponent.getInstance(project).getBoolean(propertyName(key, way)) + AutoOpenScope.APPLICATION -> PropertiesComponent.getInstance().getBoolean(propertyName(key, way)) + } + + fun set( + scope: AutoOpenScope, + project: Project, + store: TestoReportStore, + key: String, + way: ReportOpenWay, + enabled: Boolean, + ) { + when (scope) { + AutoOpenScope.THIS_RUN -> store.armAutoOpen(key, way, enabled) + AutoOpenScope.PROJECT -> PropertiesComponent.getInstance(project).setValue(propertyName(key, way), enabled) + AutoOpenScope.APPLICATION -> PropertiesComponent.getInstance().setValue(propertyName(key, way), enabled) + } + } + + /** + * The ways this report should open on its own, each granted by any scope — unless the report is muted, the + * button un-pressed: one flag silences every way for this run without unchecking any standing choice. + */ + fun decide(project: Project, store: TestoReportStore, ref: TestoReportRef): Set { + val key = keyOf(ref) + if (store.isAutoOpenMuted(key)) return emptySet() + return ReportOpenWay.entries.filterTo(LinkedHashSet()) { way -> + AutoOpenScope.entries.any { isSet(it, project, store, key, way) } + } + } + + private fun propertyName(key: String, way: ReportOpenWay) = "testo.report.autoOpen.${way.name}.$key" +} 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 2971626..0cffd88 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 @@ -72,6 +72,14 @@ data class TestoReportRef( class TestoReportStore { private val reports = LinkedHashMap() + // The deferred opens of the current run (see AutoOpenScope.THIS_RUN): (TestoReportAutoOpen.keyOf, way) pairs, + // each its own flag — arming one way must not take another way's back. + private val autoOpenThisRun = HashSet>() + + // Reports the user clicked back off for this run alone — one flag over every way of opening: a standing + // project- or application-wide choice must neither fire nor be unchecked. Guarded by autoOpenThisRun's lock. + private val autoOpenMuted = HashSet() + /** * Whether the process that writes these reports has exited. * @@ -97,6 +105,12 @@ class TestoReportStore { fun noteRunStarted(now: Long = System.currentTimeMillis()) { runFinished = false runStartedAt = now - now % 1000 + // A deferred open — and a mute over the standing choices — belongs to the run it was clicked in; a run + // stopped before its report fired must not open the next run's, and a muted run must not mute the next. + synchronized(autoOpenThisRun) { + autoOpenThisRun.clear() + autoOpenMuted.clear() + } } fun noteRunFinished() { @@ -107,10 +121,43 @@ class TestoReportStore { synchronized(reports) { reports[ref.path] = ref } } + /** The deferred open the user asked for during this run. Arming lifts the report's mute — it is the newer word. */ + fun armAutoOpen(key: String, way: ReportOpenWay, armed: Boolean) { + synchronized(autoOpenThisRun) { + if (armed) { + autoOpenThisRun.add(key to way) + autoOpenMuted.remove(key) + } else { + autoOpenThisRun.remove(key to way) + } + } + } + + fun isAutoOpenArmed(key: String, way: ReportOpenWay): Boolean = + synchronized(autoOpenThisRun) { key to way in autoOpenThisRun } + + /** Muting also takes this run's own clicks back: they are what is being un-pressed, when no standing choice is. */ + fun muteAutoOpen(key: String, muted: Boolean) { + synchronized(autoOpenThisRun) { + if (muted) { + autoOpenMuted.add(key) + autoOpenThisRun.removeAll { it.first == key } + } else { + autoOpenMuted.remove(key) + } + } + } + + fun isAutoOpenMuted(key: String): Boolean = synchronized(autoOpenThisRun) { key in autoOpenMuted } + fun clear() { runFinished = false runStartedAt = 0 synchronized(reports) { reports.clear() } + synchronized(autoOpenThisRun) { + autoOpenThisRun.clear() + autoOpenMuted.clear() + } } fun all(): List = synchronized(reports) { reports.values.toList() } diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index 76dbeff..8d433ca 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -45,11 +45,15 @@ testo.progress.elapsed.boost=Concurrency boost testo.progress.elapsed.boost.value=≥{0}x testo.report.action.text=Report -testo.report.action.description=Open the report Testo generated for this run: {0} +testo.report.action.description=Open the report testo.report.action.description.pending=Testo announced this report but did not write it in this run -testo.report.action.description.running=Testo is writing this report; it opens once the run finishes +testo.report.action.description.running=Waiting for the test run to finish +testo.report.action.description.armed=The report will open when the run finishes — click to cancel testo.report.open.webview=Open in WebView testo.report.open.browser=Open in Browser +testo.report.autoopen.run=Open When This Run Finishes +testo.report.autoopen.project=Always Open in This Project +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. diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt index d737fd5..58a7342 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -1,5 +1,7 @@ package com.github.xepozz.testo +import com.github.xepozz.testo.tests.console.ReportOpenWay +import com.github.xepozz.testo.tests.console.TestoReportAutoOpen import com.github.xepozz.testo.tests.console.TestoReportRef import com.github.xepozz.testo.tests.console.TestoReportStore import com.github.xepozz.testo.tests.console.isReportOf @@ -172,6 +174,65 @@ class TestoReportStoreTest { assertFalse(isReportOf(Path.of("no", "such", "report.html"), writtenAfter = 0)) } + @Test + fun aDeferredOpenBelongsToTheRunItWasClickedIn() { + val store = TestoReportStore() + store.noteRunStarted(1_000) + store.armAutoOpen("html/Report", ReportOpenWay.BROWSER, true) + assertTrue(store.isAutoOpenArmed("html/Report", ReportOpenWay.BROWSER)) + assertFalse(store.isAutoOpenArmed("html/Other", ReportOpenWay.BROWSER)) + + // The next run must not inherit a click nothing replayed — a stopped run leaves its arm behind. + store.noteRunStarted(2_000) + assertFalse(store.isAutoOpenArmed("html/Report", ReportOpenWay.BROWSER)) + } + + @Test + fun theWaysOfOpeningAreIndependentFlags() { + // Disarming the WebView must leave the browser's checkmark exactly where it was, and vice versa. + val store = TestoReportStore() + store.armAutoOpen("html/Report", ReportOpenWay.WEB_VIEW, true) + store.armAutoOpen("html/Report", ReportOpenWay.BROWSER, true) + + store.armAutoOpen("html/Report", ReportOpenWay.WEB_VIEW, false) + assertFalse(store.isAutoOpenArmed("html/Report", ReportOpenWay.WEB_VIEW)) + assertTrue(store.isAutoOpenArmed("html/Report", ReportOpenWay.BROWSER)) + } + + @Test + fun theMuteIsOneFlagOverEveryWayAndTakesThisRunsClicksBack() { + val store = TestoReportStore() + store.armAutoOpen("html/Report", ReportOpenWay.WEB_VIEW, true) + store.armAutoOpen("html/Report", ReportOpenWay.BROWSER, true) + + store.muteAutoOpen("html/Report", true) + assertTrue(store.isAutoOpenMuted("html/Report")) + assertFalse(store.isAutoOpenArmed("html/Report", ReportOpenWay.WEB_VIEW)) + assertFalse(store.isAutoOpenArmed("html/Report", ReportOpenWay.BROWSER)) + + // Arming again is the newer word — the mute must not survive it and silently swallow the open. + store.armAutoOpen("html/Report", ReportOpenWay.WEB_VIEW, true) + assertFalse(store.isAutoOpenMuted("html/Report")) + } + + @Test + fun aMuteBelongsToTheRunItWasClickedIn() { + // Muting silences a standing project- or application-wide choice for this run alone: the next run must + // auto-open again without the checkmark ever having moved. + val store = TestoReportStore() + store.muteAutoOpen("html/Report", true) + + store.noteRunStarted(2_000) + assertFalse(store.isAutoOpenMuted("html/Report")) + } + + @Test + fun theAutoOpenKeyIsTheFormatAndTheName() { + // The report's identity across runs: the path changes with the execution environment, these do not. + assertEquals("html/Testo HTML report", TestoReportAutoOpen.keyOf(ref("/tmp/x.html", name = "Testo HTML report"))) + assertEquals("html/", TestoReportAutoOpen.keyOf(ref("/tmp/x.html"))) + } + @Test fun clearForgetsThePreviousRun() { val store = TestoReportStore() From 0c0917bd3f65b1c15e97bd6808021a9d010b685d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 18:19:24 +0400 Subject: [PATCH 08/10] fix: the run summary no longer jitters in width as its counters tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Digits are measured and drawn in tabular slots — every digit takes the widest digit's width — so the row moves only when a count gains a digit. Java2D cannot ask an arbitrary font for tabular figures itself. Assisted-By: Claude Fable 5 --- CHANGELOG.md | 5 +++ .../tests/console/TestoProgressAction.kt | 26 +++++++++++-- .../xepozz/testo/TabularAdvancesTest.kt | 39 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/com/github/xepozz/testo/TabularAdvancesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a3358..83d4ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ first, then as-is, then as `relativePath` under the project root — which is what makes a report written inside a container or behind a remote interpreter reachable. +### Fixed + +- The toolbar run summary no longer jitters in width as its counters tick: digits are set in tabular slots, so the + row grows only when a count gains a digit. + ## [2026.4.262] - 2026-08-10 ### Added 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 4a41cdc..8061435 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 @@ -428,7 +428,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb override fun getPreferredSize(): Dimension { if (!isVisible) return Dimension(0, 0) val metrics = getFontMetrics(font) - val textWidth = if (text.isEmpty()) 0 else metrics.stringWidth(text) + val textWidth = if (text.isEmpty()) 0 else tabularAdvances(text) { metrics.charWidth(it) }.sum() val gap = if (leadingWidth > 0 && textWidth > 0) GAP else 0 val height = maxOf(icon?.iconHeight ?: 0, metrics.height, JBUI.scale(16)) + JBUI.scale(4) return Dimension(PADDING * 2 + leadingWidth + gap + textWidth, height) @@ -453,8 +453,15 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb g2.color = UIUtil.getLabelForeground() g2.font = font val metrics = g2.fontMetrics - val x = PADDING + leadingWidth + (if (leadingWidth > 0) GAP else 0) - g2.drawString(text, x, (height - metrics.height) / 2 + metrics.ascent) + var x = PADDING + leadingWidth + (if (leadingWidth > 0) GAP else 0) + val y = (height - metrics.height) / 2 + metrics.ascent + // Char by char, each digit centered in its tabular slot (see tabularAdvances) — so the label + // after the digits sits still while they tick. Kerning is lost, which digits never had. + val advances = tabularAdvances(text) { metrics.charWidth(it) } + text.forEachIndexed { i, ch -> + g2.drawString(ch.toString(), x + (advances[i] - metrics.charWidth(ch)) / 2, y) + x += advances[i] + } } } finally { g2.dispose() @@ -631,6 +638,19 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb } } +/** + * The x-advance of each character with digits set tabularly: every digit takes the widest digit's slot, everything + * else its own width. + * + * The counters tick several times a second, and in a proportional font every digit runs at its own width — measured + * as-is, the whole row jitters. Over these slots the width moves only when a digit is added (9 → 10), a jump that + * reads as growth rather than noise. + */ +internal fun tabularAdvances(text: String, widthOf: (Char) -> Int): IntArray { + val slot = ('0'..'9').maxOf(widthOf) + return IntArray(text.length) { i -> if (text[i].isDigit()) slot else widthOf(text[i]) } +} + /** * Which Testo statuses the toolbar's two standing toggles take out of the tree. * diff --git a/src/test/kotlin/com/github/xepozz/testo/TabularAdvancesTest.kt b/src/test/kotlin/com/github/xepozz/testo/TabularAdvancesTest.kt new file mode 100644 index 0000000..1617242 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TabularAdvancesTest.kt @@ -0,0 +1,39 @@ +package com.github.xepozz.testo + +import com.github.xepozz.testo.tests.console.tabularAdvances +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Plain JUnit4 test for the digit slots that keep the toolbar row from jittering as its counters tick. */ +class TabularAdvancesTest { + + // A caricature of a proportional font: the narrow one, every other digit wide, letters in between. + private val widthOf = { ch: Char -> + when (ch) { + '1' -> 4 + in '0'..'9' -> 8 + ' ' -> 3 + else -> 6 + } + } + + @Test + fun everyDigitTakesTheWidestDigitsSlot() { + assertArrayEquals(intArrayOf(8, 8), tabularAdvances("11", widthOf)) + } + + @Test + fun otherCharactersKeepTheirOwnWidth() { + assertArrayEquals(intArrayOf(8, 6, 8, 3, 6), tabularAdvances("1/7 s", widthOf)) + } + + @Test + fun theWidthMovesOnlyWithTheDigitCount() { + val widthOfText = { text: String -> tabularAdvances(text, widthOf).sum() } + + assertEquals(widthOfText("19 passed"), widthOfText("87 passed")) + assertTrue(widthOfText("100") > widthOfText("99")) + } +} From e7fe95db78fe333b7ee264062b91c37f25067b8d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 18:40:02 +0400 Subject: [PATCH 09/10] test: keep the path candidates distinct on every OS The mapper's answer spelled the project-relative form, which on Linux is the same string, so the dedup folded the two and the list came up short. Assisted-By: Claude Fable 5 --- .../kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt index 58a7342..95a588c 100644 --- a/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -245,14 +245,16 @@ class TestoReportStoreTest { @Test fun mappedPathIsTriedFirstThenTheRawOneThenTheProjectRelativeForm() { + // The mapper's answer must not spell the project-relative form: on an OS whose separator matches the + // announcement's, the two candidates would be one string and the dedup would fold them. val candidates = reportPathCandidates( ref("/app/runtime/report/index.html", relativePath = "runtime/report/index.html"), projectBasePath = "/home/me/project", - ) { "/home/me/project/runtime/report/index.html" } + ) { "/home/me/mapped/runtime/report/index.html" } assertEquals( listOf( - "/home/me/project/runtime/report/index.html", + "/home/me/mapped/runtime/report/index.html", "/app/runtime/report/index.html", Path.of("/home/me/project", "runtime/report/index.html").toString(), ), From 4ee9cc12be5eb9891761a4b9a058914a1dc602dd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 19:37:22 +0400 Subject: [PATCH 10/10] docs: cut the comments, the changelog and the CLAUDE.md notes down to the constraints Assisted-By: Claude Fable 5 --- CHANGELOG.md | 33 ++------ CLAUDE.md | 57 +++---------- .../testo/tests/TestoConsoleProperties.kt | 5 +- .../TestoOutputToGeneralEventsConverter.kt | 3 +- .../tests/console/TestoProgressAction.kt | 28 ++----- .../testo/tests/console/TestoReportAction.kt | 84 +++++-------------- .../tests/console/TestoReportAutoOpen.kt | 16 +--- .../testo/tests/console/TestoReportStore.kt | 68 +++++---------- .../xepozz/testo/ui/TestoReportEditor.kt | 32 ++----- src/main/resources/META-INF/jcef.xml | 6 +- src/main/resources/META-INF/plugin.xml | 8 +- 11 files changed, 90 insertions(+), 250 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d4ff6..3551fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,36 +6,15 @@ ### Added -- Report buttons at the right end of the test toolbar, past the run summary — one per report Testo announces with - `##teamcity[testoReport format='html' path='…' relativePath='…' name='…' schemaVersion='…']`, labelled with the name - the announcement carries. A run that writes no report shows no button. The report counts as delivered once the - process has exited with the file in place and no older than the run — deliberately not any earlier, since a report is - announced as Testo starts writing it, over the path the previous run wrote to; so stopping a run never opens the - previous run's report. -- Clicking a button opens the report in an editor tab rendered by JCEF; the arrow on it offers the external browser, - showing the file in the file manager, and copying the report's path. Reopening an already-open report reloads it, so - the tab shows the run that just finished rather than the one it was opened for. -- The button is live the whole time: a click before the report is delivered is kept and replayed once the run delivers - the file, marked by the button's icon turning green and by the *Open When This Run Finishes* checkmark - under the arrow's *Open in WebView* / *Open in Browser* entries. A second click un-presses it — every way of opening - is silenced for this run alone, while the standing project- and application-wide checkmarks stay put; pressed again, - they resume, and with none of them checked the press schedules the WebView for this run. The icon's colour tells - the report's state — grey with nothing to open, blue with the run's report on disk, green with an open scheduled — - and the tooltip spells it out: still being written, delivered, or never written in this run. -- Those entries also take a standing choice: *Always Open in This Project* or *Always Open in Every Project*, remembered - per report format and name, opening the report the chosen way as each run delivers it. Every checkmark stands on its - own — the WebView's and the browser's don't steal from each other, and both checked opens the report both ways. - Checked after the report already arrived, it starts with the next run rather than popping the current one open. -- Only reports the button can show as a page are offered. Everything else Testo announces is kept, ready for formats the - plugin will handle differently. -- The announced path is absolute inside the *execution* environment, so it is looked for through the PHP path mapper - first, then as-is, then as `relativePath` under the project root — which is what makes a report written inside a - container or behind a remote interpreter reachable. +- Report buttons on the test toolbar — one per report Testo announces, opening it in a JCEF tab or the browser. +- A report can open on its own once the run delivers it: armed by a click during the run, or standing per project / + every project, independently per way of opening. +- The report menu also shows the file in the file manager and copies its path. +- Reports written behind a remote interpreter or in a container are reached through the PHP path mapper. ### Fixed -- The toolbar run summary no longer jitters in width as its counters tick: digits are set in tabular slots, so the - row grows only when a count gains a digit. +- The toolbar run summary no longer jitters in width as its counters tick. ## [2026.4.262] - 2026-08-10 diff --git a/CLAUDE.md b/CLAUDE.md index 9a48842..7f6e7de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +36,7 @@ Dependabot bumps these regularly — read the files rather than trusting this ta `phpstorm-remote-interpreter`, `php.codeception`, `php.behat`, `gherkin`, `xepozz.ide.introspector` (+ `hackathon.indices.viewer` on 252 only — it has no 262 build). `platformBundledModules`: `intellij.platform.coverage`, `intellij.spellchecker` (+ `intellij.platform.smRunner`, -`intellij.platform.testRunner`, `intellij.platform.ui.jcef` on 262, which split them out of the monolith — asking for -the JCEF module by name on 252 fails to resolve). +`intellij.platform.testRunner`, `intellij.platform.ui.jcef` on 262, which split them out of the monolith). ### Two build variants (`phpApi`) @@ -392,12 +391,10 @@ it keeps everything the class holds (a `#[Test]` class typed as `test` would dro (failed line + text), two console foldings, and `PhpBacktraceFileFilter` for hyperlinks in raw output. 10. **Generated reports** — Testo announces each report with the non-standard `##teamcity[testoReport …]`; - `TestoReportStore` keeps them, and `TestoReportsAction` draws one button per viewable report past the run summary, - labelled with the announced name, opening it in a JCEF tab (`ui/TestoReportEditor.kt`) or the external browser. The - button is always enabled: with the file delivered a click opens it, before that it is kept as a deferred open and - replayed when the run delivers the file — marked by a clock badge on the icon, with the tooltip carrying the state. - `TestoReportAutoOpen` keeps that arm and the standing per-project/per-application auto-open choices, keyed by the - report's format + name. The spec for the report itself lives in the Testo repository (`docs/spec/html-report.md`). + `TestoReportStore` keeps them, `TestoReportsAction` draws one button per viewable report, opening it in a JCEF tab + (`ui/TestoReportEditor.kt`) or the browser. A click before the report is delivered defers the open; + `TestoReportAutoOpen` holds the auto-open choices (this run / project / application), keyed by format + name. + The report spec lives in the Testo repository (`docs/spec/html-report.md`). ## Implementation notes & gotchas @@ -474,42 +471,14 @@ Non-obvious constraints already paid for in blood — read before touching the r `ConfigurationFile`, no config file, non-empty `group`) trips the platform's "Configuration file is not specified" `RuntimeConfigurationError`, though Testo needs no config file. The error is matched by message text (`PhpBundle`), so a platform rewording fails closed — the validation error merely comes back. -- **The report buttons are drawn by hand, inside one right-aligned action.** Every platform widget failed a requirement: - a toolbar button shows the icon alone (text becomes a tooltip), `SplitButtonAction` paints its own component and drops - the text, `ComboBoxAction` turns the first click into a dropdown, and an expanded `ActionGroup` loses - `RightAlignedToolbarAction` — its children land among the buttons on the left. So `TestoReportsAction` owns a panel of - cells, the way `TestoProgressAction` does; RunTab snapshots the toolbar's actions, so the panel must exist from the - start and hide itself while it has no cells. -- **A report must be announced while the run is still going, not once it is written.** Everything after the root - `testSuiteFinished` is past the point where the platform still feeds the converter: such a line reaches neither our - branch nor the console, it simply vanishes. So Testo announces a report when it *starts* writing it, and each cell - polls for the file twice a second — which is also how a deleted report turns its button off again. -- **The announcement is read twice, as a service message and off the raw text.** The platform parses a line only when it - *starts* with `##teamcity[`, so anything in front of it (a colour escape) would lose the report; - `TestoReportRef.fromServiceMessageLine` scans for it anywhere and the store deduplicates by path. -- **No cell looks at the disk before the process exits** (`TestoReportStore.runFinished`, set from the process listener - in `TestoProgressAction.attachTo`). A report is announced at the start of the run over the path the *previous* run - wrote to, so an earlier check enabled the button on that run's report. `onTestingStarted` puts the flag back for a - second session in the same console, and a run already over by the time the listener lands is caught by - `isProcessTerminated`. -- **A report also has to be newer than the run** (`TestoReportStore.runStartedAt`, floored to a whole second for - filesystems that keep mtime by the second). The path is the same every run, so a run that was stopped — or that died - before its reporter ran — leaves the previous report in place, and the process having exited says nothing about who - wrote that file. -- **JCEF is declared for 262 only, and still never trusted.** There it is the bundled `com.intellij.modules.jcef` - plugin (`` + `jcef.xml`); on 252 it is part of the monolith and visible without a declaration. Its - 262 module `intellij.platform.ui.jcef` must never go in ``: that form is mandatory and the module does - not exist on 252, so the 252 build would not load at all. Compiling against JCEF proves nothing about runtime - visibility, so `TestoReportViewer.isAvailable` asks by **reflection** — a named reference to `JBCefApp` throws - `NoClassDefFoundError` when the enclosing method's class is verified, before any `try` can catch it. No JCEF type may - be mentioned outside a class that loads only after `isAvailable` answers true, and none outside `com.intellij.ui.jcef` - at all: `org.cef` is absent from the compile classpath. -- **The report tab is our own `FileEditorProvider`, not the platform's `HTMLEditorProvider`** — that one is - `@ApiStatus.Internal`. It accepts nothing but `TestoReportVirtualFile`, and `TestoReportViewer` keeps one such file - per path so re-opening a report returns to its tab (the platform keys tabs by identity, not equality) and reloads it. -- **A report path is resolved with `pathMapper.getLocalPath`, never `getLocalFile`** — the file was written moments - before the message arrived and the VFS need not know it yet. `relativePath` under the project root is the last - candidate, and the only one that works when the run's filesystem shares nothing with the host's. +- **A report is announced when Testo *starts* writing it** — output after the root `testSuiteFinished` never reaches + the converter — so the file is polled, no earlier than process exit and only accepting mtime no older than the run: + the path is the same every run, and a stopped run leaves the previous report in place. +- **JCEF: only `` on `com.intellij.modules.jcef`.** The module form (`intellij.platform.ui.jcef` in + ``) is mandatory and absent on 252 — that build would not load at all. `TestoReportViewer.isAvailable` + asks by reflection: a named `JBCefApp` reference throws `NoClassDefFoundError` at class verification, before any + `try`. No JCEF type outside classes that load after it answers true, and nothing from `org.cef` — it is not on the + compile classpath. ## Testing 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 0fb497d..7a734e6 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt @@ -48,7 +48,6 @@ class TestoConsoleProperties( val targetStore = TestoTargetStore(nodeIndex) - // Reports Testo announced this run; empty unless the run had a report reporter configured. val reportStore = TestoReportStore() val progressAction = TestoProgressAction() @@ -105,9 +104,7 @@ class TestoConsoleProperties( arrayOf( com.github.xepozz.testo.tests.console.TestoLogLevelFilterAction(levelFilter), *(super.createImportActions() ?: emptyArray()), - // Both are right-aligned, and the toolbar lays that group out from the right edge inwards — so the one - // listed first ends up furthest right. The report buttons therefore go before the run summary here to sit - // past it on screen. + // 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/console/TestoOutputToGeneralEventsConverter.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoOutputToGeneralEventsConverter.kt index 7ea2603..fcd8860 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 @@ -43,8 +43,7 @@ class TestoOutputToGeneralEventsConverter( override fun process(text: String, outputType: Key<*>) { if (runnerVersion == null) runnerVersion = TestoProtocolGate.parseVersion(text) - // The second route to an announcement: the platform parses a line only when it *starts* with the message, so - // one behind a colour escape or an unterminated line reaches it as plain text. The store dedups by path. + // Second route: a message behind a colour escape never reaches parseServiceMessage. The store dedups by path. TestoReportRef.fromServiceMessageLine(text)?.let { reportStore.note(it) } super.process(text, outputType) } 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 8061435..9cee92d 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 @@ -69,8 +69,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb /** What the tree is narrowed to right now; `null` means no filter of ours is applied. */ private var selected: TestoTestStatus? = null - /** Whether the results form has announced the end of a session — what makes the next start a new run, not a late - * event of this one. EDT-only, like the listener that maintains it. */ + /** Whether the results form has announced the end of a session; makes the next start a new run. EDT-only. */ private var formFinished = false override fun getActionUpdateThread() = ActionUpdateThread.EDT @@ -105,7 +104,6 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb console.properties.addListener(TestConsoleProperties.HIDE_PASSED_TESTS, onToggle) console.properties.addListener(TestConsoleProperties.HIDE_IGNORED_TEST, onToggle) // Called from the augmenter's processStarted, so this is as close to the real start as the plugin can get. - // The reports take the same mark: it is what tells this run's report from the one already at that path. clock.noteStart() reports.noteRunStarted() @@ -114,18 +112,15 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb // Only on a second session in the same console: wiping on every announcement would throw away what // the converter already reported for this run, since it reads the stream before the platform. // - // The gate is this form's own finish, not clock.isFinished(): a short run exits before the platform has - // worked through its output buffer, so processTerminated stops the clock and *then* this arrives — and - // restarting the clock there left it running forever, with no event left to stop it. The form's two - // events are strictly ordered per session, so they can tell a new run from a late announcement. + // Gated on the form's own finish, not clock.isFinished(): a short run exits before the platform has + // worked through its output buffer, and restarting the clock on that late event left it running + // forever. The form's two events are strictly ordered per session. if (formFinished) { formFinished = false store.clear() targets.clear() - // Reports are deliberately not cleared here: a report is announced before the first test, so this - // may well run after the announcement and would throw it away. A re-run writes the same path, and - // the store replaces by path, so nothing stale survives anyway. Their buttons do go back to - // disabled, though — the file on disk is the *previous* run's until this one ends. + // Not cleared: a report is announced before the first test, so this may run after the + // announcement. A re-run writes the same path and the store replaces by path anyway. reports.noteRunStarted() clock.clear() clock.noteStart() @@ -156,8 +151,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb }) // The verdict follows the process, not the tree: a run that dies before reporting anything is still red, and - // a run killed mid-flight stops the clock even though onTestingFinished never came. The report buttons wait for - // the same event — an announced report is only worth looking for once nothing is writing it any more. + // a run killed mid-flight stops the clock even though onTestingFinished never came. handler?.addProcessListener(object : ProcessListener { override fun processTerminated(event: ProcessEvent) { exitCode.set(event.exitCode) @@ -639,12 +633,8 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb } /** - * The x-advance of each character with digits set tabularly: every digit takes the widest digit's slot, everything - * else its own width. - * - * The counters tick several times a second, and in a proportional font every digit runs at its own width — measured - * as-is, the whole row jitters. Over these slots the width moves only when a digit is added (9 → 10), a jump that - * reads as growth rather than noise. + * The x-advance of each character with digits set tabularly: every digit takes the widest digit's slot. Keeps the + * row from jittering as counters tick in a proportional font — the width moves only when a digit is added (9 → 10). */ internal fun tabularAdvances(text: String, widthOf: (Char) -> Int): IntArray { val slot = ('0'..'9').maxOf(widthOf) 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 47d05fa..a41f4a2 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 @@ -40,15 +40,9 @@ import javax.swing.JPanel import javax.swing.Timer /** - * The report buttons at the far right of the test toolbar, past the run summary — one per report Testo announced. - * - * Hand-drawn, like the run summary beside it: no platform widget gives icon, name, a click that opens the report and an - * arrow for the other ways to open it, and an expanded `ActionGroup` loses [RightAlignedToolbarAction] on its children. - * - * A cell always looks and acts enabled; what the click does is decided by whether the file is there yet. Present, fresh - * and the process exited — it opens; otherwise the click is kept as a deferred open, replayed once the run delivers the - * file (see [TestoReportAutoOpen]). A scheduled open wears a clock badge on the cell's icon, and the tooltip tells the - * rest: writing, delivered, or never written in this run. + * The report buttons at the far right of the test toolbar — one per report Testo announced. Hand-drawn like the run + * summary beside it: an expanded `ActionGroup` loses [RightAlignedToolbarAction] on its children. A click before the + * report is delivered is kept as a deferred open and replayed once it is (see [TestoReportAutoOpen]). */ class TestoReportsAction( private val reports: TestoReportStore, @@ -66,25 +60,20 @@ class TestoReportsAction( override fun createCustomComponent(presentation: Presentation, place: String): JComponent = ReportsPanel() - /** - * One cell per announced report, polled rather than subscribed: the store is written by the converter off the - * process's thread, and whether the file exists yet changes without anything telling us. - */ + /** 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 timer = Timer(REFRESH_MS) { tick() } init { isOpaque = false - // Laid out by hand, like the run summary: a LayoutManager caches size requirements, and this row is - // re-measured whenever a report appears or its name changes. + // Laid out by hand, like the run summary: a LayoutManager caches size requirements. layout = null - // The left inset holds the separator that parts the reports from the run summary. border = JBUI.Borders.empty(0, 10, 0, 4) isVisible = false } - // A platform Separator can't sit here: it is not right-aligned, so it would land among the left buttons. + // The separator fencing the reports off; a platform Separator is not right-aligned and would land elsewhere. override fun paintComponent(g: Graphics) { super.paintComponent(g) g.color = JBColor.border() @@ -131,7 +120,6 @@ class TestoReportsAction( private fun tick() { val announced = reports.viewable() - // Cells follow the announcements: added when a report shows up, dropped if the store is ever cleared. announced.forEach { ref -> cells.getOrPut(ref.path) { ReportCell(ref).also { add(it) } }.ref = ref } @@ -141,8 +129,7 @@ class TestoReportsAction( cells.values.forEach { it.refresh() } isVisible = cells.isNotEmpty() - // Only when the row itself changed shape — a cell that merely lit up repaints itself, and this runs - // twice a second for as long as the tab is open. + // Re-laid out only when the row changed shape — this runs twice a second. val width = preferredSize.width if (width != laidOutWidth) { laidOutWidth = width @@ -158,20 +145,17 @@ class TestoReportsAction( private var located: Path? = null private var runWasFinished = false private var willAutoOpen = false - // The run whose report this cell has already handed to maybeAutoOpen, so one run opens it at most once — - // a report deleted and rewritten within the run must not pop the viewer open again. + // The run this cell has already auto-opened for, so one run opens the report at most once. private var autoOpenedRun = -1L // 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 - // Asked for per paint: a font set once on a raw JComponent outlives a zoom, since there is no UI delegate to - // reinstall it, and the cell would keep the size it was built at. + // 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() init { isOpaque = false - // Always the hand: a cell whose file is not there yet still takes the click, as a deferred open. cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) addMouseListener(object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { @@ -198,17 +182,14 @@ class TestoReportsAction( private fun arrowZone(): Int = ARROW.iconWidth + GAP + PADDING - /** Re-resolves the file, which is what moves the cell between lit and dimmed. */ fun refresh() { - // Not while the run is going: the report is announced as Testo starts writing it, over the path the - // previous run wrote to — so a check now would light the button up on a report that belongs to that run. + // 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 maybeAutoOpen(found, finished) - // Scheduled only while the run still goes: what remains scheduled after it (a stopped run's arm) has - // nothing left to fire it, and must not keep wearing the colour of a promise. val willOpen = !finished && TestoReportAutoOpen.decide(project, reports, ref).isNotEmpty() - // Tooltip and repaint only on a real change: this runs twice a second for as long as the tab is open. + // Tooltip and repaint only on a real change: this runs twice a second. if (refreshed && found == located && finished == runWasFinished && willOpen == willAutoOpen) return refreshed = true located = found @@ -224,7 +205,6 @@ class TestoReportsAction( } /** - * The deferred click: the first time this run's report is there, every standing choice opens it its own way. * Marked per run whether a choice exists or not, so checking "always open" *after* the report arrived starts * with the next run instead of popping this one open under the user. */ @@ -233,7 +213,6 @@ class TestoReportsAction( autoOpenedRun = reports.runStartedAt val key = TestoReportAutoOpen.keyOf(ref) TestoReportAutoOpen.decide(project, reports, ref).forEach { way -> - // The run's own arm is one-shot; a project- or application-wide choice stays for the next run. reports.armAutoOpen(key, way, false) open(way, found) } @@ -258,8 +237,6 @@ class TestoReportsAction( val arc = JBUI.scale(6) g2.fillRoundRect(0, 0, width, height, arc, arc) } - // Grey while there is nothing to open, blue once this run's report is on disk, green while the run - // still goes but an auto-open stands scheduled. val icon = when { located != null -> READY_ICON willAutoOpen -> SCHEDULED_ICON @@ -294,9 +271,8 @@ class TestoReportsAction( } /** - * The button un-pressed and pressed again. Un-pressing silences every way of opening for this run behind one - * flag — the standing checkmarks stay put; pressing back lifts the flag so they resume, and with none of them - * checked it schedules the one thing a bare press can mean: the WebView, for this run. + * Un-pressing mutes every way of opening for this run without unchecking the standing choices; pressing back + * lifts the mute, and with nothing standing it arms the default way for this run. */ private fun toggleScheduled() { val key = TestoReportAutoOpen.keyOf(ref) @@ -364,10 +340,7 @@ class TestoReportsAction( } } -/** - * "Open in …" as a perform group: clicking the entry opens the report — or arms its way's deferred open while there - * is nothing to open yet — and its submenu chooses when the report opens on its own. - */ +/** "Open in …" as a perform group: the click opens (or arms), the submenu chooses when to open unasked. */ private class OpenReportGroup( text: String, icon: Icon, @@ -416,8 +389,7 @@ private class AutoOpenToggle( TestoReportAutoOpen.isSet(scope, project, reports, key(), way) override fun setSelected(e: AnActionEvent, state: Boolean) { - // Checking this-run goes through openOrArm, so a choice over a report already delivered opens it right away - // instead of arming a click nothing is left to replay. + // Via openOrArm, so a this-run choice over a report already delivered opens it right away. if (state && scope == AutoOpenScope.THIS_RUN) { openOrArm(way) } else { @@ -428,7 +400,6 @@ private class AutoOpenToggle( private fun key(): String = TestoReportAutoOpen.keyOf(target()) } -/** Shows the report in the file manager, named whatever this OS calls it — "Show in Explorer", "Reveal in Finder". */ private class RevealReportAction( private val target: () -> TestoReportRef, private val project: Project, @@ -444,9 +415,7 @@ private class RevealReportAction( } override fun actionPerformed(e: AnActionEvent) { - // Re-resolved rather than remembered: the report may have been deleted since the menu was drawn. - val path = resolve() ?: return - RevealFileAction.openFile(path) + RevealFileAction.openFile(resolve() ?: return) } private fun resolve(): Path? = resolveReport(target(), project, mapToLocal, reports.runStartedAt) @@ -466,7 +435,7 @@ private class CopyReportPathAction( } override fun actionPerformed(e: AnActionEvent) { - // Re-resolved rather than remembered: the report may have been deleted since the menu was drawn. + // Re-resolved: the report may have been deleted since the menu was drawn. val path = resolve() ?: return CopyPasteManager.getInstance().setContents(StringSelection(path.toString())) } @@ -474,28 +443,17 @@ private class CopyReportPathAction( private fun resolve(): Path? = resolveReport(target(), project, mapToLocal, reports.runStartedAt) } -/** - * Hands the report to the external browser. - * - * Through `Path.toUri()`, not `browse(File)`: the latter goes by way of the Windows path, whose separators come out - * percent-encoded — `file:///D:/%5Cgit%5C…`, which no browser resolves. `toUri()` yields `file:///D:/git/…`. - */ +// 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 announced report as a local file this run wrote, or `null` while there is none. - * - * Touches the filesystem. Called from the cell's timer on the EDT — three `stat`s twice a second, which is the price of - * noticing that the file has appeared without anything announcing it. - */ +/** The announced report as a local file this run wrote, or `null` while there is none. Touches the filesystem. */ internal fun resolveReport( ref: TestoReportRef, project: Project, mapToLocal: (String) -> String?, writtenAfter: Long, ): Path? = - // The mapper is the PHP plugin's, over a path it may know nothing about: whatever it throws must not take the - // toolbar's update with it. + // The PHP plugin's mapper may throw over a path it does not know; that must not take the toolbar with it. reportPathCandidates(ref, project.basePath) { runCatching { mapToLocal(it) }.getOrNull() } .asSequence() .mapNotNull { runCatching { Path.of(it) }.getOrNull() } diff --git a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt index 6aabf87..62c42a7 100644 --- a/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt @@ -3,20 +3,15 @@ package com.github.xepozz.testo.tests.console import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project -/** How a report is opened: the JCEF editor tab, or handed to the external browser. */ enum class ReportOpenWay { WEB_VIEW, BROWSER } /** How long an auto-open choice lives. */ enum class AutoOpenScope { THIS_RUN, PROJECT, APPLICATION } /** - * When a report opens without being clicked. - * - * Every (way, scope) pair is its own independent flag — checking the browser does not uncheck the WebView, and both - * checked opens the report both ways. The flags are keyed by the report's format and name — its identity across runs, - * since the path changes with the execution environment. THIS_RUN lives in the run's own [TestoReportStore] and is - * what a click on a button whose report is still being written arms; the other two persist through - * [PropertiesComponent]. + * When a report opens without being clicked. Every (way, scope) pair is an independent flag, keyed by the report's + * format and name — its identity across runs, since the path changes with the execution environment. THIS_RUN lives + * in the run's own [TestoReportStore]; the other two persist through [PropertiesComponent]. */ object TestoReportAutoOpen { fun keyOf(ref: TestoReportRef): String = "${ref.format}/${ref.name.orEmpty()}" @@ -43,10 +38,7 @@ object TestoReportAutoOpen { } } - /** - * The ways this report should open on its own, each granted by any scope — unless the report is muted, the - * button un-pressed: one flag silences every way for this run without unchecking any standing choice. - */ + /** The ways this report should open on its own — none while muted, whatever any scope grants otherwise. */ fun decide(project: Project, store: TestoReportStore, ref: TestoReportRef): Set { val key = keyOf(ref) if (store.isAutoOpenMuted(key)) return emptySet() 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 0cffd88..ad14de4 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 @@ -9,7 +9,6 @@ import java.nio.file.Path * forwards it (see [TestoOutputToGeneralEventsConverter]). */ data class TestoReportRef( - /** `html` today; kept so a future format can be told apart rather than guessed from the extension. */ val format: String, /** Absolute inside the *execution* environment — under a remote interpreter or a container, not a host path. */ val path: String, @@ -18,12 +17,7 @@ data class TestoReportRef( val name: String?, val schemaVersion: String?, ) { - /** - * Whether this is a report the button can show as a page. - * - * Testo announces every report it writes, and not all are pages — a data document for external tooling has nothing - * to open in a browser. Such formats are kept for handling of their own, not offered here. - */ + /** 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) } companion object { @@ -34,11 +28,9 @@ data class TestoReportRef( private const val MESSAGE_NAME = "testoReport" /** - * The message read straight off raw output, or `null` when it holds none. - * - * The platform parses a line only when it *starts* with `##teamcity[`, so anything in front of it (a colour - * escape, output not terminated by a newline) leaves the announcement to reach the console as plain text. Hence - * the scan for the message anywhere in the text; the store deduplicates by path when both routes deliver. + * The message read straight off raw output, or `null` when it holds none. The platform parses a line only + * when it *starts* with `##teamcity[`, so one behind a colour escape reaches the console as plain text — + * hence the scan anywhere in the line; the store dedups by path when both routes deliver. */ fun fromServiceMessageLine(line: String): TestoReportRef? { val start = line.indexOf("##teamcity[$MESSAGE_NAME") @@ -49,7 +41,7 @@ data class TestoReportRef( return fromAttributes(parseServiceMessageAttributes(body)) } - /** `null` when the message carries no `path`, which is the one attribute nothing can be done without. */ + /** `null` when the message carries no `path`. */ fun fromAttributes(attributes: Map): TestoReportRef? { val path = attributes["path"]?.takeIf { it.isNotBlank() } ?: return null return TestoReportRef( @@ -64,39 +56,31 @@ data class TestoReportRef( } /** - * The reports of the current run, in announcement order. - * - * Written by the converter off the process's output thread and read by the toolbar button on the EDT, hence the lock. - * Keyed by path: a re-announced report replaces its earlier entry instead of stacking up. + * The reports of the current run, in announcement order. Written by the converter off the process's output thread and + * read by the toolbar on the EDT, hence the locks; keyed by path, so a re-announced report replaces its entry. */ class TestoReportStore { private val reports = LinkedHashMap() - // The deferred opens of the current run (see AutoOpenScope.THIS_RUN): (TestoReportAutoOpen.keyOf, way) pairs, - // each its own flag — arming one way must not take another way's back. + // This run's deferred opens: (TestoReportAutoOpen.keyOf, way) pairs, each an independent flag. private val autoOpenThisRun = HashSet>() - // Reports the user clicked back off for this run alone — one flag over every way of opening: a standing - // project- or application-wide choice must neither fire nor be unchecked. Guarded by autoOpenThisRun's lock. + // Reports clicked back off for this run alone — one flag over every way; the standing choices stay checked. private val autoOpenMuted = 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, and it is - * written to the same path every run — so a file check while the run is going finds the previous run's report and - * offers it as this one's. Volatile: set from the process's thread, read by the toolbar on the EDT. + * 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 + * previous run's file. */ @Volatile var runFinished: Boolean = false private set /** - * When the current run began, floored to a whole second. - * - * A report older than this is the *previous* run's: the path is the same every run, so a run stopped before Testo - * wrote its report leaves the old file in place. Floored because a filesystem that keeps mtime by the second would - * otherwise date a report written moments after the start before it. + * When the current run began, floored to a whole second — a filesystem keeping mtime by the second would + * otherwise date a report written moments after the start before it. A report older than this is the previous + * run's, left in place by a run stopped before its reporter ran. */ @Volatile var runStartedAt: Long = 0 @@ -105,8 +89,7 @@ class TestoReportStore { fun noteRunStarted(now: Long = System.currentTimeMillis()) { runFinished = false runStartedAt = now - now % 1000 - // A deferred open — and a mute over the standing choices — belongs to the run it was clicked in; a run - // stopped before its report fired must not open the next run's, and a muted run must not mute the next. + // Arms and mutes belong to the run they were clicked in. synchronized(autoOpenThisRun) { autoOpenThisRun.clear() autoOpenMuted.clear() @@ -121,7 +104,7 @@ class TestoReportStore { synchronized(reports) { reports[ref.path] = ref } } - /** The deferred open the user asked for during this run. Arming lifts the report's mute — it is the newer word. */ + /** Arming lifts the report's mute — it is the newer word. */ fun armAutoOpen(key: String, way: ReportOpenWay, armed: Boolean) { synchronized(autoOpenThisRun) { if (armed) { @@ -136,7 +119,7 @@ class TestoReportStore { fun isAutoOpenArmed(key: String, way: ReportOpenWay): Boolean = synchronized(autoOpenThisRun) { key to way in autoOpenThisRun } - /** Muting also takes this run's own clicks back: they are what is being un-pressed, when no standing choice is. */ + /** Muting also takes this run's own arms back. */ fun muteAutoOpen(key: String, muted: Boolean) { synchronized(autoOpenThisRun) { if (muted) { @@ -162,18 +145,14 @@ class TestoReportStore { fun all(): List = synchronized(reports) { reports.values.toList() } - /** Every report the button can show, in announcement order. */ fun viewable(): List = all().filter { it.isViewable } - /** What the button opens: the last viewable report announced, since that is the one this run wrote last. */ fun primary(): TestoReportRef? = viewable().lastOrNull() } /** - * `key='value'` pairs up to the closing `]`, with TeamCity's escaping undone. - * - * A hand-rolled reader rather than the platform's parser, because this runs on text the platform has already declined - * to parse. Values are single-quoted and `|` escapes; anything malformed is skipped rather than thrown over. + * `key='value'` pairs up to the closing `]`, with TeamCity's escaping undone. Hand-rolled because this runs on text + * the platform has already declined to parse; anything malformed is skipped rather than thrown over. */ internal fun parseServiceMessageAttributes(body: String): Map { val attributes = LinkedHashMap() @@ -217,11 +196,8 @@ private fun unescapeServiceMessageChar(escaped: Char): String = when (escaped) { } /** - * Where the announced report might sit on this machine, best guess first. - * - * The mapper leads: it is the only candidate that knows about deployment, and for a local interpreter it answers with - * the path itself anyway. The raw path follows for the plain local run, and the project-relative form is the last - * resort — it is what survives when the execution environment's filesystem has nothing in common with the host's. + * Where the announced report might sit on this machine, best guess first: the deployment mapper, the raw path, then + * `relativePath` under the project root — the one that survives when the run's filesystem shares nothing with the host. */ fun reportPathCandidates( ref: TestoReportRef, diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt index ebd2420..91976e2 100644 --- a/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt @@ -24,13 +24,8 @@ import javax.swing.JComponent import javax.swing.SwingConstants /** - * An editor tab showing a generated Testo report in JCEF. - * - * A tab rather than a tool window so it can be split, kept alongside the code and closed like any other file. The - * platform's own `HTMLEditorProvider` would have done this, but it is `@ApiStatus.Internal` — hence the light file plus - * provider below, which is all public API. - * - * The report opens over `file://`, so it must not fetch its data: see the report spec in the Testo repository. + * An editor tab showing a generated Testo report in JCEF, over `file://`. The platform's own `HTMLEditorProvider` + * is `@ApiStatus.Internal` — hence the light file plus provider below, which is all public API. */ class TestoReportVirtualFile(val reportPath: Path, label: String) : LightVirtualFile(label) { init { @@ -42,8 +37,7 @@ class TestoReportVirtualFile(val reportPath: Path, label: String) : LightVirtual class TestoReportFileEditor(private val file: TestoReportVirtualFile) : UserDataHolderBase(), FileEditor { - // Null when the IDE runs without JCEF. The action checks TestoReportViewer.isAvailable before opening a tab, so - // this is only the belt-and-braces path — and it is why the failure is swallowed rather than thrown at the editor. + // Null when the IDE runs without JCEF; the action checks TestoReportViewer.isAvailable before opening a tab. private val browser: JBCefBrowser? = runCatching { if (JBCefApp.isSupported()) JBCefBrowser.createBuilder().setUrl(file.reportUrl).build() else null }.getOrNull() @@ -73,11 +67,8 @@ class TestoReportFileEditor(private val file: TestoReportVirtualFile) : UserData override fun removePropertyChangeListener(listener: PropertyChangeListener) = Unit /** - * Re-reads the report from disk — what a second click on the toolbar button means after a new run. - * - * Through `loadURL`, not `cefBrowser.reloadIgnoreCache()`: `CefBrowser` lives in `org.cef`, which the platform - * artifact does not put on the compile classpath — it compiles only against a JDK that happens to bundle JCEF, and - * CI's does not. `JBCefBrowser` is the module we do depend on, and navigating to the same `file://` URL re-reads it. + * Re-reads the report from disk. Through `loadURL`, not `cefBrowser.reloadIgnoreCache()`: `org.cef` is not on the + * compile classpath (only `com.intellij.ui.jcef` is), and navigating to the same `file://` URL re-reads it anyway. */ fun reload() { browser?.loadURL(file.reportUrl) @@ -100,18 +91,13 @@ class TestoReportFileEditorProvider : FileEditorProvider, DumbAware { } object TestoReportViewer { - // One light file per report path, so re-opening the same report returns to its tab instead of stacking up new ones - // — FileEditorManager keys tabs by VirtualFile identity, not by equality. + // One light file per report path: FileEditorManager keys tabs by VirtualFile identity, not by equality. private val files = ConcurrentHashMap() /** - * Whether a report can be shown in a tab at all. - * - * Asked by reflection, and that is the point: JCEF may be absent outright (module not visible, unsupported - * architecture, a remote-dev backend), and a *named* reference to `JBCefApp` in a method body throws - * `NoClassDefFoundError` when that body's class is verified — before any `try` around the call can catch it. That is - * what took the toolbar's whole action group down. Nothing else in this plugin mentions a JCEF type outside a class - * that loads only once this has answered `true`. + * Asked by reflection on purpose: JCEF may be absent outright, and a *named* reference to `JBCefApp` throws + * `NoClassDefFoundError` when the enclosing class is verified — before any `try` can catch it. No JCEF type may + * be mentioned outside a class that loads only after this answers `true`. */ val isAvailable: Boolean by lazy { val supported = runCatching { diff --git a/src/main/resources/META-INF/jcef.xml b/src/main/resources/META-INF/jcef.xml index 0d5fdbc..2b84bac 100644 --- a/src/main/resources/META-INF/jcef.xml +++ b/src/main/resources/META-INF/jcef.xml @@ -1,5 +1,3 @@ - + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 7c454bf..b3bc8db 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -8,11 +8,8 @@ com.jetbrains.php com.intellij.modules.coverage - + com.intellij.modules.jcef messages.TestoBundle @@ -38,7 +35,6 @@ -