diff --git a/CHANGELOG.md b/CHANGELOG.md index ebffa62..3551fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +### Added + +- 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. + ## [2026.4.262] - 2026-08-10 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 958f844..7f6e7de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +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` 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). ### Two build variants (`phpApi`) @@ -151,6 +151,9 @@ 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 +│ │ ├── 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 │ │ └── 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,12 @@ 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 each report with the non-standard `##teamcity[testoReport …]`; + `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 Non-obvious constraints already paid for in blood — read before touching the relevant area. @@ -461,6 +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. +- **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/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..7a734e6 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.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 @@ -46,8 +48,13 @@ class TestoConsoleProperties( val targetStore = TestoTargetStore(nodeIndex) + 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 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. var channelsInstalled = false @@ -65,6 +72,7 @@ class TestoConsoleProperties( runTimings, targetStore, nodeIndex, + reportStore, ) override fun getTestStackTraceParser(url: String, proxy: SMTestProxy, project: Project) = @@ -96,7 +104,8 @@ class TestoConsoleProperties( 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. + // 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/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 { 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..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 @@ -95,7 +95,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..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 @@ -20,6 +20,7 @@ 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. */ @@ -42,6 +43,8 @@ class TestoOutputToGeneralEventsConverter( override fun process(text: String, outputType: Key<*>) { if (runnerVersion == null) runnerVersion = TestoProtocolGate.parseVersion(text) + // Second route: a message behind a colour escape never reaches parseServiceMessage. The store dedups by path. + TestoReportRef.fromServiceMessageLine(text)?.let { reportStore.note(it) } super.process(text, outputType) } @@ -112,6 +115,12 @@ class TestoOutputToGeneralEventsConverter( } } + // Testo's own message, naming a report of this run. Not forwarded, for the same reason as buildProblem. + TESTO_REPORT -> { + TestoReportRef.fromAttributes(attrs)?.let { reportStore.note(it) } + return + } + BUILD_PROBLEM -> { reportBuildProblem(attrs["description"].orEmpty(), attrs["identity"].orEmpty()) // Not forwarded: the visitor sends unknown names to handleUnexpectedServiceMessage, which echoes the @@ -215,5 +224,6 @@ 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" } } 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..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,6 +69,9 @@ 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; makes the next start a new run. EDT-only. */ + private var formFinished = false + override fun getActionUpdateThread() = ActionUpdateThread.EDT override fun actionPerformed(e: AnActionEvent) = Unit @@ -84,6 +87,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb store: TestoStatusStore, clock: TestoRunTimings, targets: TestoTargetStore, + reports: TestoReportStore, handler: ProcessHandler?, ) { val viewer = console.resultsViewer @@ -101,14 +105,23 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb 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. clock.noteStart() + reports.noteRunStarted() viewer.addEventsListener(object : TestResultsViewer.EventsListener { 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()) { + // + // 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() + // 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() exitCode.set(null) @@ -133,6 +146,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 } }) @@ -142,8 +156,11 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb 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() } /** @@ -405,7 +422,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) @@ -430,8 +447,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() @@ -608,6 +632,15 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb } } +/** + * 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) + 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/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..a41f4a2 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAction.kt @@ -0,0 +1,465 @@ +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.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 +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.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 +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 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, + private val project: Project, + private val mapToLocal: (String) -> String?, +) : AnAction(), CustomComponentAction, RightAlignedToolbarAction, DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) = Unit + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = true + } + + override fun createCustomComponent(presentation: Presentation, place: String): JComponent = ReportsPanel() + + /** 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. + layout = null + border = JBUI.Borders.empty(0, 10, 0, 4) + isVisible = false + } + + // 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() + val inset = JBUI.scale(4) + g.fillRect(JBUI.scale(2), inset, JBUI.scale(1), height - 2 * inset) + } + + 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() + 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() + + // Re-laid out only when the row changed shape — this runs twice a second. + val width = preferredSize.width + if (width != laidOutWidth) { + laidOutWidth = width + revalidate() + repaint() + } + } + } + + /** 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 runWasFinished = false + private var willAutoOpen = false + // 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 (no UI delegate reinstalls it). + override fun getFont(): Font = UIUtil.getLabelFont() + + init { + isOpaque = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + hovered = true + repaint() + } + + override fun mouseExited(e: MouseEvent) { + hovered = false + repaint() + } + + override fun mouseClicked(e: MouseEvent) { + when { + e.x >= width - arrowZone() -> showMenu() + located != null -> open(defaultWay(), located!!) + else -> toggleScheduled() + } + } + }) + } + + private fun text(): String = ref.name ?: TestoBundle.message("testo.report.action.text") + + private fun arrowZone(): Int = ARROW.iconWidth + GAP + PADDING + + fun refresh() { + // Not while the run is going: a report is announced as Testo starts writing it, over the path the + // previous run wrote to — a check now would offer that run's file. + val finished = reports.runFinished + val found = if (finished) resolveReport(ref, project, mapToLocal, reports.runStartedAt) else null + maybeAutoOpen(found, finished) + val willOpen = !finished && TestoReportAutoOpen.decide(project, reports, ref).isNotEmpty() + // Tooltip and repaint only on a real change: this runs twice a second. + if (refreshed && found == located && finished == runWasFinished && willOpen == willAutoOpen) return + refreshed = true + located = found + runWasFinished = finished + willAutoOpen = willOpen + toolTipText = when { + 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() + } + + /** + * 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 -> + 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 + 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) { + g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val arc = JBUI.scale(6) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } + 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 = UIUtil.getLabelForeground() + val metrics = g2.fontMetrics + val textX = PADDING + ICON.iconWidth + GAP + g2.drawString(text(), textX, (height - metrics.height) / 2 + metrics.ascent) + + ARROW.paintIcon(this, g2, width - PADDING - ARROW.iconWidth, (height - ARROW.iconHeight) / 2) + } finally { + g2.dispose() + } + } + + 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() + } + } + + /** + * 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) + 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(openGroup("testo.report.open.webview", AllIcons.Actions.Preview, ReportOpenWay.WEB_VIEW)) + } + 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() + .createActionGroupPopup( + null, + group, + DataManager.getInstance().getDataContext(this), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ActionPlaces.TOOLBAR, + ) + .showUnderneathOf(this) + } + + private fun openGroup(key: String, icon: Icon, way: ReportOpenWay) = + OpenReportGroup(TestoBundle.message(key), icon, way, { ref }, project, reports, ::openOrArm) + } + + private companion object { + private const val REFRESH_MS = 500 + + 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) + } +} + +/** "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, + 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 + + override fun actionPerformed(e: AnActionEvent) = openOrArm(way) + + 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, + 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) { + // 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 { + TestoReportAutoOpen.set(scope, project, reports, key(), way, state) + } + } + + private fun key(): String = TestoReportAutoOpen.keyOf(target()) +} + +private class RevealReportAction( + private val target: () -> TestoReportRef, + private val project: Project, + private val mapToLocal: (String) -> String?, + private val reports: TestoReportStore, +) : AnAction(RevealFileAction.getActionName(), null, AllIcons.Nodes.Folder), DumbAware { + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isVisible = RevealFileAction.isSupported() + e.presentation.isEnabled = e.presentation.isVisible && resolve() != null + } + + override fun actionPerformed(e: AnActionEvent) { + RevealFileAction.openFile(resolve() ?: return) + } + + 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: 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) +} + +// 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. */ +internal fun resolveReport( + ref: TestoReportRef, + project: Project, + mapToLocal: (String) -> String?, + writtenAfter: Long, +): Path? = + // 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() } + .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/TestoReportAutoOpen.kt b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt new file mode 100644 index 0000000..62c42a7 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportAutoOpen.kt @@ -0,0 +1,51 @@ +package com.github.xepozz.testo.tests.console + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.project.Project + +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 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()}" + + 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 — 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() + 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 new file mode 100644 index 0000000..ad14de4 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/tests/console/TestoReportStore.kt @@ -0,0 +1,212 @@ +package com.github.xepozz.testo.tests.console + +import java.nio.file.Path + +/** + * A report Testo generated during the run, announced with `##teamcity[testoReport …]`. + * + * Non-standard by design: the platform knows nothing of it, so the converter consumes the message itself and never + * forwards it (see [TestoOutputToGeneralEventsConverter]). + */ +data class TestoReportRef( + val format: String, + /** Absolute inside the *execution* environment — under a remote interpreter or a container, not a host path. */ + val path: String, + /** The same file relative to the working directory, when it sits inside it. The way back from a mapped path. */ + val relativePath: String?, + val name: String?, + val schemaVersion: String?, +) { + /** 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 { + const val FORMAT_HTML: String = "html" + + private val VIEWABLE_FORMATS = setOf(FORMAT_HTML) + + 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 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") + if (start < 0) return null + val body = line.substring(start + "##teamcity[$MESSAGE_NAME".length) + // The name has to end here, or `testoReportSomethingElse` would be read as ours. + if (body.isNotEmpty() && !body[0].isWhitespace() && body[0] != ']') return null + return fromAttributes(parseServiceMessageAttributes(body)) + } + + /** `null` when the message carries no `path`. */ + fun fromAttributes(attributes: Map): TestoReportRef? { + val path = attributes["path"]?.takeIf { it.isNotBlank() } ?: return null + return TestoReportRef( + format = attributes["format"]?.takeIf { it.isNotBlank() } ?: FORMAT_HTML, + path = path, + relativePath = attributes["relativePath"]?.takeIf { it.isNotBlank() }, + name = attributes["name"]?.takeIf { it.isNotBlank() }, + schemaVersion = attributes["schemaVersion"]?.takeIf { it.isNotBlank() }, + ) + } + } +} + +/** + * 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() + + // This run's deferred opens: (TestoReportAutoOpen.keyOf, way) pairs, each an independent flag. + private val autoOpenThisRun = HashSet>() + + // 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, 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 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 + private set + + fun noteRunStarted(now: Long = System.currentTimeMillis()) { + runFinished = false + runStartedAt = now - now % 1000 + // Arms and mutes belong to the run they were clicked in. + synchronized(autoOpenThisRun) { + autoOpenThisRun.clear() + autoOpenMuted.clear() + } + } + + fun noteRunFinished() { + runFinished = true + } + + fun note(ref: TestoReportRef) { + synchronized(reports) { reports[ref.path] = ref } + } + + /** 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 arms back. */ + 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() } + + fun viewable(): List = all().filter { it.isViewable } + + fun primary(): TestoReportRef? = viewable().lastOrNull() +} + +/** + * `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() + var i = 0 + while (i < body.length) { + when { + body[i] == ']' -> return attributes + body[i].isWhitespace() -> i++ + else -> { + val eq = body.indexOf('=', i) + if (eq < 0 || eq + 1 >= body.length || body[eq + 1] != '\'') return attributes + val key = body.substring(i, eq).trim() + val value = StringBuilder() + var j = eq + 2 + while (j < body.length && body[j] != '\'') { + if (body[j] == '|' && j + 1 < body.length) { + value.append(unescapeServiceMessageChar(body[j + 1])) + j += 2 + } else { + value.append(body[j]) + j++ + } + } + if (j >= body.length) return attributes + if (key.isNotEmpty()) attributes[key] = value.toString() + i = j + 1 + } + } + } + return attributes +} + +// The letters TeamCity gives a meaning to; every other escape (`|'`, `||`, `|[`, `|]`) stands for the character itself. +private fun unescapeServiceMessageChar(escaped: Char): String = when (escaped) { + 'n' -> "\n" + 'r' -> "\r" + 'x' -> "\u0085" + 'l' -> "\u2028" + 'p' -> "\u2029" + else -> escaped.toString() +} + +/** + * 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, + projectBasePath: String?, + mapToLocal: (String) -> String?, +): List = buildList { + mapToLocal(ref.path)?.takeIf { it.isNotBlank() }?.let { add(it) } + add(ref.path) + if (projectBasePath != null && ref.relativePath != null) { + add(Path.of(projectBasePath, ref.relativePath).toString()) + } +}.distinct() diff --git a/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt b/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt new file mode 100644 index 0000000..91976e2 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/testo/ui/TestoReportEditor.kt @@ -0,0 +1,125 @@ +package com.github.xepozz.testo.ui + +import com.github.xepozz.testo.TestoBundle +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.LightVirtualFile +import com.intellij.ui.components.JBLabel +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.util.ui.JBUI +import java.beans.PropertyChangeListener +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import javax.swing.JComponent +import javax.swing.SwingConstants + +/** + * 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 { + isWritable = false + } + + val reportUrl: String get() = reportPath.toUri().toString() +} + +class TestoReportFileEditor(private val file: TestoReportVirtualFile) : UserDataHolderBase(), FileEditor { + + // 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() + + private val fallback: JComponent by lazy { + JBLabel(TestoBundle.message("testo.report.webview.unavailable"), SwingConstants.CENTER) + .apply { border = JBUI.Borders.empty(20) } + } + + override fun getComponent(): JComponent = browser?.component ?: fallback + + override fun getPreferredFocusedComponent(): JComponent? = browser?.component + + override fun getName(): String = TestoBundle.message("testo.report.editor.name") + + override fun getFile(): VirtualFile = file + + override fun setState(state: FileEditorState) = Unit + + override fun isModified(): Boolean = false + + // Always valid: the file is regenerated in place by the next run, and answering false would close the tab. + override fun isValid(): Boolean = true + + override fun addPropertyChangeListener(listener: PropertyChangeListener) = Unit + + override fun removePropertyChangeListener(listener: PropertyChangeListener) = Unit + + /** + * 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) + } + + override fun dispose() { + browser?.let { Disposer.dispose(it) } + } +} + +class TestoReportFileEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean = file is TestoReportVirtualFile + + override fun createEditor(project: Project, file: VirtualFile): FileEditor = + TestoReportFileEditor(file as TestoReportVirtualFile) + + override fun getEditorTypeId(): String = "TestoReportEditor" + + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR +} + +object TestoReportViewer { + // One light file per report path: FileEditorManager keys tabs by VirtualFile identity, not by equality. + private val files = ConcurrentHashMap() + + /** + * 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 { + 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..2b84bac --- /dev/null +++ b/src/main/resources/META-INF/jcef.xml @@ -0,0 +1,3 @@ + + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 09226e2..b3bc8db 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -8,6 +8,10 @@ com.jetbrains.php com.intellij.modules.coverage + + com.intellij.modules.jcef + messages.TestoBundle @@ -31,6 +35,9 @@ + + diff --git a/src/main/resources/messages/TestoBundle.properties b/src/main/resources/messages/TestoBundle.properties index cb78a55..8d433ca 100644 --- a/src/main/resources/messages/TestoBundle.properties +++ b/src/main/resources/messages/TestoBundle.properties @@ -44,6 +44,20 @@ 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.report.action.description.pending=Testo announced this report but did not write it in this run +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. + 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/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")) + } +} 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..95a588c --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/testo/TestoReportStoreTest.kt @@ -0,0 +1,293 @@ +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 +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 + +/** + * 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 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 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 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() + store.note(ref("/tmp/index.html")) + store.clear() + + assertNull(store.primary()) + assertTrue(store.all().isEmpty()) + } + + @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/mapped/runtime/report/index.html" } + + assertEquals( + listOf( + "/home/me/mapped/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") +}