diff --git a/README.md b/README.md index 782e04c..5d25640 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ traceroot traces list | `login` | Authenticate and save credentials (validates before writing). | | `status` | Show the identity your credentials resolve to — workspace, project, key hint, host, source. | | `traces list` | List traces for your project, newest first. `--limit `, `--since `, `--from`/`--to` | -| `traces get ` | Show one trace: span tree, derived duration, I/O preview, and a link to open it. | +| `traces get ` | Show one trace: span tree, derived duration, I/O preview, and a link to open it. Bound large traces with `--max-spans `, `--depth `, `--errors-only`, `--output jsonl`. | | `traces export ` | Write a trace bundle (`trace.json`, `spans.json`, `git_context.json`, `manifest.json`) to a directory. `--output `, `--force` | | `detectors list` | List your project's detectors, newest first. The `DETECTOR ID` column is what you pass to `findings list --detector`. `--limit `, `--since `, `--from`/`--to` | | `findings list` | List detector findings for your project, newest first. `--limit `, `--since `, `--from`/`--to`, `--detector `, `--trace ` | @@ -76,6 +76,37 @@ traceroot findings list --detector --since 7d --json | jq '.data[] traceroot findings get --trace 99224be337d725fd5e8f2e7b45dc22ef ``` +### Bounding large traces + +An agent trace can carry thousands of spans, which is a lot to dump into a +terminal — or into another model's context. `traces get` has four opt-in, +backward-compatible flags to subset the output (they apply to both the human +tree and the JSON/JSONL bodies): + +| Flag | Effect | +| :-- | :-- | +| `--max-spans ` | Show at most `n` spans. JSON adds `spans_truncated: { shown, total }`; the human tree appends a `… N more spans` line. | +| `--depth ` | Cap tree depth (roots are depth 1); deeper spans are dropped. The human tree marks each elided subtree with `… N deeper spans hidden`. | +| `--errors-only` | Keep only error spans and their full ancestor chains, dropping unrelated branches. | +| `--output jsonl` | Stream a header line (the trace minus its spans, plus `finding`) then one span per line. Implies machine output, so it works with or without `--json`, and stays `head`-safe. | + +The flags compose — `--errors-only` is applied first, then `--depth`, then +`--max-spans` — and the truncation total always reflects the post-filter set. +Truncation selects spans in tree traversal order (the order the human tree +prints), so both views keep the same spans; JSON emits the kept spans in the +backend's original array order. + +```sh +# Just the failures and how they were reached, as JSON. +traceroot traces get --errors-only --json | jq '.spans[].name' + +# Stream spans and grep for slow model calls without buffering the whole trace. +traceroot traces get --output jsonl | grep '"model_name":"gpt-4o"' + +# Peek at the top of a huge trace: the header line, nothing more. +traceroot traces get --output jsonl | head -1 +``` + ## Skills & agents Make your coding agent TraceRoot-aware without touching your application source. The diff --git a/src/commands/traces/get.ts b/src/commands/traces/get.ts index 1a27282..d363dbe 100644 --- a/src/commands/traces/get.ts +++ b/src/commands/traces/get.ts @@ -1,10 +1,16 @@ import type { Command } from "commander"; import type { ApiClient, FindingDetail, TraceDetail } from "../../api/client.js"; -import { type Writers, colorEnabled, defaultWriters, writeJson } from "../../output.js"; +import { CliError, type Writers, colorEnabled, defaultWriters, writeJson } from "../../output.js"; import { createStyler } from "../../render/style.js"; -import { renderTree } from "../../render/tree.js"; +import { + filterErrorsWithAncestors, + renderTree, + spansWithinDepth, + treeOrder, +} from "../../render/tree.js"; import { formatDuration, formatTimestamp, parseBackendTime } from "../../util/index.js"; import { contextFromCommand, requireApiClient } from "../shared.js"; +import { onceOption } from "./list.js"; /** Max width for the single-line RCA preview shown inline in `traces get`. */ const RCA_PREVIEW_MAX = 80; @@ -31,6 +37,60 @@ export interface RunGetDeps { json: boolean; writers: Writers; traceId: string; + /** Cap emitted spans (tree + JSON). Undefined = no cap. */ + maxSpans?: number; + /** Cap tree depth, roots = 1 (tree + JSON). Undefined = no cap. */ + depth?: number; + /** Keep only error spans plus their ancestor chains. */ + errorsOnly?: boolean; + /** + * Machine output format. `"jsonl"` streams a header line (trace minus spans, + * plus `finding`) then one span per line; it implies machine output whether or + * not the global `--json` flag is set. Undefined = follow `json`. + */ + output?: "jsonl"; +} + +/** + * Applies the `traces get` span bounds in a fixed order — `--errors-only` first, + * then `--depth`, then `--max-spans` — so the human tree and the JSON/JSONL + * emitters always work from the same set. Returns the spans to emit plus the + * counts the truncation marker and empty-result indicators need. + */ +function boundSpans( + spans: Span[], + opts: { maxSpans?: number; depth?: number; errorsOnly?: boolean }, +): { + /** Errors-only working set (or all spans); what the human tree renders. */ + working: Span[]; + /** Final capped list emitted by the JSON/JSONL paths. */ + shown: Span[]; + /** Post-depth total the `--max-spans` marker reports against. */ + depthTotal: number; + /** True when `--errors-only` matched nothing. */ + errorsOnlyEmpty: boolean; +} { + const working = opts.errorsOnly === true ? filterErrorsWithAncestors(spans) : spans; + const depthFiltered = opts.depth !== undefined ? spansWithinDepth(working, opts.depth) : working; + // `--max-spans` SELECTS the first n spans in tree traversal order — the same + // order the human tree prints, via the shared `treeOrder` — so both modes keep + // the SAME spans. The kept spans are then EMITTED in their original backend + // array order, keeping the JSON/JSONL body array-stable. + let shown = depthFiltered; + if (opts.maxSpans !== undefined && opts.maxSpans < depthFiltered.length) { + const keep = new Set( + treeOrder(depthFiltered) + .slice(0, opts.maxSpans) + .map((s) => s.span_id), + ); + shown = depthFiltered.filter((s) => keep.has(s.span_id)); + } + return { + working, + shown, + depthTotal: depthFiltered.length, + errorsOnlyEmpty: opts.errorsOnly === true && working.length === 0, + }; } type Span = TraceDetail["spans"][number]; @@ -70,7 +130,7 @@ function elapsedMs(start: string, end: string | null): number | 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; + const { client, json, writers, traceId, maxSpans, depth, errorsOnly, output } = deps; const trace = await client.getTrace(traceId); // Best-effort: surface the detector finding for this trace (findings are @@ -83,9 +143,38 @@ export async function runGet(deps: RunGetDeps): Promise { finding = null; } + // Apply the bounding flags ONCE; every emit path reads from this result so the + // human tree and the JSON/JSONL bodies can never diverge. + const { working, shown, depthTotal, errorsOnlyEmpty } = boundSpans(trace.spans, { + maxSpans, + depth, + errorsOnly, + }); + // Only `--max-spans` advertises a truncation marker (with the true post-filter + // total); `--depth` simply omits deeper spans. Present only when it fires, so + // default output is byte-identical to before. + const truncated = shown.length < depthTotal; + const marker = truncated ? { spans_truncated: { shown: shown.length, total: depthTotal } } : {}; + // `--errors-only` matching nothing is surfaced explicitly rather than as a + // silent empty span list. + const emptyMarker = errorsOnlyEmpty ? { errors_only_no_matches: true } : {}; + + if (output === "jsonl") { + // Header first (all trace fields except spans, plus finding + any markers), + // then one span per line, written incrementally so a downstream `head`/`grep` + // can stop us early (the global EPIPE handler makes that a clean exit). + const { spans: _spans, ...header } = trace; + writeJson({ ...header, finding, ...marker, ...emptyMarker }, writers); + for (const span of shown) { + writeJson(span, writers); + } + return; + } + if (json) { - // FULL untruncated trace, plus the finding (or null) so scripts get it in one call. - writeJson({ ...trace, finding }, writers); + // The full trace with its spans bounded by the flags, plus the finding (or + // null) so scripts get everything in one call. + writeJson({ ...trace, spans: shown, finding, ...marker, ...emptyMarker }, writers); return; } @@ -135,7 +224,13 @@ export async function runGet(deps: RunGetDeps): Promise { } lines.push(""); lines.push(label("Spans:")); - lines.push(renderTree(trace.spans, { color: colorEnabled(writers.out) })); + if (errorsOnlyEmpty) { + lines.push(" (no error spans in this trace)"); + } else { + lines.push( + renderTree(working, { color: colorEnabled(writers.out), maxDepth: depth, maxSpans }), + ); + } if (live) { // Indicate the tree is incomplete — more spans are still arriving. lines.push(" *** (live — more spans incoming)"); @@ -149,14 +244,66 @@ export async function runGet(deps: RunGetDeps): Promise { writers.out.write(`${lines.join("\n")}\n`); } +/** + * Coercion for a positive-integer option that also rejects a repeated flag. + * Modeled on {@link onceOption} + `parseLimit`, but returns a validated `number`. + */ +function positiveIntOnce(flag: string): (val: string, prev: number | undefined) => number { + return (val: string, prev: number | undefined): number => { + if (prev !== undefined) { + throw new CliError(`${flag} may only be given once`); + } + if (!/^\d+$/.test(val)) { + throw new CliError(`${flag} must be a positive integer`); + } + const value = Number.parseInt(val, 10); + if (!Number.isInteger(value) || value < 1) { + throw new CliError(`${flag} must be a positive integer`); + } + return value; + }; +} + export function registerTracesGet(traces: Command): void { traces .command("get") .argument("", "trace identifier") .description("Get a single trace") + .option( + "--max-spans ", + "cap the number of spans shown (tree and JSON); adds a truncation marker", + positiveIntOnce("--max-spans"), + ) + .option( + "--depth ", + "cap tree depth (roots = 1); deeper spans are hidden in both tree and JSON", + positiveIntOnce("--depth"), + ) + .option("--errors-only", "show only error spans and their ancestor chains") + .option( + "--output ", + "machine output format: 'jsonl' streams a header line then one span per line (implies JSON)", + onceOption("--output"), + ) .action(async (traceId: string, _opts, command: Command) => { + const opts = command.opts(); + // `--output` accepts only `jsonl`; reject anything else as a usage error + // before any network work. + const output = opts.output as string | undefined; + if (output !== undefined && output !== "jsonl") { + throw new CliError(`--output must be 'jsonl' (got '${output}')`); + } const ctx = contextFromCommand(command); const client = requireApiClient(ctx); - await runGet({ client, json: ctx.json, writers: defaultWriters, traceId }); + await runGet({ + client, + json: ctx.json, + writers: defaultWriters, + traceId, + maxSpans: opts.maxSpans as number | undefined, + depth: opts.depth as number | undefined, + errorsOnly: opts.errorsOnly as boolean | undefined, + output: output === "jsonl" ? "jsonl" : undefined, + }); }); } diff --git a/src/render/tree.ts b/src/render/tree.ts index b66e18e..082557e 100644 --- a/src/render/tree.ts +++ b/src/render/tree.ts @@ -18,42 +18,47 @@ const ERROR_STATUSES = new Set(["ERROR", "error", "STATUS_CODE_ERROR"]); const ANSI_RESET = "\x1b[0m"; const ANSI_RED = "\x1b[91m"; -function isError(status: string | undefined): boolean { +/** + * Whether a span's `status` denotes an error. The single source of truth for + * error classification, shared by the renderer and the `--errors-only` filter so + * the two can't drift apart. + */ +export function isErrorStatus(status: string | undefined): boolean { return status !== undefined && ERROR_STATUSES.has(status); } function marker(status: string | undefined): string { - return isError(status) ? "[error]" : "[ok]"; + return isErrorStatus(status) ? "[error]" : "[ok]"; } -function sortKey(span: SpanLike, index: number): [string, number] { +function sortKey(span: { span_start_time?: string }, index: number): [string, number] { return [span.span_start_time ?? "", index]; } -/** Optional behavior for {@link renderTree}. */ -export interface RenderTreeOptions { - /** When true, error span lines are colored red. Off for non-TTY / `NO_COLOR`. */ - color?: boolean; +/** Minimal structural fields the tree-building helpers need. */ +type TreeSpan = { span_id: string; parent_span_id: string | null; span_start_time?: string }; + +/** The parent/child structure every tree consumer must agree on. */ +interface TreeIndex { + /** Spans whose parent is null or absent from the set (orphans). */ + roots: T[]; + childrenOf: Map; + /** Sibling comparator: start time, falling back to input order for stability. */ + byStart: (a: T, b: T) => number; } /** - * Renders spans as an indented ASCII tree built from `parent_span_id`. Roots are - * spans whose parent is null or absent from the set (orphans). Siblings are - * ordered by start time, falling back to input order for stability. + * Builds the parent/child index used by {@link renderTree}, {@link treeOrder} + * and the depth computation. One shared construction (roots, children, sibling + * order) so the human tree and the flat-array filters cannot drift apart. */ -export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}): string { - if (spans.length === 0) { - return ""; - } - const color = options.color === true; - +function buildTree(spans: T[]): TreeIndex { const indexOf = new Map(); for (const [i, span] of spans.entries()) { indexOf.set(span.span_id, i); } - - const childrenOf = new Map(); - const roots: SpanLike[] = []; + const childrenOf = new Map(); + const roots: T[] = []; for (const span of spans) { const parent = span.parent_span_id; if (parent !== null && indexOf.has(parent)) { @@ -64,50 +69,266 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}): roots.push(span); } } - - const byStart = (a: SpanLike, b: SpanLike): number => { + const byStart = (a: T, b: T): number => { const [ak, ai] = sortKey(a, indexOf.get(a.span_id) ?? 0); const [bk, bi] = sortKey(b, indexOf.get(b.span_id) ?? 0); if (ak < bk) return -1; if (ak > bk) return 1; return ai - bi; }; + return { roots, childrenOf, byStart }; +} - const lines: string[] = []; +/** Optional behavior for {@link renderTree}. */ +export interface RenderTreeOptions { + /** When true, error span lines are colored red. Off for non-TTY / `NO_COLOR`. */ + color?: boolean; + /** + * Cap the rendered tree depth (roots are depth 1). Spans deeper than this are + * not printed; a `… N deeper span(s) hidden` marker is appended under the + * deepest visible ancestor whose children were dropped. Undefined = no cap. + */ + maxDepth?: number; + /** + * Cap the number of span lines emitted. Once reached, rendering stops and a + * single `… N more span(s)` elision line is appended, where N is the true + * remainder (post depth-cap). Undefined = no cap. + */ + maxSpans?: number; +} + +/** + * Renders spans as an indented ASCII tree built from `parent_span_id`. Roots are + * spans whose parent is null or absent from the set (orphans). Siblings are + * ordered by start time, falling back to input order for stability. + */ +export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}): string { + if (spans.length === 0) { + return ""; + } + const color = options.color === true; + const maxDepth = options.maxDepth; + const maxSpans = options.maxSpans; + + const { roots, childrenOf, byStart } = buildTree(spans); + + // Count the spans hidden behind a depth-elision marker: the subtree(s) under + // `children`, EXCLUDING anything already rendered (`visitedSet`) — an + // already-displayed span (e.g. the shown side of a parent cycle) is not + // hidden, so it must not be counted. Each genuinely hidden span is added to + // `visitedSet` so the orphan-recovery pass below does not re-render it as a + // stray root; the same set breaks parent cycles. + const subtreeSpanCount = (children: SpanLike[], visitedSet: Set): number => { + let count = 0; + const stack = [...children]; + while (stack.length > 0) { + const s = stack.pop() as SpanLike; + if (visitedSet.has(s.span_id)) { + continue; + } + visitedSet.add(s.span_id); + count += 1; + for (const c of childrenOf.get(s.span_id) ?? []) { + stack.push(c); + } + } + return count; + }; + + // Each entry is one output line, tagged so the `--max-spans` cap can count + // span lines while still emitting the depth markers that precede the cut. + interface Entry { + text: string; + isSpan: boolean; + } + const entries: Entry[] = []; // Guards against re-visiting a span: prevents infinite recursion and silent // duplication if the external span data contains a parent cycle. const visited = new Set(); - const walk = (span: SpanLike, prefix: string, isLast: boolean, isRoot: boolean): void => { + const walk = ( + span: SpanLike, + prefix: string, + isLast: boolean, + isRoot: boolean, + depth: number, + ): void => { if (visited.has(span.span_id)) { return; } 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); + entries.push({ + text: color && isErrorStatus(span.status) ? `${ANSI_RED}${text}${ANSI_RESET}` : text, + isSpan: true, + }); const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ "); const children = [...(childrenOf.get(span.span_id) ?? [])].sort(byStart); + if (children.length === 0) { + return; + } + // Depth cap: at the limit, hide this span's whole subtree behind one marker + // rendered as a final pseudo-child, rather than recursing further. When the + // "subtree" holds nothing new (every member already rendered, e.g. a cycle + // back to a shown span), there is nothing hidden and no marker to print. + if (maxDepth !== undefined && depth >= maxDepth) { + const hidden = subtreeSpanCount(children, visited); + if (hidden > 0) { + entries.push({ + text: `${childPrefix}└─ … ${hidden} deeper span${hidden === 1 ? "" : "s"} hidden`, + isSpan: false, + }); + } + return; + } for (const [i, child] of children.entries()) { - walk(child, childPrefix, i === children.length - 1, false); + walk(child, childPrefix, i === children.length - 1, false, depth + 1); } }; const sortedRoots = [...roots].sort(byStart); for (const root of sortedRoots) { - walk(root, "", true, true); + walk(root, "", true, true, 1); } // Recover any spans never reached from a root (e.g. members of a parent cycle // in malformed data) as additional roots, so nothing is silently dropped. for (const span of spans) { if (!visited.has(span.span_id)) { - walk(span, "", true, true); + walk(span, "", true, true, 1); } } + const spanTotal = entries.reduce((n, e) => n + (e.isSpan ? 1 : 0), 0); + const cap = maxSpans ?? Number.POSITIVE_INFINITY; + const lines: string[] = []; + let shown = 0; + for (const entry of entries) { + // Only SPAN lines count toward (and stop at) the cap. A depth-elision + // pseudo-line belonging to the last displayed span must still get through — + // otherwise stacking --depth with --max-spans would silently drop the only + // hint that the span's descendants were elided. + if (entry.isSpan && shown >= cap) { + break; + } + lines.push(entry.text); + if (entry.isSpan) { + shown += 1; + } + } + if (spanTotal > cap) { + // `shown` equals the cap here (we filled to it), so this is the true remainder. + const remaining = spanTotal - shown; + lines.push(`… ${remaining} more span${remaining === 1 ? "" : "s"}`); + } + return lines.join("\n"); } +/** + * Depth of each span keyed by `span_id`, where a root/orphan is depth 1 and a + * child is one deeper than its parent. Computed by traversal from the roots + * over the SAME {@link buildTree} structure {@link renderTree} walks, so the + * flat-array depth filter and the tree renderer agree on what "depth" means. + * Spans unreachable from any real root (parent cycles in malformed data) are + * recovered as depth-1 roots in input order — and traversal CONTINUES into + * their still-unassigned descendants — exactly mirroring the renderer's + * recovery walk (e.g. in an x ↔ y cycle, x is depth 1 and y is depth 2). + */ +function computeDepths(spans: T[]): Map { + const { roots, childrenOf } = buildTree(spans); + const depthOf = new Map(); + const assignFrom = (start: T): void => { + const queue: Array<[T, number]> = [[start, 1]]; + for (let i = 0; i < queue.length; i++) { + const [span, depth] = queue[i] as [T, number]; + if (depthOf.has(span.span_id)) { + continue; + } + depthOf.set(span.span_id, depth); + for (const child of childrenOf.get(span.span_id) ?? []) { + queue.push([child, depth + 1]); + } + } + }; + for (const root of roots) { + assignFrom(root); + } + for (const span of spans) { + if (!depthOf.has(span.span_id)) { + assignFrom(span); + } + } + return depthOf; +} + +/** + * The spans in the renderer's traversal order: sorted roots depth-first (with + * the same sibling ordering, cycle guard, and orphan-recovery pass), exactly + * the sequence {@link renderTree} prints span lines in. The shared source of + * truth for "which spans come first", so a span-count cap selects the SAME + * spans in the human tree and in the JSON/JSONL emitters. + */ +export function treeOrder(spans: T[]): T[] { + const { roots, childrenOf, byStart } = buildTree(spans); + const out: T[] = []; + const visited = new Set(); + const visit = (span: T): void => { + if (visited.has(span.span_id)) { + return; + } + visited.add(span.span_id); + out.push(span); + const children = [...(childrenOf.get(span.span_id) ?? [])].sort(byStart); + for (const child of children) { + visit(child); + } + }; + for (const root of [...roots].sort(byStart)) { + visit(root); + } + for (const span of spans) { + if (!visited.has(span.span_id)) { + visit(span); + } + } + return out; +} + +/** + * Returns the spans whose depth (roots = 1) is at most `maxDepth`, preserving + * input order. The flat-array counterpart of {@link renderTree}'s depth cap. + */ +export function spansWithinDepth(spans: T[], maxDepth: number): T[] { + const depthOf = computeDepths(spans); + return spans.filter((s) => (depthOf.get(s.span_id) ?? 1) <= maxDepth); +} + +/** + * Keeps every error-status span plus its full ancestor chain (via + * `parent_span_id`), dropping unrelated spans. Input order is preserved. A + * per-span `seen` set guards against parent cycles in malformed data. + */ +export function filterErrorsWithAncestors< + T extends { span_id: string; parent_span_id: string | null; status?: string }, +>(spans: T[]): T[] { + const byId = new Map(spans.map((s) => [s.span_id, s])); + const keep = new Set(); + for (const span of spans) { + if (!isErrorStatus(span.status)) { + continue; + } + let cur: T | undefined = span; + const seen = new Set(); + while (cur !== undefined && !seen.has(cur.span_id)) { + seen.add(cur.span_id); + keep.add(cur.span_id); + cur = cur.parent_span_id !== null ? byId.get(cur.parent_span_id) : undefined; + } + } + return spans.filter((s) => keep.has(s.span_id)); +} + /** * Returns `text` unchanged when its length is at most `max`, otherwise the first * `max` characters followed by a truncation hint. HUMAN-render only. diff --git a/tests/commands/traces-get.test.ts b/tests/commands/traces-get.test.ts index a54a8a6..96b45d6 100644 --- a/tests/commands/traces-get.test.ts +++ b/tests/commands/traces-get.test.ts @@ -352,6 +352,342 @@ describe("runGet (finding indicator)", () => { }); }); +/** A trace with a 4-span linear chain: root → a → b → c, all OK. */ +function chainTrace(): TraceDetail { + return detail({ + spans: [ + span({ span_id: "root", name: "root", parent_span_id: null }), + span({ span_id: "a", name: "a", parent_span_id: "root" }), + span({ span_id: "b", name: "b", parent_span_id: "a" }), + span({ span_id: "c", name: "c", parent_span_id: "b" }), + ], + }); +} + +describe("runGet --max-spans", () => { + it("caps the JSON spans array and records the true total", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + maxSpans: 2, + }); + const parsed = JSON.parse(out.data.trim()); + expect(parsed.spans).toHaveLength(2); + expect(parsed.spans_truncated).toEqual({ shown: 2, total: 4 }); + }); + + it("caps the human tree and appends an elision line with the true remainder", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + maxSpans: 2, + }); + expect(out.data).toContain("… 2 more spans"); + }); + + it("adds no marker when the cap is not exceeded", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + maxSpans: 10, + }); + const parsed = JSON.parse(out.data.trim()); + expect(parsed.spans).toHaveLength(4); + expect(parsed.spans_truncated).toBeUndefined(); + }); +}); + +describe("runGet --depth", () => { + it("filters deep spans out of the JSON array", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + depth: 2, + }); + const parsed = JSON.parse(out.data.trim()); + expect(parsed.spans.map((s: { span_id: string }) => s.span_id)).toEqual(["root", "a"]); + }); + + it("emits a depth elision marker in the human tree", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + depth: 2, + }); + expect(out.data).toContain("deeper span"); + expect(out.data).not.toContain("c [ok]"); + }); +}); + +describe("runGet --errors-only", () => { + /** root → a → err(ERROR), plus an unrelated OK branch root → other. */ + function errorTrace(): TraceDetail { + return detail({ + spans: [ + span({ span_id: "root", name: "root", parent_span_id: null, status: "OK" }), + span({ span_id: "a", name: "a", parent_span_id: "root", status: "OK" }), + span({ span_id: "err", name: "err", parent_span_id: "a", status: "ERROR" }), + span({ span_id: "other", name: "other", parent_span_id: "root", status: "OK" }), + ], + }); + } + + it("keeps error spans and their ancestors, dropping unrelated branches (JSON)", async () => { + const trace = errorTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + errorsOnly: true, + }); + const parsed = JSON.parse(out.data.trim()); + expect(parsed.spans.map((s: { span_id: string }) => s.span_id)).toEqual(["root", "a", "err"]); + }); + + it("drops the unrelated branch from the human tree", async () => { + const trace = errorTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + errorsOnly: true, + }); + expect(out.data).toContain("err"); + expect(out.data).not.toContain("other"); + }); + + it("indicates explicitly when no error spans match (JSON)", async () => { + const trace = chainTrace(); // all OK + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + errorsOnly: true, + }); + const parsed = JSON.parse(out.data.trim()); + expect(parsed.spans).toEqual([]); + expect(parsed.errors_only_no_matches).toBe(true); + }); + + it("indicates explicitly when no error spans match (human)", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + errorsOnly: true, + }); + expect(out.data).toContain("no error spans"); + }); +}); + +describe("runGet --output jsonl", () => { + it("emits a header line then one JSON-parseable line per span; header excludes spans", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + output: "jsonl", + }); + const lines = out.data.trim().split("\n"); + expect(lines).toHaveLength(5); // 1 header + 4 spans + const header = JSON.parse(lines[0] as string); + expect(header.spans).toBeUndefined(); + expect(header.trace_id).toBe("t-1"); + expect(header.finding).toBeNull(); + for (const line of lines.slice(1)) { + expect(() => JSON.parse(line)).not.toThrow(); + } + expect(JSON.parse(lines[1] as string).span_id).toBe("root"); + }); + + it("works without the global --json flag (jsonl implies machine output)", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + output: "jsonl", + }); + // Machine output only: no human tree connectors / labels. + expect(out.data).not.toContain("Trace:"); + expect(out.data).not.toContain("[ok]"); + }); + + it("carries the truncation marker on the header when capped", async () => { + const trace = chainTrace(); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: false, + writers: w, + traceId: "t-1", + output: "jsonl", + maxSpans: 2, + }); + const lines = out.data.trim().split("\n"); + expect(lines).toHaveLength(3); // header + 2 spans + const header = JSON.parse(lines[0] as string); + expect(header.spans_truncated).toEqual({ shown: 2, total: 4 }); + }); +}); + +describe("runGet --max-spans selection consistency", () => { + // Reviewer repro: sibling B appears BEFORE A in the backend array but A + // starts earlier, so tree order (root, A, B) differs from array order + // (root, B, A). Both modes must keep the SAME spans. + function siblingTrace(): TraceDetail { + return detail({ + spans: [ + span({ + span_id: "root", + name: "root", + parent_span_id: null, + span_start_time: "2024-01-01T00:00:00Z", + }), + span({ + span_id: "B", + name: "B", + parent_span_id: "root", + span_start_time: "2024-01-01T00:00:02Z", + }), + span({ + span_id: "A", + name: "A", + parent_span_id: "root", + span_start_time: "2024-01-01T00:00:01Z", + }), + ], + }); + } + + it("keeps the same spans in human and JSON (tree-order selection)", async () => { + const human = writers(); + await runGet({ + client: fakeClient({ trace: siblingTrace() }), + json: false, + writers: human.writers, + traceId: "t-1", + maxSpans: 2, + }); + const machine = writers(); + await runGet({ + client: fakeClient({ trace: siblingTrace() }), + json: true, + writers: machine.writers, + traceId: "t-1", + maxSpans: 2, + }); + const parsed = JSON.parse(machine.out.data.trim()); + // Tree order is root → A (earlier start) → B, so the cap keeps root and A + // in BOTH modes; B is the elided span everywhere. + expect(parsed.spans.map((s: { span_id: string }) => s.span_id).sort()).toEqual(["A", "root"]); + expect(human.out.data).toContain("A [ok]"); + expect(human.out.data).not.toContain("B [ok]"); + expect(parsed.spans_truncated).toEqual({ shown: 2, total: 3 }); + }); + + it("emits the kept spans in the original backend array order", async () => { + // Child c1 precedes its parent in the array; both survive the cap, so the + // JSON must keep the array order [c1, root], not tree order [root, c1]. + const trace = detail({ + spans: [ + span({ + span_id: "c1", + name: "c1", + parent_span_id: "root", + span_start_time: "2024-01-01T00:00:01Z", + }), + span({ + span_id: "root", + name: "root", + parent_span_id: null, + span_start_time: "2024-01-01T00:00:00Z", + }), + span({ + span_id: "c2", + name: "c2", + parent_span_id: "root", + span_start_time: "2024-01-01T00:00:02Z", + }), + ], + }); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + maxSpans: 2, + }); + const parsed = JSON.parse(out.data.trim()); + // Keep-set from tree order = {root, c1}; emitted array-stable as [c1, root]. + expect(parsed.spans.map((s: { span_id: string }) => s.span_id)).toEqual(["c1", "root"]); + expect(parsed.spans_truncated).toEqual({ shown: 2, total: 3 }); + }); +}); + +describe("runGet (flags compose)", () => { + it("applies --errors-only before --max-spans; total reflects the post-filter set", async () => { + // root → a → err(ERROR); a second error branch root → b → err2(ERROR). + const trace = detail({ + spans: [ + span({ span_id: "root", name: "root", parent_span_id: null, status: "OK" }), + span({ span_id: "a", name: "a", parent_span_id: "root", status: "OK" }), + span({ span_id: "err", name: "err", parent_span_id: "a", status: "ERROR" }), + span({ span_id: "b", name: "b", parent_span_id: "root", status: "OK" }), + span({ span_id: "err2", name: "err2", parent_span_id: "b", status: "ERROR" }), + ], + }); + const { writers: w, out } = writers(); + await runGet({ + client: fakeClient({ trace }), + json: true, + writers: w, + traceId: "t-1", + errorsOnly: true, + maxSpans: 2, + }); + const parsed = JSON.parse(out.data.trim()); + // All 5 spans are on an error path, so the post-filter total is 5, capped to 2. + expect(parsed.spans).toHaveLength(2); + expect(parsed.spans_truncated).toEqual({ shown: 2, total: 5 }); + }); +}); + describe("runGet (errors)", () => { it("rejects and writes nothing to stdout on an unknown id", async () => { const { writers: w, out } = writers(); diff --git a/tests/helpers/traceServer.mjs b/tests/helpers/traceServer.mjs new file mode 100644 index 0000000..938f6c4 --- /dev/null +++ b/tests/helpers/traceServer.mjs @@ -0,0 +1,67 @@ +// A throwaway localhost stand-in for the TraceRoot public API, run as its OWN +// process so the contract tests can drive the real binary via the synchronous +// `spawnSync` (an in-process server can't respond while spawnSync blocks the +// shared event loop). Serves one trace detail with a configurable span count and +// 404s the finding lookup (→ finding: null). Prints `PORT ` once listening. +import { createServer } from "node:http"; + +const spanCount = Number.parseInt(process.env.SPAN_COUNT ?? "2000", 10); + +function makeTrace(traceId) { + const spans = Array.from({ length: spanCount }, (_, i) => ({ + span_id: `s-${i}`, + trace_id: traceId, + parent_span_id: i === 0 ? null : `s-${i - 1}`, + name: `span-${i}`, + span_kind: "INTERNAL", + status: "OK", + status_message: null, + span_start_time: "2024-01-01T00:00:00Z", + span_end_time: "2024-01-01T00:00:01Z", + input: null, + output: null, + metadata: null, + model_name: null, + input_tokens: null, + output_tokens: null, + total_tokens: null, + cost_details: {}, + usage_details: {}, + })); + return { + trace_id: traceId, + project_id: "p-1", + name: "contract trace", + trace_start_time: "2024-01-01T00:00:00Z", + trace_url: "https://app.example.com/trace/contract", + session_id: null, + user_id: null, + input: null, + output: null, + metadata: null, + git_repo: null, + git_ref: null, + spans, + }; +} + +const server = createServer((req, res) => { + const url = req.url ?? ""; + if (/\/finding$/.test(url)) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: "not flagged" })); + return; + } + const match = /\/api\/v1\/public\/traces\/([^/?]+)/.exec(url); + if (match) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(makeTrace(decodeURIComponent(match[1])))); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ detail: "unknown" })); +}); + +server.listen(0, "127.0.0.1", () => { + process.stdout.write(`PORT ${server.address().port}\n`); +}); diff --git a/tests/output.contract.test.ts b/tests/output.contract.test.ts index 9505f5f..49652ae 100644 --- a/tests/output.contract.test.ts +++ b/tests/output.contract.test.ts @@ -1,8 +1,8 @@ -import { spawnSync } from "node:child_process"; +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; const binPath = fileURLToPath(new URL("../bin/traceroot.mjs", import.meta.url)); @@ -45,3 +45,81 @@ describe("output contract (spawned failing command)", () => { expect(stderr).not.toContain("\x1b["); }); }); + +// These tests drive the REAL spawned binary against a throwaway localhost API so +// `traces get` runs end-to-end without a network. The stub API runs as its OWN +// process (helpers/traceServer.mjs): the tests block the event loop on the +// synchronous `spawnSync`, so an in-process server could never answer. +describe("output contract (traces get against a local server)", () => { + const SPAN_COUNT = 2000; + const serverPath = fileURLToPath(new URL("./helpers/traceServer.mjs", import.meta.url)); + let server: ChildProcess; + let host: string; + + beforeAll(async () => { + server = spawn(process.execPath, [serverPath], { + env: { ...process.env, SPAN_COUNT: String(SPAN_COUNT) }, + stdio: ["ignore", "pipe", "inherit"], + }); + host = await new Promise((resolve, reject) => { + let buf = ""; + server.stdout?.on("data", (chunk: Buffer) => { + buf += chunk.toString(); + const match = /PORT (\d+)/.exec(buf); + if (match) { + resolve(`http://127.0.0.1:${match[1]}`); + } + }); + server.on("error", reject); + setTimeout(() => reject(new Error("traceServer did not report a port in time")), 10_000); + }); + }); + + afterAll(() => { + server.kill(); + }); + + function get(...args: string[]): ReturnType { + return runIsolated("--host", host, "--api-key", "test-key", "traces", "get", ...args); + } + + it("emits jsonl: a header line then one JSON-parseable span per line", () => { + const { stdout, status } = get("tr-1", "--output", "jsonl"); + expect(status).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines).toHaveLength(SPAN_COUNT + 1); // header + one per span + // Every line is independently valid JSON. + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + const header = JSON.parse(lines[0] as string); + expect(header.spans).toBeUndefined(); // header excludes the spans array + expect(header.trace_id).toBe("tr-1"); + expect(JSON.parse(lines[1] as string).span_id).toBe("s-0"); + }); + + it("surfaces the true total in the truncation marker under --json --max-spans", () => { + const { stdout, status } = get("tr-1", "--json", "--max-spans", "5"); + expect(status).toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.spans).toHaveLength(5); + expect(parsed.spans_truncated).toEqual({ shown: 5, total: SPAN_COUNT }); + }); + + it("stays head-safe: `traces get --output jsonl | head -1` exits cleanly with no stack trace", () => { + // A large trace keeps the CLI writing while `head` closes the pipe, forcing + // the EPIPE path. The global handler must turn that into a quiet exit. + const result = spawnSync( + "/bin/sh", + [ + "-c", + `'${process.execPath}' '${binPath}' --host '${host}' --api-key test-key traces get tr-1 --output jsonl | head -1`, + ], + { encoding: "utf8", env: isolatedEnv }, + ); + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toHaveLength(1); + expect(result.stderr).not.toMatch(/EPIPE|at .*\(/); // no Node stack trace + expect(JSON.parse(result.stdout.trim()).trace_id).toBe("tr-1"); + }); +}); diff --git a/tests/render/tree.test.ts b/tests/render/tree.test.ts index fe2c838..a2f5e17 100644 --- a/tests/render/tree.test.ts +++ b/tests/render/tree.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { type SpanLike, renderTree, truncate } from "../../src/render/tree.js"; +import { + type SpanLike, + filterErrorsWithAncestors, + isErrorStatus, + renderTree, + spansWithinDepth, + treeOrder, + truncate, +} from "../../src/render/tree.js"; function span(partial: Partial & { span_id: string }): SpanLike { return { @@ -106,6 +114,239 @@ describe("renderTree", () => { }); }); +describe("renderTree --depth cap", () => { + const nested = [ + span({ span_id: "root", name: "root" }), + span({ span_id: "mid", parent_span_id: "root", name: "mid" }), + span({ span_id: "leaf", parent_span_id: "mid", name: "leaf" }), + span({ span_id: "leaf2", parent_span_id: "mid", name: "leaf2" }), + ]; + + it("hides spans below the depth limit and marks the count", () => { + // depth 2: root (1) and mid (2) show; leaf/leaf2 (3) are hidden behind one marker. + const out = renderTree(nested, { maxDepth: 2 }); + expect(out).toContain("root"); + expect(out).toContain("mid"); + expect(out).not.toContain("leaf"); + expect(out).toContain("… 2 deeper spans hidden"); + }); + + it("singularizes the marker for a single hidden span", () => { + const out = renderTree( + [ + span({ span_id: "root", name: "root" }), + span({ span_id: "only", parent_span_id: "root", name: "only" }), + ], + { maxDepth: 1 }, + ); + expect(out).toContain("… 1 deeper span hidden"); + expect(out).not.toContain("deeper spans"); + }); + + it("emits no depth marker when nothing is hidden", () => { + const out = renderTree(nested, { maxDepth: 5 }); + expect(out).not.toContain("hidden"); + expect(out).toContain("leaf2"); + }); +}); + +describe("renderTree --depth stacked with --max-spans", () => { + it("keeps the depth marker of the span sitting exactly at the span cap", () => { + // root (d1) → a (d2, has hidden child a1 at d3) and b (d2, later start). + // With --depth 2 --max-spans 2, `a` is the LAST displayed span and its + // depth marker must still render before the overall cap marker. + const spans = [ + span({ span_id: "root", name: "root", span_start_time: "2024-01-01T00:00:00Z" }), + span({ + span_id: "a", + parent_span_id: "root", + name: "a", + span_start_time: "2024-01-01T00:00:01Z", + }), + span({ + span_id: "a1", + parent_span_id: "a", + name: "a1", + span_start_time: "2024-01-01T00:00:02Z", + }), + span({ + span_id: "b", + parent_span_id: "root", + name: "b", + span_start_time: "2024-01-01T00:00:03Z", + }), + ]; + const out = renderTree(spans, { maxDepth: 2, maxSpans: 2 }); + const lines = out.split("\n"); + // Both markers, with correct counts: a1 elided by depth, b elided by the cap. + expect(out).toContain("… 1 deeper span hidden"); + expect(lines[lines.length - 1]).toBe("… 1 more span"); + // Exactly: root, a, a's depth marker, then the cap marker. + expect(lines).toHaveLength(4); + expect(out).not.toContain("b [ok]"); + expect(out).not.toContain("a1 [ok]"); + }); +}); + +describe("renderTree --max-spans cap", () => { + const spans = [ + span({ span_id: "a", name: "a" }), + span({ span_id: "b", name: "b" }), + span({ span_id: "c", name: "c" }), + span({ span_id: "d", name: "d" }), + ]; + + it("stops after N spans and appends the true remainder", () => { + const out = renderTree(spans, { maxSpans: 2 }); + const lines = out.split("\n"); + // 2 span lines + 1 elision line. + expect(lines).toHaveLength(3); + expect(out).toContain("… 2 more spans"); + }); + + it("singularizes the elision line for one remaining span", () => { + const out = renderTree(spans, { maxSpans: 3 }); + expect(out).toContain("… 1 more span"); + expect(out).not.toContain("more spans"); + }); + + it("emits no elision line when the cap is not reached", () => { + const out = renderTree(spans, { maxSpans: 10 }); + expect(out).not.toContain("more span"); + }); +}); + +describe("spansWithinDepth", () => { + const spans = [ + span({ span_id: "root", name: "root" }), + span({ span_id: "mid", parent_span_id: "root", name: "mid" }), + span({ span_id: "leaf", parent_span_id: "mid", name: "leaf" }), + ]; + + it("keeps only spans at or above the depth limit (roots = 1)", () => { + expect(spansWithinDepth(spans, 1).map((s) => s.span_id)).toEqual(["root"]); + expect(spansWithinDepth(spans, 2).map((s) => s.span_id)).toEqual(["root", "mid"]); + expect(spansWithinDepth(spans, 3).map((s) => s.span_id)).toEqual(["root", "mid", "leaf"]); + }); + + it("recovers a parent cycle exactly like the renderer: first member root, rest deeper", () => { + const cyclic = [ + span({ span_id: "x", parent_span_id: "y", name: "x" }), + span({ span_id: "y", parent_span_id: "x", name: "y" }), + ]; + // Mirrors renderTree's recovery walk: x (first in input order) becomes the + // recovered root at depth 1 and y hangs under it at depth 2. + expect(spansWithinDepth(cyclic, 1).map((s) => s.span_id)).toEqual(["x"]); + expect(spansWithinDepth(cyclic, 2).map((s) => s.span_id)).toEqual(["x", "y"]); + }); +}); + +describe("renderTree and spansWithinDepth agree on malformed data", () => { + // Adversarial graph: a real chain root → a plus a detached 2-cycle x ↔ y. + const adversarial = [ + span({ span_id: "root", name: "root" }), + span({ span_id: "a", parent_span_id: "root", name: "a" }), + span({ span_id: "x", parent_span_id: "y", name: "x" }), + span({ span_id: "y", parent_span_id: "x", name: "y" }), + ]; + + /** Span names visible in a rendered tree (excludes elision markers). */ + function renderedNames(out: string): string[] { + return out + .split("\n") + .filter((l) => / \[(ok|error)\]$/.test(l)) + .map((l) => (/([\w-]+) \[/.exec(l) as RegExpExecArray)[1] as string); + } + + for (const depth of [1, 2, 3]) { + it(`keeps the SAME span set in the tree and the flat filter at depth ${depth}`, () => { + const human = renderedNames(renderTree(adversarial, { maxDepth: depth })).sort(); + const flat = spansWithinDepth(adversarial, depth) + .map((s) => s.span_id) + .sort(); + expect(flat).toEqual(human); + }); + } + + it("hides the far side of a recovered cycle at depth 1 in both views", () => { + expect(spansWithinDepth(adversarial, 1).map((s) => s.span_id)).toEqual(["root", "x"]); + const out = renderTree(adversarial, { maxDepth: 1 }); + expect(out).toContain("x [ok]"); + expect(out).not.toContain("y [ok]"); + }); + + it("does not count the displayed recovered root in its own elision marker", () => { + const out = renderTree( + [ + span({ span_id: "x", parent_span_id: "y", name: "x" }), + span({ span_id: "y", parent_span_id: "x", name: "y" }), + ], + { maxDepth: 1 }, + ); + // Only y is hidden; x itself is on screen and must not be counted. + expect(out).toContain("… 1 deeper span hidden"); + expect(out).not.toContain("2 deeper"); + }); +}); + +describe("treeOrder", () => { + it("matches the renderer's span line order exactly, including recovered cycles", () => { + // Array order deliberately differs from tree order: sibling B is listed + // before A but A starts earlier, and a detached x ↔ y cycle trails behind. + const spans = [ + span({ + span_id: "B", + parent_span_id: "root", + name: "B", + span_start_time: "2024-01-01T00:00:02Z", + }), + span({ span_id: "root", name: "root", span_start_time: "2024-01-01T00:00:00Z" }), + span({ + span_id: "A", + parent_span_id: "root", + name: "A", + span_start_time: "2024-01-01T00:00:01Z", + }), + span({ span_id: "x", parent_span_id: "y", name: "x" }), + span({ span_id: "y", parent_span_id: "x", name: "y" }), + ]; + const renderedOrder = renderTree(spans) + .split("\n") + .map((l) => (/([\w-]+) \[/.exec(l) as RegExpExecArray)[1] as string); + expect(treeOrder(spans).map((s) => s.span_id)).toEqual(renderedOrder); + expect(renderedOrder).toEqual(["root", "A", "B", "x", "y"]); + }); +}); + +describe("filterErrorsWithAncestors", () => { + it("keeps error spans plus their ancestor chain and drops unrelated branches", () => { + const spans = [ + span({ span_id: "root", name: "root", status: "OK" }), + span({ span_id: "a", parent_span_id: "root", name: "a", status: "OK" }), + span({ span_id: "err", parent_span_id: "a", name: "err", status: "ERROR" }), + span({ span_id: "unrelated", parent_span_id: "root", name: "unrelated", status: "OK" }), + ]; + const kept = filterErrorsWithAncestors(spans).map((s) => s.span_id); + expect(kept).toEqual(["root", "a", "err"]); + expect(kept).not.toContain("unrelated"); + }); + + it("returns an empty list when there are no error spans", () => { + const spans = [span({ span_id: "root", name: "root", status: "OK" })]; + expect(filterErrorsWithAncestors(spans)).toEqual([]); + }); +}); + +describe("isErrorStatus", () => { + it("recognizes the known error statuses and nothing else", () => { + for (const s of ["ERROR", "error", "STATUS_CODE_ERROR"]) { + expect(isErrorStatus(s)).toBe(true); + } + expect(isErrorStatus("OK")).toBe(false); + expect(isErrorStatus(undefined)).toBe(false); + }); +}); + describe("truncate", () => { it("returns text unchanged when at or below the max", () => { expect(truncate("hello", 200)).toBe("hello");