Terminal View Region
The terminal-view region shows the progress and result of a multi-step run: a summary card with a headline and failures first, a list of steps with their state and duration, and a strip of colour blocks per test suite that you can hover or focus for a tooltip. It reads a stream of small, versioned events from an endpoint, or from events your own code hands to it, and reduces them to one model on the client.
You can show one in three ways:
| Where | How |
|---|---|
| A console page card | <@card type="terminal-view" id="run">{ contractVersion: '1', terminalView: {...} }</@card> |
| A views page region | TerminalViewDef.forMixin(...).toRegion() in a RegionTable |
| A table row detail | A datatables catalog detail.region with populate: 'terminal-view' and params |
The event types and the report readers live in their own small module, juneau-terminal-report (package
org.apache.juneau.terminal.report: TerminalEvent, TerminalViewChecks, ReportReaders and the readers), which has
no dependency on the REST server. A producer such as a build tool can depend on it alone. The rest of the server
half ships in juneau-console-views (org.apache.juneau.console.views), which depends on
juneau-terminal-report; the client half is juneau-terminal-view.js, part of the views toolkit pack. It pairs naturally with the Terminal Region: the
terminal view summarizes, the terminal holds the raw output, and a test block can link to the terminal offset it came from.
The event contract
The contract version is "1". The client accepts one event, an array of events, or a page:
{
"contractVersion": "1",
"events": [ { "ev": "step", "seq": 1, "id": "build", "title": "mvn package" } ],
"next": "k3x9ab.1",
"more": false,
"terminal": false
}
| Page member | Rules |
|---|---|
contractVersion | Required. Exactly "1"; anything else is fatal (E-RV-2). |
events | Required, may be empty. Ascending seq. At most 2,000 per page from the bundled endpoint. |
next | Required. An opaque token (^[A-Za-z0-9._~-]{1,128}$) the client sends back as ?after=. |
more | Required. true when the source holds more events right now; the client re-fetches at once. |
terminal | Optional, default false. true means the source will never produce another event. The client stops polling when it sees terminal: true with more: false. |
Every event carries ev and, in a page, a strictly increasing seq (gaps are fine). An unknown ev and unknown
members are ignored silently, so the contract can grow. A malformed known event is dropped and reported once per
batch (E-RV-3). Strings over their limit are truncated with a trailing … on the client and rejected by the Java
factories.
ev | Members | Meaning |
|---|---|---|
step | id (^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$), title (1-200), optional n, status (running or waiting), rawOffset | A step starts, or an open step is updated. |
end | id, status (ok, fail, skip), optional ms, exit | The step finishes. exit is shown in the step tooltip only. |
suite | step, fw, suite (1-512), counts {pass, fail, skip}, optional rawOffset | A live placeholder result for a whole suite, before per-test detail exists. |
test | step, fw, suite, name (1-512), status (pass, fail, skip, error), optional ms, msg (2,000), trace (8,000), rawOffset | One test result. |
replace | step | Discard the step's suites and tests so a fresh, authoritative set can follow. |
note | level (info, warn, error), text (1-1,000), optional href, step | A notable event, shown under its step or under the run. |
done | status (ok, fail, cancelled) | The run finished. |
fw is the test framework key (^[a-z0-9][a-z0-9._-]{0,31}$). The counts line labels the built-in keys
surefire (Surefire), junit-xml (JUnit), pytest (pytest), jest (Jest) and playwright (Playwright); any
other key is displayed as itself.
Reducer rules
The client applies events in seq order and skips any event at or below the last applied seq, so a replayed
page is harmless.
- A
stepwith a newidappends a step. With a known, openidit updates the title, ordinal, status andrawOffset(awaitingstep becomingrunningis a secondstepevent). For an ended step it is dropped. - An
endfor an unknown step is dropped; a secondendreplaces the first. - Suites are keyed by
(step, fw, suite). Asuiteevent creates a placeholder with counts; the firsttestfor the key converts it to a detailed suite and discards the placeholder counts. replaceremoves all suites and tests of a step. Which suites the user expanded survives it.- A step still open when a
doneexists is shown asfailif the run failed, otherwiseskip.
Re-run steps
A step cannot reopen once it has ended, so a producer that runs a step again sends it as a new step: the first run
keeps the plain id and later runs take <id>.2, <id>.3 and so on, titled <title> (attempt 2),
<title> (attempt 3). The client recognizes this naming, and shows only the latest attempt in the step list. The
earlier attempts sit under it behind an N earlier attempts toggle (1 earlier attempt for a single one),
collapsed by default. A step id counts as an attempt only when the step it extends (build for build.2) exists;
a title ending in (attempt N) is recognized the same way when the ids do not follow the pattern. Expanding the
toggle lists the earlier attempts, oldest first, each as an ordinary step. The toggle stays open as later attempts
arrive, and picking a failure from the summary list opens it when that failure belongs to an earlier attempt.
[
{ "ev": "step", "id": "build", "title": "mvn package" },
{ "ev": "end", "id": "build", "status": "fail" },
{ "ev": "step", "id": "build.2", "title": "mvn package (attempt 2)" }
]
This shows one step, mvn package (attempt 2), with a collapsed 1 earlier attempt toggle under it. Counts and
the failures list still include every attempt.
The run status is derived, not sent:
| Condition | Status | Headline |
|---|---|---|
| No events yet | empty | Waiting for events… |
Page terminal with no done | stopped | Stopped |
No done, a step waiting and none running | waiting | Waiting at title |
No done | running | Running |
done is ok | ok | Passed |
done is fail | fail | Failed at title |
done is cancelled | cancelled | Cancelled |
Serving events: TerminalViewMixin
TerminalViewMixin serves GET /juneau-terminal-view/{runId}/events?after=<token>. Implement terminalViewSource to resolve
the run id, and return an empty Optional for an id the caller may not read (the endpoint answers 404, never a
distinguishable 403):
@Rest
public class Runs extends BasicRestServlet implements TerminalViewMixin {
static final Map<String,TerminalViewSource> SOURCES = new ConcurrentHashMap<>();
@Override
public Optional<TerminalViewSource> terminalViewSource(String runId, RestRequest req) {
return Optional.ofNullable(SOURCES.get(runId));
}
}
TerminalViewEndpoints gives the same logic for custom paths. An unknown or stale after token answers 410 Gone,
and the client then reloads the stream from the start with no banner.
Sources
TerminalViewLog is a thread-safe in-memory source that the producer appends to:
TerminalViewLog log = TerminalViewLog.create();
Runs.SOURCES.put("42", log);
log.append(TerminalEvent.step("build", "mvn package"));
log.append(TerminalEvent.suite("build", "surefire", "FooTest", 12, 0, 0));
log.append(TerminalEvent.end("build", TerminalEvent.EndStatus.OK));
log.append(TerminalEvent.done(TerminalEvent.DoneStatus.OK));
log.close(); // the final page carries terminal: true
append numbers the event and returns it; appendAll takes a batch. The log holds at most
TerminalViewLog.DEFAULT_MAX_EVENTS (200,000) events. reset() empties it and starts a new epoch, so clients holding
an old token get 410.
FileTerminalViewSource pages one growing JSON Lines file, one event per line. The line number is the seq, so write
events without one. Only complete lines are read, and a truncated or replaced file answers 410:
TerminalViewSource src = FileTerminalViewSource.create(root.resolve("run-42.jsonl"))
.terminal(() -> run.isFinished())
.build();
The application supplies the Path; nothing in a request ever becomes a path. Keep one instance per file.
Implement TerminalViewSource for anything else.
Report readers
The readers in juneau-terminal-report turn test-report files into events, and work without the region. A reader never throws for
bad input: a missing, unreadable, oversize, malformed or truncated file yields the tests it could recover plus a
warning.
Kind passed to ReportReaders.forKind | Reader | fw |
|---|---|---|
surefire | SurefireReportReader (a directory of TEST-*.xml, or one file) | surefire |
junitxml | JUnitXmlReportReader | junit-xml |
pytest | JUnitXmlReportReader("pytest") | pytest |
jest-json | JestJsonReportReader | jest |
playwright-json | PlaywrightJsonReportReader | playwright |
forKind returns null for an unknown kind. The XML readers stream the file with external entities and DTDs
disabled. ReportLimits.DEFAULT caps a file at 32 MiB and a report at 100,000 tests, with names clipped to 512
characters, messages to 2,000 and traces to 8,000.
ReportResult result = ReportReaders.forKind("surefire").read(reportsDir, ReportLimits.DEFAULT);
for (TerminalEvent e : result.toEvents("build"))
log.append(e);
toEvents(step) returns replace, one test per test, then one warn note per warning (such as report truncated: unexpected end of file after 4120 tests). A result with warnings and no recovered tests returns only
the notes, so the step keeps its live suite placeholders. Warning text names files, never absolute paths.
Declaring the region
TerminalViewDef def = TerminalViewDef.forMixin("run", "", "42") // id, mount path, run id
.title("Run 42")
.refreshMs(2000)
.rawHref("#raw-O{offset}");
RegionTable.of(def.validate().toRegion());
| Option | Meaning |
|---|---|
eventsUrl | Same-origin events URL; {id} is replaced by the row id in a row detail. Required unless poll is false. |
refreshMs | Poll interval, at least 1000. |
poll | false skips polling; you feed events through the JavaScript API. |
compact | Smaller layout for a row detail: a three-failure list and a one-line summary when clean. |
title | Accessible name of the region; default "Run". |
rawHref | Template for block links, containing {offset} once, such as #raw-O{offset}. |
onBlockClick | Function called when a test block is clicked, or activated with Enter or Space. JavaScript API only; see Block clicks. |
As a console card
<@card type="terminal-view" id="run" title="Run">{
contractVersion: '1',
terminalView: { eventsUrl: '/juneau-terminal-view/42/events', rawHref: '#raw-O{offset}' }
}</@card>
The keys of terminalView are exactly those above. id= is required; src= and template= are rejected. The card
needs the views toolkit on the page. If JuneauViews.terminalView is missing at load time, the shell shows E-JS-14.
The other card types are listed in Admin-Console Chrome.
Linking to the raw console
A host page that shows a terminal card beside the terminal view gives the terminal view
rawHref: '#<terminal id>-O{offset}'. A block or step whose event carries rawOffset then links to that byte offset
in the terminal output. Which tab shows the terminal on hashchange is the host page's job.
JavaScript API
JuneauViews.terminalView publishes:
| Member | Purpose |
|---|---|
create(el, options) | Build an instance on an element without polling and return its handle. |
poll(handle, opts) | Start polling the handle's eventsUrl; resolves when polling ends. |
mount(el, options, ctx) | create plus poll (unless poll: false); returns a cleanup function. |
of(elOrId) | The handle mounted on an element or id. |
validateEvent, formatDuration, isSafeNoteHref, isSafeRawHref, CONTRACT_VERSION | Helpers. |
A handle has append(events), which takes one event or an array and returns {applied, skipped, dropped}, and
destroy(). To drive a region from your own transport, such as server-sent events, declare it with poll: false
and append what arrives:
const rv = JuneauViews.terminalView.of('run');
const es = new EventSource('/runs/42/stream');
es.onmessage = m => rv.append(JSON.parse(m.data));
An event passed without a seq gets the next one. The region publishes {kind: 'terminal-view.state', id, state, terminal} on the region bus whenever the derived status changes, so a host can enable or disable a Cancel
button without reading the DOM.
Block clicks
Every test block carries data-step-id, the id of the step it belongs to (a re-run step has its own id, such as
build.2). A host can map a block to its step without counting positions.
Pass onBlockClick to create() or mount() to be told when a block is used:
JuneauViews.terminalView.create(el, {
rawHref: '#term-O{offset}',
onBlockClick: info => console.log(info.stepId, info.offset, info.kind)
});
info is {stepId, offset, kind}:
| Member | Value |
|---|---|
stepId | The step's id, the same value as the block's data-step-id. |
offset | The test's rawOffset. Omitted when the test has none. |
kind | The test status: pass, fail, skip or error. |
The hook fires once per click, and once per Enter or Space on a focused block that is not a link. Link blocks
still navigate. A hook that throws is logged and ignored. A function can't be written in a declarative region or
a card, so onBlockClick works only in JavaScript-configured mounts; a value that is not a function is rejected
with E-RV-1.
Limits
Counts are always exact. Individual records beyond the caps are counted but not kept, and a cap hit is reported once (E-RV-9).
| Cap | Value |
|---|---|
| Steps | 1,000; further new steps are dropped (existing steps still update) |
| Suites per step | 5,000 |
| Retained passing or skipped tests | 20,000 per instance |
| Retained failing or errored tests | 5,000 per instance, so passes cannot crowd out failures |
| Notes | 1,000 |
| Serialized event | 65,536 characters |
The DOM is bounded separately: collapsed suites build no blocks, an expanded strip shows at most 300 blocks, at most 2,000 suite rows render, and the failures list shows 50 failures (3 in compact mode).
Security
- All event text is assigned with
textContent; nothing is parsed as HTML. - A block link is built from
rawHrefand the digits ofrawOffset; an event cannot inject a URL. note.hrefmust be an absolutehttp:orhttps:URL with a host, or a same-origin path. Anything else is dropped (E-RV-7) and the note still renders. Absolute links open withrel="noopener noreferrer".runIdis checked against^[A-Za-z0-9_-]{1,128}$before your resolver sees it, andafteragainst the token grammar. A token never names a path.
Accessibility
- The region is
role="region"with an accessible name; the headline is its only live region. - Steps are an ordered list, and each step's state is spoken as text, not conveyed by the glyph.
- Suite toggles are buttons with
aria-expanded. Blocks are focusable and carry the test name and status inaria-label; tooltips show on focus and close with Escape. - The failures list gives keyboard access to every failure without tabbing through the blocks, and a failure's trace opens from its own "details" button.
- Nothing is animated, and focus is never moved by an update.
Errors
Errors carry data-juneau-error="E-RV-n". A fatal one shows a banner inside the region, stops polling and keeps
the rendered content.
| Code | Meaning |
|---|---|
E-RV-1 | Bad option: missing or cross-origin eventsUrl, bad refreshMs, bad rawHref. Thrown before any DOM. |
E-RV-2 | A response's contractVersion is not "1". Fatal. |
E-RV-3 | Malformed events were dropped (a warning once per batch). |
E-RV-4 | Fetching events failed. Fatal for HTTP 4xx other than 408 and 429; otherwise "Reconnecting…" with backoff. |
E-RV-5 | Invalid next token, or seq not strictly increasing within a page. Fatal. |
E-RV-6 | An event named an unknown or ended step (warning, once per step). |
E-RV-7 | An unsafe note.href was ignored. |
E-RV-8 | The server reset the stream (410 with a token) and the view reloaded. Console only, no banner. |
E-RV-9 | A model cap was hit; some records are counted but not shown. |
The console shell adds E-JS-14 for a terminal-view card on a page without the views toolkit.
See also
- Regions
- Rich Table Views
- Admin-Console Chrome
- juneau-terminal, a build wrapper that writes terminal-view events