Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<depends optional>` on `com.intellij.modules.jcef`.** The module form (`intellij.platform.ui.jcef` in
`<dependencies>`) 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

Expand Down
4 changes: 3 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
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
Expand Down Expand Up @@ -46,8 +48,13 @@

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) }

Check notice on line 56 in src/main/kotlin/com/github/xepozz/testo/tests/TestoConsoleProperties.kt

View workflow job for this annotation

GitHub Actions / Inspect code

Class member can have 'private' visibility

Property 'reportsAction' could be private

// 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
Expand All @@ -65,6 +72,7 @@
runTimings,
targetStore,
nodeIndex,
reportStore,
)

override fun getTestStackTraceParser(url: String, proxy: SMTestProxy, project: Project) =
Expand Down Expand Up @@ -96,7 +104,8 @@
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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -84,6 +87,7 @@ class TestoProgressAction : AnAction(), CustomComponentAction, RightAlignedToolb
store: TestoStatusStore,
clock: TestoRunTimings,
targets: TestoTargetStore,
reports: TestoReportStore,
handler: ProcessHandler?,
) {
val viewer = console.resultsViewer
Expand All @@ -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)
Expand All @@ -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
}
})

Expand All @@ -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()
}

/**
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading