<script lang="ts">
import type { GroupRun, RunView, StepRun, SuiteRun } from "../../stores/op-output-view.js";
import { formatMs, suiteOutcome, tallyOf } from "../../stores/op-output-view.js";
import { formatElapsed, ticker } from "../../stores/elapsed.svelte.js";
import { logTail } from "../../stores/log-tail.svelte.js";
import ScrollMarks from "./ScrollMarks.svelte";
let tree = $state<HTMLDivElement | undefined>();
import { remembered } from "../../utils/remember.svelte.js";
import AppIcon from "../layout/AppIcon.svelte";
let {
view,
selected,
onselect,
}: {
view: RunView;
selected: string | null;
onselect: (step: StepRun) => void;
} = $props();
const TAIL_SHOWN = 8;
let collapsed = $state<Set<string>>(new Set());
const failuresOnly = remembered("runtree.failuresOnly", false);
const running = $derived(
view.groups
.flatMap((g) => g.suites)
.flatMap((s) => s.steps)
.find((s) => s.outcome === null && s.startedAt !== null) ?? null,
);
$effect(() => (running ? ticker.watch() : undefined));
$effect(() => {
if (running?.log) logTail.open(running.log);
else logTail.close();
});
function toggle(group: string) {
const next = new Set(collapsed);
if (next.has(group)) next.delete(group);
else next.add(group);
collapsed = next;
}
function shownSuites(group: GroupRun): SuiteRun[] {
if (!failuresOnly.value) return group.suites;
return group.suites.filter((suite) => suiteOutcome(suite) === "failed");
}
const anyFailed = $derived(
view.groups.some((group) => group.suites.some((s) => suiteOutcome(s) === "failed")),
);
function stepLabel(step: StepRun): string {
return step.step;
}
</script>
<div class="run-tree-holder">
<div class="run-tree" bind:this={tree}>
<div class="tree-controls">
<label class="toggle">
<input type="checkbox" bind:checked={failuresOnly.value} disabled={!anyFailed} />
failures only
</label>
{#if view.summary}
<span class="tally">
<span class="ok">{view.summary.passed} passed</span>
<span class:bad={view.summary.failed > 0}>{view.summary.failed} failed</span>
<span class="dim">{view.summary.skipped} skipped</span>
<span class="dim">{formatMs(view.summary.durationMs)}</span>
</span>
{/if}
</div>
{#if view.violations.length > 0}
<div class="violations" role="alert">
<span class="violations-title">the run disagreed with its own plan</span>
{#each view.violations as violation, i (i)}
<div class="violation">{violation}</div>
{/each}
</div>
{/if}
{#each view.groups as group (group.group)}
{@const tally = tallyOf(group)}
{@const suites = shownSuites(group)}
<div class="group">
<button class="group-row" onclick={() => toggle(group.group)}>
<span class="twist"><AppIcon name={collapsed.has(group.group) ? "chevron-right" : "chevron-down"} size={11} /></span>
<span class="group-name">{group.label ?? group.group}</span>
<span
class="group-state"
class:running={group.outcome === null}
class:ok={group.outcome === "ok"}
class:bad={group.outcome === "failed"}
class:skipped={group.outcome === "skipped"}
>
{group.outcome ?? (tally.running > 0 ? "running" : "waiting")}
</span>
<span class="counts">
{#if tally.ok > 0}<span class="ok">{tally.ok}✓</span>{/if}
{#if tally.failed > 0}<span class="bad">{tally.failed}✗</span>{/if}
{#if tally.skipped > 0}<span class="dim">{tally.skipped}–</span>{/if}
{#if tally.running > 0}<span class="running">{tally.running}…</span>{/if}
{#if tally.waiting > 0}<span class="dim">{tally.waiting}·</span>{/if}
</span>
{#if group.durationMs !== null}
<span class="dim time">{formatMs(group.durationMs)}</span>
{/if}
</button>
{#if !collapsed.has(group.group)}
{#each suites as suite (suite.key)}
{@const outcome = suiteOutcome(suite)}
<div class="suite">
<div class="suite-row">
<span
class="dot"
class:running={outcome === null}
class:ok={outcome === "ok"}
class:bad={outcome === "failed"}
class:skipped={outcome === "skipped"}
></span>
<span class="suite-name">{suite.suite}</span>
{#if suite.skip}<span class="skip-reason">{suite.skip}</span>{/if}
</div>
{#each suite.steps as step (step.key)}
<button
class="step-row"
data-severity={step.outcome === "failed"
? "error"
: step.outcome === "skipped"
? "warning"
: undefined}
class:selected={selected === step.key}
class:has-log={step.log !== null}
onclick={() => onselect(step)}
title={step.log ? `open ${step.log}` : "no log for this step yet"}
>
<span
class="step-state"
class:running={step.outcome === null && step.startedAt !== null}
class:waiting={step.outcome === null && step.startedAt === null}
class:ok={step.outcome === "ok"}
class:bad={step.outcome === "failed"}
class:skipped={step.outcome === "skipped"}
>
{step.outcome === null
? step.startedAt === null
? "WAIT"
: "····"
: step.outcome === "ok"
? "PASS"
: step.outcome === "failed"
? "FAIL"
: "SKIP"}
</span>
<span class="step-name">{stepLabel(step)}</span>
{#if step.message}<span class="step-message">{step.message}</span>{/if}
{#if step.durationMs !== null}
<span class="dim time">{formatMs(step.durationMs)}</span>
{:else if step.startedAt !== null}
<span class="time running-time"
>{formatElapsed(step.startedAt, undefined, ticker.now)}</span
>
{/if}
</button>
{#if running?.key === step.key && (logTail.lines.length > 0 || logTail.waiting)}
<div class="live-tail" role="log" aria-live="polite" aria-label="live output">
{#if logTail.waiting && logTail.lines.length === 0}
<div class="tail-line dim">waiting for output…</div>
{/if}
{#each logTail.lines.slice(-TAIL_SHOWN) as line, i (i)}
<div class="tail-line">{line}</div>
{/each}
</div>
{/if}
{/each}
</div>
{/each}
{#if suites.length === 0}
<div class="empty">
{failuresOnly.value ? "nothing failed in this group" : "no suites ran"}
</div>
{/if}
{/if}
</div>
{/each}
</div>
<ScrollMarks container={tree} label="run" />
</div>
<style>
.run-tree-holder {
position: relative;
flex: 1;
min-height: 0;
display: flex;
}
.run-tree {
display: flex;
flex-direction: column;
font-size: var(--wb-font-size-11);
overflow: auto;
min-height: 0;
flex: 1;
min-width: 0;
}
.tree-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-4) var(--wb-spacing-10);
position: sticky;
top: 0;
background: var(--vscode-editor-background, var(--vscode-panel-background, #1f1f1f));
z-index: 1;
border-bottom: 1px solid var(--vscode-panel-border);
}
.toggle {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-4);
opacity: 0.85;
}
.tally {
display: inline-flex;
gap: var(--wb-spacing-8);
font-variant-numeric: tabular-nums;
}
.group-row,
.step-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
width: 100%;
text-align: left;
background: none;
border: none;
color: inherit;
cursor: pointer;
font: inherit;
padding: var(--wb-spacing-2) var(--wb-spacing-10);
}
.group-row:hover,
.step-row:hover {
background: color-mix(in srgb, currentColor 7%, transparent);
}
.group-name {
font-weight: 600;
}
.group-state,
.step-state {
text-transform: lowercase;
opacity: 0.9;
}
.step-state {
width: var(--wb-control-size-34);
flex-shrink: 0;
text-transform: none;
font-variant-numeric: tabular-nums;
}
.counts {
display: inline-flex;
gap: var(--wb-spacing-6);
font-variant-numeric: tabular-nums;
}
.twist {
width: var(--wb-control-size-10);
opacity: 0.7;
}
.suite {
display: flex;
flex-direction: column;
}
.suite-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-2) var(--wb-spacing-10) 0 var(--wb-spacing-24);
opacity: 0.9;
}
.dot {
width: var(--wb-control-size-6);
height: var(--wb-control-size-6);
border-radius: 50%;
background: color-mix(in srgb, currentColor 30%, transparent);
}
.step-row {
padding-left: var(--wb-spacing-38);
}
.step-row.selected {
background: color-mix(in srgb, var(--vscode-focusBorder, #0e70c0) 18%, transparent);
}
.step-row.has-log .step-name {
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.step-message {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.time {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.skip-reason {
margin-left: var(--wb-spacing-8);
opacity: 0.6;
font-style: italic;
}
.step-state.waiting {
opacity: 0.45;
}
.violations {
margin: var(--wb-spacing-4) var(--wb-spacing-8) var(--wb-spacing-6);
padding: var(--wb-spacing-5) var(--wb-spacing-8);
border-left: 2px solid var(--vscode-testing-iconFailed, #f44336);
background: var(--vscode-inputValidation-errorBackground, rgba(244, 67, 54, 0.12));
}
.violations-title {
display: block;
font-weight: 600;
color: var(--vscode-testing-iconFailed, #f44336);
}
.violation {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.9em;
}
.running-time {
color: var(--vscode-testing-iconQueued, #cca700);
}
.live-tail {
margin: 0 0 var(--wb-spacing-3) var(--wb-spacing-24);
padding: var(--wb-spacing-3) var(--wb-spacing-8);
border-left: 2px solid var(--vscode-testing-iconQueued, #cca700);
background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.1));
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.9em;
overflow-x: auto;
}
.tail-line {
white-space: pre;
line-height: 1.35;
opacity: 0.85;
}
.empty {
padding: var(--wb-spacing-2) var(--wb-spacing-10) var(--wb-spacing-4) var(--wb-spacing-24);
opacity: 0.6;
}
.ok,
.dot.ok {
color: var(--vscode-testing-iconPassed, #4caf50);
background: var(--vscode-testing-iconPassed, #4caf50);
}
.ok {
background: none;
}
.bad,
.dot.bad {
color: var(--vscode-errorForeground, #f14c4c);
background: var(--vscode-errorForeground, #f14c4c);
}
.bad {
background: none;
}
.running,
.dot.running {
color: var(--vscode-focusBorder, #0e70c0);
background: var(--vscode-focusBorder, #0e70c0);
}
.running {
background: none;
}
.skipped,
.dim,
.dot.skipped {
opacity: 0.6;
}
</style>