// 單元測試:execution-evaluator — 從 trace 導出每顆零件成敗 + 回寫 registry // SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」 import { describe, it, expect, vi, afterEach } from 'vitest'; import { componentVerdictsFromTrace, recordComponentStats } from '../src/actions/execution-evaluator'; import type { GraphNode, TraceStep } from '../src/types'; const NODES: GraphNode[] = [ { id: 'input', type: 'Input' }, { id: 'fetch', type: 'Component', componentId: 'http_request' }, { id: 'transform', type: 'Component', componentId: 'code' }, { id: 'output', type: 'Output' }, ]; function step(nodeId: string, over: Partial = {}): TraceStep { return { nodeId, type: 'Component', input: {}, output: { ok: true }, duration_ms: 10, ...over }; } describe('componentVerdictsFromTrace', () => { it('只算 Component 節點;Input/Output 跳過', () => { const verdicts = componentVerdictsFromTrace(NODES, [ step('input', { type: 'Input' }), step('fetch'), step('output', { type: 'Output' }), ]); expect(verdicts).toEqual([{ component_id: 'http_request', success: true, duration_ms: 10 }]); }); it('trace 有 error → 該零件記失敗', () => { const verdicts = componentVerdictsFromTrace(NODES, [ step('fetch', { error: 'boom', output: null }), ]); expect(verdicts).toEqual([{ component_id: 'http_request', success: false, duration_ms: 10 }]); }); it('output.success === false → 記失敗(makeHttpRunner 對非 2xx 不 throw)', () => { const verdicts = componentVerdictsFromTrace(NODES, [ step('fetch', { output: { success: false, status: 500, error: 'oops' } }), ]); expect(verdicts[0].success).toBe(false); }); it('FOREACH 同節點多筆 trace → 每次執行各記一次樣本', () => { const verdicts = componentVerdictsFromTrace(NODES, [ step('fetch'), step('fetch', { error: 'x', output: null }), step('fetch'), ]); expect(verdicts).toHaveLength(3); expect(verdicts.map(v => v.success)).toEqual([true, false, true]); }); }); describe('recordComponentStats', () => { afterEach(() => vi.unstubAllGlobals()); it('對每顆零件各發一次 POST /analytics/record(fire-and-forget)', async () => { const calls: Array<{ url: string; body: Record }> = []; vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { calls.push({ url: String(url), body: JSON.parse(String(init.body)) }); return new Response('{}', { status: 200 }); })); await recordComponentStats( { REGISTRY_BASE_URL: 'http://registry.local' }, NODES, [step('fetch'), step('transform', { error: 'bad', output: null })], ); expect(calls).toHaveLength(2); expect(calls[0].url).toBe('http://registry.local/analytics/record'); expect(calls[0].body).toEqual({ canonical_id: 'http_request', success: true, duration_ms: 10 }); expect(calls[1].body).toEqual({ canonical_id: 'code', success: false, duration_ms: 10 }); }); it('registry 打不到也不 throw(統計失敗不影響執行)', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); })); await expect( recordComponentStats({ REGISTRY_BASE_URL: 'http://registry.local' }, NODES, [step('fetch')]), ).resolves.toBeUndefined(); }); it('無 REGISTRY_BASE_URL 也無 WORKER_SUBDOMAIN → 靜默略過不打', async () => { const fetchSpy = vi.fn(); vi.stubGlobal('fetch', fetchSpy); await recordComponentStats({}, NODES, [step('fetch')]); expect(fetchSpy).not.toHaveBeenCalled(); }); it('未設 REGISTRY_BASE_URL → 用 wasmWorkerUrl 慣例組 registry URL', async () => { const calls: string[] = []; vi.stubGlobal('fetch', vi.fn(async (url: string) => { calls.push(String(url)); return new Response('{}', { status: 200 }); })); await recordComponentStats({ WORKER_SUBDOMAIN: 'uncle6-me' }, NODES, [step('fetch')]); expect(calls[0]).toBe('https://arcrun-registry.uncle6-me.workers.dev/analytics/record'); }); });