mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
/**
|
|
* Unit tests for Distributed Tracing — TraceCollector, encodeTraceContext, extractTraceContext
|
|
* ADR-064 Phase 3: Learning & Observability
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
TraceCollector,
|
|
createTraceCollector,
|
|
encodeTraceContext,
|
|
extractTraceContext,
|
|
} from '../../src/coordination/agent-teams/tracing.js';
|
|
import type { TraceContext } from '../../src/coordination/agent-teams/tracing.js';
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
/** Default span options used by most tests */
|
|
const defaultOpts = {
|
|
operationName: 'test-op',
|
|
agentId: 'agent-1',
|
|
domain: 'test-generation',
|
|
} as const;
|
|
|
|
// ============================================================================
|
|
// TraceCollector Tests
|
|
// ============================================================================
|
|
|
|
describe('TraceCollector', () => {
|
|
let collector: TraceCollector;
|
|
|
|
beforeEach(() => {
|
|
collector = createTraceCollector();
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// startTrace
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('startTrace', () => {
|
|
it('creates a root trace with unique traceId and spanId', () => {
|
|
const { context, span } = collector.startTrace(defaultOpts);
|
|
|
|
expect(context.traceId).toBeDefined();
|
|
expect(context.spanId).toBeDefined();
|
|
expect(context.traceId).not.toBe(context.spanId);
|
|
|
|
expect(span.traceId).toBe(context.traceId);
|
|
expect(span.spanId).toBe(context.spanId);
|
|
expect(span.parentSpanId).toBeUndefined();
|
|
expect(span.operationName).toBe('test-op');
|
|
expect(span.agentId).toBe('agent-1');
|
|
expect(span.domain).toBe('test-generation');
|
|
expect(span.status).toBe('active');
|
|
expect(span.startTime).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('creates unique IDs on each call', () => {
|
|
const first = collector.startTrace(defaultOpts);
|
|
const second = collector.startTrace(defaultOpts);
|
|
|
|
expect(first.context.traceId).not.toBe(second.context.traceId);
|
|
expect(first.context.spanId).not.toBe(second.context.spanId);
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// startSpan
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('startSpan', () => {
|
|
it('creates a child span inheriting traceId from parent', () => {
|
|
const root = collector.startTrace(defaultOpts);
|
|
|
|
const child = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'child-op',
|
|
agentId: 'agent-2',
|
|
parentContext: root.context,
|
|
});
|
|
|
|
expect(child.context.traceId).toBe(root.context.traceId);
|
|
expect(child.context.spanId).not.toBe(root.context.spanId);
|
|
expect(child.context.parentSpanId).toBe(root.context.spanId);
|
|
|
|
expect(child.span.traceId).toBe(root.context.traceId);
|
|
expect(child.span.parentSpanId).toBe(root.context.spanId);
|
|
expect(child.span.operationName).toBe('child-op');
|
|
expect(child.span.agentId).toBe('agent-2');
|
|
expect(child.span.status).toBe('active');
|
|
});
|
|
|
|
it('creates a new root trace when no parentContext is provided', () => {
|
|
const { context, span } = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'orphan-op',
|
|
});
|
|
|
|
expect(context.traceId).toBeDefined();
|
|
expect(context.spanId).toBeDefined();
|
|
expect(context.parentSpanId).toBeUndefined();
|
|
expect(span.parentSpanId).toBeUndefined();
|
|
expect(span.operationName).toBe('orphan-op');
|
|
expect(span.status).toBe('active');
|
|
});
|
|
|
|
it('propagates baggage from parent context', () => {
|
|
const root = collector.startTrace(defaultOpts);
|
|
const parentWithBaggage: TraceContext = {
|
|
...root.context,
|
|
baggage: { tenant: 'acme', env: 'test' },
|
|
};
|
|
|
|
const child = collector.startSpan({
|
|
...defaultOpts,
|
|
parentContext: parentWithBaggage,
|
|
});
|
|
|
|
expect(child.context.baggage).toEqual({ tenant: 'acme', env: 'test' });
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// completeSpan
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('completeSpan', () => {
|
|
it('sets endTime and status to completed', () => {
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
expect(span.status).toBe('active');
|
|
expect(span.endTime).toBeUndefined();
|
|
|
|
collector.completeSpan(span.spanId);
|
|
|
|
const retrieved = collector.getSpan(span.spanId);
|
|
expect(retrieved).toBeDefined();
|
|
expect(retrieved!.status).toBe('completed');
|
|
expect(retrieved!.endTime).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('does nothing for unknown spanId', () => {
|
|
// Should not throw
|
|
collector.completeSpan('nonexistent-span');
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// failSpan
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('failSpan', () => {
|
|
it('sets status to error and records errorMessage', () => {
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
|
|
collector.failSpan(span.spanId, 'Something went wrong');
|
|
|
|
const retrieved = collector.getSpan(span.spanId);
|
|
expect(retrieved).toBeDefined();
|
|
expect(retrieved!.status).toBe('error');
|
|
expect(retrieved!.errorMessage).toBe('Something went wrong');
|
|
expect(retrieved!.endTime).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('does nothing for unknown spanId', () => {
|
|
// Should not throw
|
|
collector.failSpan('nonexistent-span', 'err');
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// getTrace
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('getTrace', () => {
|
|
it('returns all spans for a traceId sorted by startTime', () => {
|
|
const root = collector.startTrace(defaultOpts);
|
|
|
|
// Create two children with staggered start times
|
|
const child1 = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'child-1',
|
|
parentContext: root.context,
|
|
});
|
|
|
|
const child2 = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'child-2',
|
|
parentContext: root.context,
|
|
});
|
|
|
|
const spans = collector.getTrace(root.context.traceId);
|
|
expect(spans).toHaveLength(3);
|
|
expect(spans[0].spanId).toBe(root.span.spanId);
|
|
|
|
// Verify sorted by startTime (ascending)
|
|
for (let i = 1; i < spans.length; i++) {
|
|
expect(spans[i].startTime).toBeGreaterThanOrEqual(spans[i - 1].startTime);
|
|
}
|
|
});
|
|
|
|
it('returns empty array for unknown traceId', () => {
|
|
const spans = collector.getTrace('unknown-trace-id');
|
|
expect(spans).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// getSpan
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('getSpan', () => {
|
|
it('returns a single span by spanId', () => {
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
|
|
const retrieved = collector.getSpan(span.spanId);
|
|
expect(retrieved).toBeDefined();
|
|
expect(retrieved!.spanId).toBe(span.spanId);
|
|
expect(retrieved!.operationName).toBe(defaultOpts.operationName);
|
|
});
|
|
|
|
it('returns undefined for unknown spanId', () => {
|
|
expect(collector.getSpan('does-not-exist')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// getActiveSpans
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('getActiveSpans', () => {
|
|
it('returns only active spans', () => {
|
|
const root = collector.startTrace(defaultOpts);
|
|
const child = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'child',
|
|
parentContext: root.context,
|
|
});
|
|
|
|
// Complete the root span
|
|
collector.completeSpan(root.span.spanId);
|
|
|
|
const activeSpans = collector.getActiveSpans();
|
|
expect(activeSpans).toHaveLength(1);
|
|
expect(activeSpans[0].spanId).toBe(child.span.spanId);
|
|
expect(activeSpans[0].status).toBe('active');
|
|
});
|
|
|
|
it('returns empty array when all spans are completed', () => {
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
collector.completeSpan(span.spanId);
|
|
|
|
expect(collector.getActiveSpans()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// getStats
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('getStats', () => {
|
|
it('counts active and completed traces correctly', () => {
|
|
// Create two traces
|
|
const trace1 = collector.startTrace({ ...defaultOpts, operationName: 'trace-1' });
|
|
const trace2 = collector.startTrace({ ...defaultOpts, operationName: 'trace-2' });
|
|
|
|
// Complete all spans in trace 1
|
|
collector.completeSpan(trace1.span.spanId);
|
|
|
|
const stats = collector.getStats();
|
|
expect(stats.totalSpans).toBe(2);
|
|
expect(stats.activeTraces).toBe(1); // trace2 still active
|
|
expect(stats.completedTraces).toBe(1); // trace1 fully completed
|
|
});
|
|
|
|
it('treats a trace as active if any span is still active', () => {
|
|
const root = collector.startTrace(defaultOpts);
|
|
const child = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'child',
|
|
parentContext: root.context,
|
|
});
|
|
|
|
// Complete root but leave child active
|
|
collector.completeSpan(root.span.spanId);
|
|
|
|
const stats = collector.getStats();
|
|
expect(stats.totalSpans).toBe(2);
|
|
expect(stats.activeTraces).toBe(1);
|
|
expect(stats.completedTraces).toBe(0);
|
|
});
|
|
|
|
it('returns zeros on empty collector', () => {
|
|
const stats = collector.getStats();
|
|
expect(stats.totalSpans).toBe(0);
|
|
expect(stats.activeTraces).toBe(0);
|
|
expect(stats.completedTraces).toBe(0);
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// purge
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('purge', () => {
|
|
it('removes completed traces older than maxAge', () => {
|
|
// Use vi.spyOn to control Date.now
|
|
const now = 1_000_000;
|
|
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
collector.completeSpan(span.spanId);
|
|
|
|
// Advance time past maxAge
|
|
dateSpy.mockReturnValue(now + 120_000);
|
|
|
|
const removed = collector.purge(60_000);
|
|
expect(removed).toBe(1);
|
|
|
|
// Span should be gone
|
|
expect(collector.getSpan(span.spanId)).toBeUndefined();
|
|
expect(collector.getStats().totalSpans).toBe(0);
|
|
|
|
dateSpy.mockRestore();
|
|
});
|
|
|
|
it('does not remove active traces', () => {
|
|
const now = 1_000_000;
|
|
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
// Leave span active (do not complete)
|
|
|
|
// Advance time
|
|
dateSpy.mockReturnValue(now + 120_000);
|
|
|
|
const removed = collector.purge(60_000);
|
|
expect(removed).toBe(0);
|
|
|
|
// Span should still exist
|
|
expect(collector.getSpan(span.spanId)).toBeDefined();
|
|
|
|
dateSpy.mockRestore();
|
|
});
|
|
|
|
it('does not remove recent completed traces', () => {
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
collector.completeSpan(span.spanId);
|
|
|
|
// Purge with a large maxAge (traces are not old enough)
|
|
const removed = collector.purge(999_999_999);
|
|
expect(removed).toBe(0);
|
|
expect(collector.getSpan(span.spanId)).toBeDefined();
|
|
});
|
|
|
|
it('removes multi-span traces only when all spans are completed and old', () => {
|
|
const now = 1_000_000;
|
|
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
|
|
const root = collector.startTrace(defaultOpts);
|
|
const child = collector.startSpan({
|
|
...defaultOpts,
|
|
operationName: 'child',
|
|
parentContext: root.context,
|
|
});
|
|
|
|
// Complete root but leave child active
|
|
collector.completeSpan(root.span.spanId);
|
|
|
|
dateSpy.mockReturnValue(now + 120_000);
|
|
|
|
// Should NOT purge because child is still active
|
|
expect(collector.purge(60_000)).toBe(0);
|
|
|
|
// Now complete the child
|
|
collector.completeSpan(child.span.spanId);
|
|
|
|
dateSpy.mockReturnValue(now + 240_000);
|
|
|
|
// Now purge should remove both
|
|
expect(collector.purge(60_000)).toBe(2);
|
|
expect(collector.getStats().totalSpans).toBe(0);
|
|
|
|
dateSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// auto-purge on maxSpans
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('auto-purge when maxSpans limit reached', () => {
|
|
it('triggers purge when span count reaches maxSpans', () => {
|
|
const now = 1_000_000;
|
|
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
|
|
// Create a collector with a very small limit
|
|
const smallCollector = createTraceCollector(3);
|
|
|
|
// Create and complete 3 spans
|
|
const t1 = smallCollector.startTrace({ ...defaultOpts, operationName: 'op-1' });
|
|
smallCollector.completeSpan(t1.span.spanId);
|
|
|
|
const t2 = smallCollector.startTrace({ ...defaultOpts, operationName: 'op-2' });
|
|
smallCollector.completeSpan(t2.span.spanId);
|
|
|
|
const t3 = smallCollector.startTrace({ ...defaultOpts, operationName: 'op-3' });
|
|
smallCollector.completeSpan(t3.span.spanId);
|
|
|
|
// Advance time beyond auto-purge threshold (60s)
|
|
dateSpy.mockReturnValue(now + 120_000);
|
|
|
|
// This 4th span should trigger auto-purge (purge with 60_000ms)
|
|
const t4 = smallCollector.startTrace({ ...defaultOpts, operationName: 'op-4' });
|
|
|
|
// Old completed traces should be purged; only the new span remains
|
|
const stats = smallCollector.getStats();
|
|
expect(stats.totalSpans).toBe(1);
|
|
expect(smallCollector.getSpan(t4.span.spanId)).toBeDefined();
|
|
|
|
dateSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// dispose
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('dispose', () => {
|
|
it('clears all spans and indexes', () => {
|
|
collector.startTrace(defaultOpts);
|
|
collector.startTrace(defaultOpts);
|
|
|
|
expect(collector.getStats().totalSpans).toBe(2);
|
|
|
|
collector.dispose();
|
|
|
|
expect(collector.getStats().totalSpans).toBe(0);
|
|
expect(collector.getActiveSpans()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// tags
|
|
// --------------------------------------------------------------------------
|
|
|
|
describe('tags', () => {
|
|
it('stores tags on span when provided', () => {
|
|
const { span } = collector.startTrace({
|
|
...defaultOpts,
|
|
tags: { priority: 'high', retryCount: 3, isFlaky: true },
|
|
});
|
|
|
|
expect(span.tags).toEqual({ priority: 'high', retryCount: 3, isFlaky: true });
|
|
});
|
|
|
|
it('defaults to empty tags object when not provided', () => {
|
|
const { span } = collector.startTrace(defaultOpts);
|
|
expect(span.tags).toEqual({});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Context Serialization Tests
|
|
// ============================================================================
|
|
|
|
describe('encodeTraceContext / extractTraceContext', () => {
|
|
it('round-trips: encode then extract returns same context', () => {
|
|
const original: TraceContext = {
|
|
traceId: 'trace-abc-123',
|
|
spanId: 'span-def-456',
|
|
parentSpanId: 'span-parent-789',
|
|
};
|
|
|
|
const encoded = encodeTraceContext(original);
|
|
const decoded = extractTraceContext(encoded);
|
|
|
|
expect(decoded).toBeDefined();
|
|
expect(decoded!.traceId).toBe(original.traceId);
|
|
expect(decoded!.spanId).toBe(original.spanId);
|
|
expect(decoded!.parentSpanId).toBe(original.parentSpanId);
|
|
});
|
|
|
|
it('round-trips context without parentSpanId', () => {
|
|
const original: TraceContext = {
|
|
traceId: 'trace-root',
|
|
spanId: 'span-root',
|
|
};
|
|
|
|
const encoded = encodeTraceContext(original);
|
|
const decoded = extractTraceContext(encoded);
|
|
|
|
expect(decoded).toBeDefined();
|
|
expect(decoded!.traceId).toBe('trace-root');
|
|
expect(decoded!.spanId).toBe('span-root');
|
|
expect(decoded!.parentSpanId).toBeUndefined();
|
|
});
|
|
|
|
it('encoded string starts with trace: prefix', () => {
|
|
const encoded = encodeTraceContext({
|
|
traceId: 't',
|
|
spanId: 's',
|
|
});
|
|
expect(encoded.startsWith('trace:')).toBe(true);
|
|
});
|
|
|
|
it('returns undefined for non-trace strings', () => {
|
|
expect(extractTraceContext('some-random-correlation-id')).toBeUndefined();
|
|
expect(extractTraceContext('not-trace-prefixed')).toBeUndefined();
|
|
expect(extractTraceContext('')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for undefined input', () => {
|
|
expect(extractTraceContext(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for malformed JSON after trace: prefix', () => {
|
|
expect(extractTraceContext('trace:{invalid-json')).toBeUndefined();
|
|
expect(extractTraceContext('trace:')).toBeUndefined();
|
|
expect(extractTraceContext('trace:null')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when JSON lacks required fields', () => {
|
|
// Valid JSON but missing required fields
|
|
expect(extractTraceContext('trace:{"foo":"bar"}')).toBeUndefined();
|
|
expect(extractTraceContext('trace:{"traceId":"t"}')).toBeUndefined();
|
|
expect(extractTraceContext('trace:{"spanId":"s"}')).toBeUndefined();
|
|
});
|
|
});
|