diff --git a/src/commands/traces/get.ts b/src/commands/traces/get.ts index 1a27282..0bc6614 100644 --- a/src/commands/traces/get.ts +++ b/src/commands/traces/get.ts @@ -3,7 +3,7 @@ import type { ApiClient, FindingDetail, TraceDetail } from "../../api/client.js" import { type Writers, colorEnabled, defaultWriters, writeJson } from "../../output.js"; import { createStyler } from "../../render/style.js"; import { renderTree } from "../../render/tree.js"; -import { formatDuration, formatTimestamp, parseBackendTime } from "../../util/index.js"; +import { elapsedMs, formatDuration, formatTimestamp } from "../../util/index.js"; import { contextFromCommand, requireApiClient } from "../shared.js"; /** Max width for the single-line RCA preview shown inline in `traces get`. */ @@ -51,23 +51,6 @@ function isLive(spans: Span[]): boolean { return spans.some((span) => span.span_end_time === null); } -/** Milliseconds between two ISO timestamps, or null when not derivable. */ -function elapsedMs(start: string, end: string | null): number | null { - if (end === null) { - return null; - } - // Parse both as zone-less UTC so the live path (end is a real-UTC ISO) and the - // completed path stay consistent; a bare `new Date(...)` would misread the - // zone-less backend start time as host-local and skew the elapsed math. - const startDate = parseBackendTime(start); - const endDate = parseBackendTime(end); - if (startDate === null || endDate === null) { - return null; - } - const ms = endDate.getTime() - startDate.getTime(); - return Number.isFinite(ms) && ms >= 0 ? ms : null; -} - /** Core, network-free logic for `traces get`. Tests inject a fake client. */ export async function runGet(deps: RunGetDeps): Promise { const { client, json, writers, traceId } = deps; @@ -92,12 +75,17 @@ export async function runGet(deps: RunGetDeps): Promise { const styler = createStyler(writers.out); const label = (text: string): string => styler.bold(text); const live = isLive(trace.spans); + // Single "now" instant, reused for both the header's live elapsed duration + // and the per-span tree (via renderTree's `now` option), so a still-running + // span's elapsed-so-far agrees with the header rather than drifting apart + // from a second, later `Date.now()` read. + const nowIso = new Date().toISOString(); // Live traces have no real end yet: show elapsed-so-far and a LIVE marker // instead of an end time. Completed traces derive end/duration from the spans. const end = live ? null : latestSpanEnd(trace.spans); const duration = live - ? elapsedMs(trace.trace_start_time, new Date().toISOString()) + ? elapsedMs(trace.trace_start_time, nowIso) : elapsedMs(trace.trace_start_time, end); const lines: string[] = []; @@ -135,7 +123,13 @@ export async function runGet(deps: RunGetDeps): Promise { } lines.push(""); lines.push(label("Spans:")); - lines.push(renderTree(trace.spans, { color: colorEnabled(writers.out) })); + lines.push( + renderTree(trace.spans, { + color: colorEnabled(writers.out), + width: process.stdout.columns ?? 80, + now: nowIso, + }), + ); if (live) { // Indicate the tree is incomplete — more spans are still arriving. lines.push(" *** (live — more spans incoming)"); diff --git a/src/render/tree.ts b/src/render/tree.ts index b66e18e..27239d5 100644 --- a/src/render/tree.ts +++ b/src/render/tree.ts @@ -1,3 +1,5 @@ +import { elapsedMs, formatDuration } from "../util/index.js"; + /** * Minimal structural shape a span needs to be rendered as a tree. A superset of * the fields of `SpanResponse` used here, kept local so the renderer stays a @@ -9,6 +11,16 @@ export interface SpanLike { name: string; status?: string; span_start_time?: string; + /** End timestamp, or `null`/absent while the span is still running. */ + span_end_time?: string | null; + /** Error detail, shown as its own line beneath an errored span. */ + status_message?: string | null; + /** LLM model name; presence triggers the compact model/token/cost detail. */ + model_name?: string | null; + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + cost?: number | null; } const ERROR_STATUSES = new Set(["ERROR", "error", "STATUS_CODE_ERROR"]); @@ -17,6 +29,12 @@ const ERROR_STATUSES = new Set(["ERROR", "error", "STATUS_CODE_ERROR"]); // terminals; applied to the whole line, gated behind the `color` option. const ANSI_RESET = "\x1b[0m"; const ANSI_RED = "\x1b[91m"; +// Dim, for the secondary LLM model/token/cost detail line, so it reads as +// supplementary rather than competing with the span line itself. +const ANSI_DIM = "\x1b[2m"; + +/** Default terminal width assumed when {@link RenderTreeOptions.width} is omitted. */ +const DEFAULT_WIDTH = 80; function isError(status: string | undefined): boolean { return status !== undefined && ERROR_STATUSES.has(status); @@ -30,10 +48,71 @@ function sortKey(span: SpanLike, index: number): [string, number] { return [span.span_start_time ?? "", index]; } +/** + * Compact token count for the LLM detail line, e.g. `850` → `"850 tok"`, + * `1200` → `"1.2k tok"`. Values at or above 1000 collapse to one decimal of + * thousands (trailing `.0` dropped) so the detail stays a single glanceable + * token. + */ +function formatTokens(count: number): string { + if (count < 1000) { + return `${count} tok`; + } + const thousands = (count / 1000).toFixed(1).replace(/\.0$/, ""); + return `${thousands}k tok`; +} + +/** `total_tokens` if present, else `input_tokens + output_tokens`; null if neither is known. */ +function tokenCount(span: SpanLike): number | null { + if (span.total_tokens !== undefined && span.total_tokens !== null) { + return span.total_tokens; + } + if ( + (span.input_tokens !== undefined && span.input_tokens !== null) || + (span.output_tokens !== undefined && span.output_tokens !== null) + ) { + return (span.input_tokens ?? 0) + (span.output_tokens ?? 0); + } + return null; +} + +/** + * Compact `model · N tok · $cost` detail for an LLM span, or null when the + * span has no `model_name`. Tokens and cost are each omitted individually when + * unknown, so a span with only a model name still renders that much. + */ +function llmDetail(span: SpanLike): string | null { + if (span.model_name === undefined || span.model_name === null || span.model_name === "") { + return null; + } + const parts = [span.model_name]; + const tokens = tokenCount(span); + if (tokens !== null) { + parts.push(formatTokens(tokens)); + } + if (span.cost !== undefined && span.cost !== null) { + parts.push(`$${span.cost.toFixed(4)}`); + } + return parts.join(" · "); +} + /** Optional behavior for {@link renderTree}. */ export interface RenderTreeOptions { /** When true, error span lines are colored red. Off for non-TTY / `NO_COLOR`. */ color?: boolean; + /** + * Terminal width: durations are right-aligned to this column and error + * messages are truncated to it. Defaults to 80. + */ + width?: number; + /** + * ISO timestamp used as "now" for the elapsed-so-far duration of a span with + * no `span_end_time` (still running). Passed in — rather than read via + * `Date.now()` inside the renderer — so this stays a pure, testable + * transform and agrees with whatever "now" the caller used for its own live + * elapsed display (e.g. the `traces get` header). + */ + now?: string; } /** @@ -46,6 +125,8 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}): return ""; } const color = options.color === true; + const width = options.width ?? DEFAULT_WIDTH; + const now = options.now; const indexOf = new Map(); for (const [i, span] of spans.entries()) { @@ -85,8 +166,39 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}): visited.add(span.span_id); const connector = isRoot ? "" : isLast ? "└─ " : "├─ "; const text = `${prefix}${connector}${span.name} ${marker(span.status)}`; - lines.push(color && isError(span.status) ? `${ANSI_RED}${text}${ANSI_RESET}` : text); const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ "); + + // Duration, right-aligned to `width`: end time if the span has finished, + // otherwise elapsed-so-far against `now` (only when the caller supplied + // one — a still-running span with no `now` simply shows no duration + // rather than silently computing one from a fresh `Date.now()`). + const end = span.span_end_time ?? now ?? null; + const durationMs = + span.span_start_time !== undefined ? elapsedMs(span.span_start_time, end) : null; + const durationText = durationMs !== null ? formatDuration(durationMs) : null; + const lineCore = + durationText === null + ? text + : `${text}${" ".repeat(Math.max(1, width - text.length - durationText.length))}${durationText}`; + lines.push(color && isError(span.status) ? `${ANSI_RED}${lineCore}${ANSI_RESET}` : lineCore); + + // Error detail, directly beneath the span line, indented to align under + // the name (the same indent children continue at) and truncated to width. + if (isError(span.status) && span.status_message !== undefined && span.status_message !== null) { + const trimmed = span.status_message.trim(); + if (trimmed.length > 0) { + const msgText = `${childPrefix}${truncate(trimmed, Math.max(10, width - childPrefix.length))}`; + lines.push(color ? `${ANSI_RED}${msgText}${ANSI_RESET}` : msgText); + } + } + + // Compact LLM detail (model · tokens · cost), dim, on its own line. + const detail = llmDetail(span); + if (detail !== null) { + const detailText = `${childPrefix}${detail}`; + lines.push(color ? `${ANSI_DIM}${detailText}${ANSI_RESET}` : detailText); + } + const children = [...(childrenOf.get(span.span_id) ?? [])].sort(byStart); for (const [i, child] of children.entries()) { walk(child, childPrefix, i === children.length - 1, false); @@ -108,13 +220,23 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}): return lines.join("\n"); } +/** Hint appended to truncated text; budgeted into {@link truncate}'s `max`. */ +const TRUNCATION_SUFFIX = "… (truncated)"; + /** - * Returns `text` unchanged when its length is at most `max`, otherwise the first - * `max` characters followed by a truncation hint. HUMAN-render only. + * Returns `text` unchanged when its length is at most `max`, otherwise a + * truncated form whose TOTAL length (kept text plus the truncation hint) is at + * most `max`, so callers can budget it against a terminal width. When `max` is + * too small for the hint plus at least one source character, the hint is + * dropped and the text is plainly cut at `max`, preserving the length + * invariant. HUMAN-render only. */ export function truncate(text: string, max = 200): string { if (text.length <= max) { return text; } - return `${text.slice(0, max)}… (truncated)`; + if (max <= TRUNCATION_SUFFIX.length) { + return text.slice(0, max); + } + return `${text.slice(0, max - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`; } diff --git a/src/util/index.ts b/src/util/index.ts index 6acaa06..9be4006 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -68,6 +68,27 @@ export function parseBackendTime(raw: string): Date | null { return Number.isNaN(date.getTime()) ? null : date; } +/** + * Milliseconds between two backend timestamps, or null when not derivable + * (`end` is null, either side fails to parse, or the result would be + * negative). Both sides are parsed via {@link parseBackendTime} so zone-less + * backend times are read as UTC rather than host-local. Shared by the trace + * header (`traces get`) and the per-span tree renderer so their "live" / + * elapsed durations stay consistent with each other. + */ +export function elapsedMs(start: string, end: string | null): number | null { + if (end === null) { + return null; + } + const startDate = parseBackendTime(start); + const endDate = parseBackendTime(end); + if (startDate === null || endDate === null) { + return null; + } + const ms = endDate.getTime() - startDate.getTime(); + return Number.isFinite(ms) && ms >= 0 ? ms : null; +} + /** * Renders a backend timestamp in a readable, unambiguous form: local time with * an explicit timezone label (e.g. `2026-06-04 16:43:13 PDT`), since the backend diff --git a/tests/commands/traces-get.test.ts b/tests/commands/traces-get.test.ts index a54a8a6..8438c94 100644 --- a/tests/commands/traces-get.test.ts +++ b/tests/commands/traces-get.test.ts @@ -104,6 +104,68 @@ describe("runGet (human)", () => { expect(out.data).not.toContain("Output:"); }); + it("shows each span's own duration on its tree line", async () => { + const trace = detail({ + spans: [ + span({ + span_id: "root", + name: "root-span", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01.5Z", + }), + ], + }); + const { writers: w, out } = writers(); + await runGet({ client: fakeClient({ trace }), json: false, writers: w, traceId: "t-1" }); + + const spanLine = out.data.split("\n").find((l) => l.includes("root-span")) as string; + expect(spanLine).toBeDefined(); + expect(spanLine).toContain("1.5s"); + }); + + it("shows the error status_message beneath a failing span", async () => { + const trace = detail({ + spans: [ + span({ + span_id: "root", + name: "root-span", + status: "ERROR", + status_message: "downstream timeout after 30s", + }), + ], + }); + const { writers: w, out } = writers(); + await runGet({ client: fakeClient({ trace }), json: false, writers: w, traceId: "t-1" }); + + const lines = out.data.split("\n"); + const spanLineIndex = lines.findIndex((l) => l.includes("root-span")); + expect(spanLineIndex).toBeGreaterThanOrEqual(0); + expect(lines[spanLineIndex + 1]).toContain("downstream timeout after 30s"); + }); + + it("shows a compact model/token/cost detail beneath an LLM span", async () => { + const trace = detail({ + spans: [ + span({ + span_id: "root", + name: "root-span", + model_name: "claude-sonnet-4-5", + total_tokens: 1200, + cost: 0.004, + }), + ], + }); + const { writers: w, out } = writers(); + await runGet({ client: fakeClient({ trace }), json: false, writers: w, traceId: "t-1" }); + + const lines = out.data.split("\n"); + const spanLineIndex = lines.findIndex((l) => l.includes("root-span")); + expect(spanLineIndex).toBeGreaterThanOrEqual(0); + expect(lines[spanLineIndex + 1]).toContain("claude-sonnet-4-5"); + expect(lines[spanLineIndex + 1]).toContain("1.2k tok"); + expect(lines[spanLineIndex + 1]).toContain("$0.0040"); + }); + it("renders the trace_url as an OSC 8 hyperlink on a TTY", async () => { const trace = detail({}); const out = new StringSink(true); diff --git a/tests/render/tree.test.ts b/tests/render/tree.test.ts index fe2c838..47272cd 100644 --- a/tests/render/tree.test.ts +++ b/tests/render/tree.test.ts @@ -6,6 +6,7 @@ function span(partial: Partial & { span_id: string }): SpanLike { parent_span_id: null, name: partial.span_id, span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01Z", ...partial, }; } @@ -106,6 +107,237 @@ describe("renderTree", () => { }); }); +describe("renderTree durations", () => { + it("shows the formatted duration for a span with an end time", () => { + const out = renderTree([ + span({ + span_id: "r", + name: "r", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01.5Z", + }), + ]); + expect(out).toContain("1.5s"); + }); + + it("right-aligns the duration to the given width", () => { + const out = renderTree( + [ + span({ + span_id: "r", + name: "r", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01Z", + }), + ], + { width: 40 }, + ); + const line = out.split("\n")[0] as string; + expect(line.length).toBe(40); + expect(line.endsWith("1.0s")).toBe(true); + }); + + it("defaults the width to 80 when not given", () => { + const out = renderTree([ + span({ + span_id: "r", + name: "r", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01Z", + }), + ]); + expect((out.split("\n")[0] as string).length).toBe(80); + }); + + it("shows elapsed-so-far against options.now for a span with no span_end_time", () => { + const out = renderTree([ + span({ + span_id: "r", + name: "r", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: null, + }), + ]); + expect(out).not.toMatch(/\d+(\.\d+)?(ms|s)/); + + const withNow = renderTree( + [ + span({ + span_id: "r", + name: "r", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: null, + }), + ], + { now: "2024-01-01T00:00:03Z" }, + ); + expect(withNow).toContain("3.0s"); + }); + + it("never pads to less than one space between the name/marker and the duration", () => { + const out = renderTree( + [ + span({ + span_id: "r", + name: "a-very-long-span-name-that-blows-past-the-target-width-entirely", + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01Z", + }), + ], + { width: 20 }, + ); + const line = out.split("\n")[0] as string; + expect(line).toContain(" 1.0s"); + expect(line).not.toContain(" 1.0s"); + }); +}); + +describe("renderTree error status_message", () => { + it("prints the status_message on its own line beneath an errored span, in red when color is on", () => { + const out = renderTree( + [span({ span_id: "bad", status: "ERROR", status_message: "connection refused" })], + { color: true }, + ); + const lines = out.split("\n"); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain("connection refused"); + expect(lines[1]).toContain("\x1b[91m"); + }); + + it("indents the message to align under the span name", () => { + const out = renderTree([ + span({ span_id: "root", name: "root" }), + span({ + span_id: "bad", + parent_span_id: "root", + name: "bad", + status: "ERROR", + status_message: "boom", + }), + ]); + const lines = out.split("\n"); + const msgLine = lines.find((l) => l.includes("boom")) as string; + expect(msgLine).toBeDefined(); + expect(msgLine.indexOf("boom")).toBeGreaterThan(0); + }); + + it("has no ANSI codes for the message when color is off", () => { + const out = renderTree([ + span({ span_id: "bad", status: "ERROR", status_message: "connection refused" }), + ]); + expect(out).not.toContain("\x1b["); + }); + + it("omits the message line entirely when status_message is null", () => { + const out = renderTree([span({ span_id: "bad", status: "ERROR", status_message: null })]); + expect(out.split("\n")).toHaveLength(1); + }); + + it("omits the message line when status_message is absent", () => { + const out = renderTree([span({ span_id: "bad", status: "ERROR" })]); + expect(out.split("\n")).toHaveLength(1); + }); + + it("does not print a status_message for a non-error span", () => { + const out = renderTree([span({ span_id: "ok", status: "OK", status_message: "some message" })]); + expect(out.split("\n")).toHaveLength(1); + expect(out).not.toContain("some message"); + }); + + it("truncates a long status_message to the given width", () => { + const long = "x".repeat(200); + const out = renderTree([span({ span_id: "bad", status: "ERROR", status_message: long })], { + width: 40, + }); + const lines = out.split("\n"); + expect(lines[1]?.length).toBeLessThanOrEqual(40); + expect(lines[1]).toContain("truncated"); + }); + + it("keeps the full message line (prefix + text + hint) within an exact narrow width", () => { + const width = 30; + const out = renderTree( + [ + span({ span_id: "root", name: "root" }), + span({ + span_id: "bad", + parent_span_id: "root", + name: "bad", + status: "ERROR", + status_message: "y".repeat(500), + }), + ], + { width }, + ); + const msgLine = out.split("\n").find((l) => l.includes("truncated")) as string; + expect(msgLine).toBeDefined(); + // The COMPLETE rendered line — indent prefix, kept text, and the + // "… (truncated)" hint — fits within the requested terminal width. + expect(msgLine.length).toBeLessThanOrEqual(width); + }); +}); + +describe("renderTree LLM detail", () => { + it("shows model, compact token count, and cost on their own dim line", () => { + const out = renderTree( + [ + span({ + span_id: "llm", + model_name: "claude-sonnet-4-5", + total_tokens: 1234, + cost: 0.004, + }), + ], + { color: true }, + ); + const lines = out.split("\n"); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain("claude-sonnet-4-5"); + expect(lines[1]).toContain("1.2k tok"); + expect(lines[1]).toContain("$0.0040"); + expect(lines[1]).toContain("\x1b[2m"); + }); + + it("sums input_tokens and output_tokens when total_tokens is absent", () => { + const out = renderTree([ + span({ span_id: "llm", model_name: "gpt-4o", input_tokens: 100, output_tokens: 50 }), + ]); + expect(out).toContain("150 tok"); + }); + + it("prefers total_tokens over input+output when both are present", () => { + const out = renderTree([ + span({ + span_id: "llm", + model_name: "gpt-4o", + input_tokens: 100, + output_tokens: 50, + total_tokens: 999, + }), + ]); + expect(out).toContain("999 tok"); + expect(out).not.toContain("150 tok"); + }); + + it("omits tokens and cost individually when missing, still showing the model", () => { + const out = renderTree([span({ span_id: "llm", model_name: "gpt-4o" })]); + const lines = out.split("\n"); + expect(lines[1]).toBe("gpt-4o"); + }); + + it("omits the detail line entirely when model_name is absent", () => { + const out = renderTree([span({ span_id: "s", total_tokens: 100, cost: 0.01 })]); + expect(out.split("\n")).toHaveLength(1); + }); + + it("has no ANSI codes for the detail line when color is off", () => { + const out = renderTree([ + span({ span_id: "llm", model_name: "claude-sonnet-4-5", total_tokens: 1200, cost: 0.004 }), + ]); + expect(out).not.toContain("\x1b["); + }); +}); + describe("truncate", () => { it("returns text unchanged when at or below the max", () => { expect(truncate("hello", 200)).toBe("hello"); @@ -113,18 +345,46 @@ describe("truncate", () => { expect(truncate(exact, 200)).toBe(exact); }); - it("truncates to max chars and appends a hint when over the max", () => { + it("caps the TOTAL output (kept text plus hint) at max when over the max", () => { const long = "a".repeat(250); const out = truncate(long, 200); - expect(out).toContain("a".repeat(200)); - expect(out).not.toContain("a".repeat(201)); + expect(out.length).toBeLessThanOrEqual(200); expect(out).toContain("truncated"); + expect(out.startsWith("a".repeat(200 - "… (truncated)".length))).toBe(true); + expect(out).not.toContain("a".repeat(200 - "… (truncated)".length + 1)); }); it("defaults the max to 200", () => { const long = "b".repeat(201); const out = truncate(long); + expect(out.length).toBeLessThanOrEqual(200); expect(out).toContain("truncated"); - expect(out.startsWith("b".repeat(200))).toBe(true); + expect(out.startsWith("b".repeat(200 - "… (truncated)".length))).toBe(true); + }); + + it("drops the hint and plainly cuts when max cannot fit hint plus one char (max 13)", () => { + // "… (truncated)" is exactly 13 chars: no room for any kept text, so the + // hint is dropped rather than emitted alone. + const out = truncate("c".repeat(50), 13); + expect(out).toBe("c".repeat(13)); + expect(out).not.toContain("truncated"); + }); + + it("drops the hint and plainly cuts at a tiny max (max 10)", () => { + const out = truncate("c".repeat(50), 10); + expect(out).toBe("c".repeat(10)); + }); + + it("appends the hint at the smallest max that fits it plus one char (max 14)", () => { + const out = truncate("c".repeat(50), 14); + expect(out).toBe("c… (truncated)"); + expect(out.length).toBe(14); + }); + + it("never exceeds max for any small budget (sweep 1..20)", () => { + const long = "d".repeat(100); + for (let max = 1; max <= 20; max++) { + expect(truncate(long, max).length).toBeLessThanOrEqual(max); + } }); }); diff --git a/tests/util.test.ts b/tests/util.test.ts index 306f98b..c9059bf 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { CliError } from "../src/output.js"; import { + elapsedMs, formatBytes, formatTimestamp, parseBackendTime, @@ -66,6 +67,29 @@ describe("parseBackendTime", () => { }); }); +describe("elapsedMs", () => { + it("returns the millisecond difference between two zone-less UTC timestamps", () => { + expect(elapsedMs("2024-01-01T00:00:00Z", "2024-01-01T00:00:01.500Z")).toBe(1500); + }); + + it("treats zone-less start/end as UTC (matches parseBackendTime)", () => { + expect(elapsedMs("2024-01-01T00:00:00", "2024-01-01T00:00:02")).toBe(2000); + }); + + it("returns null when end is null (still running, no duration yet)", () => { + expect(elapsedMs("2024-01-01T00:00:00Z", null)).toBeNull(); + }); + + it("returns null when either side fails to parse", () => { + expect(elapsedMs("not-a-date", "2024-01-01T00:00:01Z")).toBeNull(); + expect(elapsedMs("2024-01-01T00:00:00Z", "not-a-date")).toBeNull(); + }); + + it("returns null rather than a negative duration when end precedes start", () => { + expect(elapsedMs("2024-01-01T00:00:05Z", "2024-01-01T00:00:00Z")).toBeNull(); + }); +}); + describe("formatTimestamp", () => { it("treats a zone-less backend timestamp as UTC and labels the zone", () => { expect(formatTimestamp("2026-06-04T23:43:13.590000", "UTC")).toBe("2026-06-04 23:43:13 UTC");