mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
test(ui): cover trace-coalesce, leaf-coalesce, and stage-hint helpers
PR-Agent flagged "No relevant tests" each round. Add 23 unit tests covering the pure-function surface introduced by this PR: - deriveStageHint — explicit stage wins, fallback to last .-segment of event, 12-char cap, undefined when nothing meaningful. - coalesceChildren — empty input, single child, adjacent identical collapse, distinct messages stay separate (round-1 fix), distinct sources stay separate (round-2 fix), distinct stages stay separate, interleaving breaks the run. - deriveTraceGroups — empty input, lone leaf, requestId grouping, ts-asc child sort, group ts pinned to oldest child, adjacent leaf coalesce with collapsedRange, leaves split by trace stay distinct (round-3 fix), distinct messages stay distinct, reverse-chronological display sort, max-level + total-latency aggregates, original-input adjacency preserved despite display sort. Refactor: coalesceChildren extracted from logs-trace-row.tsx into derive-trace-groups.ts so it's testable as a pure function without React rendering. Refs #1138, #1151
This commit is contained in:
@@ -35,6 +35,52 @@ function coalesceKey(entry: LogsEntry): string {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce identical consecutive children inside a single trace.
|
||||
*
|
||||
* Key tuple includes `message` so two children that share
|
||||
* event/stage/level/module but report different content stay visible as
|
||||
* separate rows. Includes `source` so adjacent rows from different
|
||||
* services with otherwise-identical fields stay distinct (a request can
|
||||
* fan out across multiple sources participating in the same trace).
|
||||
* Excludes `latencyMs` — it drifts per request and would defeat the
|
||||
* dedup on truly redundant polls.
|
||||
*
|
||||
* Exported so it can be unit-tested independently from the React row.
|
||||
*/
|
||||
export function coalesceChildren(children: LogsEntry[]): Array<{ head: LogsEntry; count: number }> {
|
||||
if (children.length === 0) return [];
|
||||
const out: Array<{ head: LogsEntry; count: number }> = [];
|
||||
let head: LogsEntry | null = null;
|
||||
let count = 0;
|
||||
let key = '';
|
||||
const flush = () => {
|
||||
if (head) out.push({ head, count });
|
||||
head = null;
|
||||
count = 0;
|
||||
};
|
||||
for (const child of children) {
|
||||
const k = [
|
||||
child.event ?? '',
|
||||
child.message ?? '',
|
||||
child.stage ?? '',
|
||||
child.level,
|
||||
child.module ?? '',
|
||||
child.source ?? '',
|
||||
].join(' ');
|
||||
if (head && k === key) {
|
||||
count += 1;
|
||||
} else {
|
||||
flush();
|
||||
head = child;
|
||||
count = 1;
|
||||
key = k;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an entry without an explicit `stage` field, derive a short chip label
|
||||
* from the event name so the trace timeline still renders meaningful badges
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import {
|
||||
coalesceChildren,
|
||||
deriveStageHint,
|
||||
deriveTraceGroups,
|
||||
type LeafItem,
|
||||
} from '@/components/logs/derive-trace-groups';
|
||||
import type { TraceGroup } from '@/components/logs/logs-trace-row';
|
||||
|
||||
// Minimal LogsEntry factory — fields the helpers actually look at.
|
||||
function entry(overrides: Partial<LogsEntry> & Pick<LogsEntry, 'id' | 'timestamp'>): LogsEntry {
|
||||
return {
|
||||
level: 'info',
|
||||
source: 'web-server:http',
|
||||
event: 'event.default',
|
||||
message: 'default message',
|
||||
...overrides,
|
||||
} as LogsEntry;
|
||||
}
|
||||
|
||||
function leafEntries(
|
||||
...overrides: Array<Partial<LogsEntry> & Pick<LogsEntry, 'id' | 'timestamp'>>
|
||||
) {
|
||||
return overrides.map(entry);
|
||||
}
|
||||
|
||||
describe('deriveStageHint', () => {
|
||||
it('returns explicit stage when present', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', stage: 'route' }))).toBe('route');
|
||||
});
|
||||
|
||||
it('falls back to last `.`-segment of event when stage is missing', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'request.dispatched' }))).toBe(
|
||||
'dispatched'
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the derived hint at 12 chars', () => {
|
||||
expect(
|
||||
deriveStageHint(entry({ id: '1', timestamp: 't', event: 'foo.absurdlylongsegmentname' }))
|
||||
).toBe('absurdlylong');
|
||||
});
|
||||
|
||||
it('returns undefined when neither stage nor event is meaningful', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: '' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats single-segment event as its own hint', () => {
|
||||
expect(deriveStageHint(entry({ id: '1', timestamp: 't', event: 'startup' }))).toBe('startup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coalesceChildren', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(coalesceChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes through a single child as count=1', () => {
|
||||
const e = entry({ id: '1', timestamp: 't', event: 'a.b' });
|
||||
expect(coalesceChildren([e])).toEqual([{ head: e, count: 1 }]);
|
||||
});
|
||||
|
||||
it('coalesces two adjacent identical children', () => {
|
||||
const a = entry({ id: '1', timestamp: 't1', event: 'a.b', message: 'm' });
|
||||
const b = entry({ id: '2', timestamp: 't2', event: 'a.b', message: 'm' });
|
||||
const result = coalesceChildren([a, b]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.count).toBe(2);
|
||||
expect(result[0]?.head).toBe(a);
|
||||
});
|
||||
|
||||
it('keeps children with different messages distinct (round-1 fix)', () => {
|
||||
const a = entry({ id: '1', timestamp: 't1', event: 'auth.ok', message: 'alice' });
|
||||
const b = entry({ id: '2', timestamp: 't2', event: 'auth.ok', message: 'bob' });
|
||||
expect(coalesceChildren([a, b])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps children with different sources distinct (round-2 fix)', () => {
|
||||
const a = entry({ id: '1', timestamp: 't1', source: 'svc-a', event: 'e' });
|
||||
const b = entry({ id: '2', timestamp: 't2', source: 'svc-b', event: 'e' });
|
||||
expect(coalesceChildren([a, b])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps children with different stages distinct', () => {
|
||||
const a = entry({ id: '1', timestamp: 't1', stage: 'route', event: 'e' });
|
||||
const b = entry({ id: '2', timestamp: 't2', stage: 'dispatch', event: 'e' });
|
||||
expect(coalesceChildren([a, b])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('only collapses adjacent duplicates — interleaving breaks the run', () => {
|
||||
const a = entry({ id: '1', timestamp: 't1', event: 'a' });
|
||||
const b = entry({ id: '2', timestamp: 't2', event: 'b' });
|
||||
const c = entry({ id: '3', timestamp: 't3', event: 'a' });
|
||||
const result = coalesceChildren([a, b, c]);
|
||||
// a, b, a — three distinct runs of count 1 each
|
||||
expect(result.map((r) => r.count)).toEqual([1, 1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTraceGroups', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(deriveTraceGroups([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits a single leaf for a no-requestId entry', () => {
|
||||
const e = entry({ id: '1', timestamp: '2026-01-01T00:00:00Z' });
|
||||
const result = deriveTraceGroups([e]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.kind).toBe('leaf');
|
||||
expect((result[0] as LeafItem).entry).toBe(e);
|
||||
expect((result[0] as LeafItem).repeatCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('groups entries sharing a requestId into one trace', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1', stage: 'intake' },
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1', stage: 'route' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.kind).toBe('trace');
|
||||
expect((result[0] as TraceGroup).children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sorts trace children by ts ascending and pins group ts to oldest child', () => {
|
||||
// Input order intentionally reverses ts so we can assert the sort.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:02Z', requestId: 'req-1' },
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', requestId: 'req-1' }
|
||||
)
|
||||
);
|
||||
const trace = result[0] as TraceGroup;
|
||||
expect(trace.children.map((c) => c.id)).toEqual(['1', '2']);
|
||||
expect(trace.ts).toBe('2026-01-01T00:00:01Z');
|
||||
});
|
||||
|
||||
it('coalesces two adjacent identical leaves into a single ×N row', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'poll', message: 'tick' },
|
||||
{ id: '2', timestamp: 't2', event: 'poll', message: 'tick' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
const leaf = result[0] as LeafItem;
|
||||
expect(leaf.repeatCount).toBe(2);
|
||||
expect(leaf.collapsedRange?.fromTs).toBe('t1');
|
||||
expect(leaf.collapsedRange?.toTs).toBe('t2');
|
||||
});
|
||||
|
||||
it('does NOT coalesce identical leaves split by an unrelated trace (round-3 fix)', () => {
|
||||
// Two identical no-requestId entries with a trace between them: the run
|
||||
// must break — they were not adjacent in the real stream.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'poll', message: 'tick' },
|
||||
{ id: '2', timestamp: 't2', requestId: 'req-1' },
|
||||
{ id: '3', timestamp: 't3', event: 'poll', message: 'tick' }
|
||||
)
|
||||
);
|
||||
// Expected: 1 trace + 2 distinct leaves (no ×N).
|
||||
const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[];
|
||||
expect(leaves).toHaveLength(2);
|
||||
expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps adjacent leaves with different messages as separate rows', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', event: 'login', message: 'alice' },
|
||||
{ id: '2', timestamp: 't2', event: 'login', message: 'bob' }
|
||||
)
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('display-sorts items reverse-chronologically', () => {
|
||||
// Use distinct events so leaves don't coalesce — testing display sort,
|
||||
// not coalesce.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'a' },
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:03Z', event: 'b' },
|
||||
{ id: '3', timestamp: '2026-01-01T00:00:02Z', event: 'c' }
|
||||
)
|
||||
);
|
||||
const ids = result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'));
|
||||
expect(ids).toEqual(['2', '3', '1']);
|
||||
});
|
||||
|
||||
it('computes max level across trace children', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', requestId: 'r', level: 'debug' },
|
||||
{ id: '2', timestamp: 't2', requestId: 'r', level: 'error' },
|
||||
{ id: '3', timestamp: 't3', requestId: 'r', level: 'info' }
|
||||
)
|
||||
);
|
||||
expect((result[0] as TraceGroup).maxLevel).toBe('error');
|
||||
});
|
||||
|
||||
it('sums latencyMs across trace children', () => {
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: 't1', requestId: 'r', latencyMs: 100 },
|
||||
{ id: '2', timestamp: 't2', requestId: 'r', latencyMs: 50 },
|
||||
{ id: '3', timestamp: 't3', requestId: 'r' /* no latency */ }
|
||||
)
|
||||
);
|
||||
expect((result[0] as TraceGroup).totalLatencyMs).toBe(150);
|
||||
});
|
||||
|
||||
it('preserves original input order when computing leaf adjacency, even if display sort reorders later', () => {
|
||||
// Stream: [A, B, A] — A entries identical but split by B. A run of 1+1+1.
|
||||
const result = deriveTraceGroups(
|
||||
leafEntries(
|
||||
{ id: '1', timestamp: '2026-01-01T00:00:01Z', event: 'A' },
|
||||
{ id: '2', timestamp: '2026-01-01T00:00:02Z', event: 'B' },
|
||||
{ id: '3', timestamp: '2026-01-01T00:00:03Z', event: 'A' }
|
||||
)
|
||||
);
|
||||
const leaves = result.filter((r) => r.kind === 'leaf') as LeafItem[];
|
||||
expect(leaves).toHaveLength(3);
|
||||
// None should have repeatCount because A and A weren't adjacent.
|
||||
expect(leaves.every((l) => l.repeatCount === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user