ea1c0571c1
- registry 新增 POST /analytics/record:ANALYTICS_KV 計數器(stats:{hash_id}:{version})
為唯一真相源,衍生值(success_rate/avg_duration_ms/call_count)回填 comp: 記錄
——查詢讀取端讀哪就寫哪,不開第二真相源。KV 無 CAS,誠實標註非原子。
- cypher-executor execution-evaluator 從 stub 改真實作:執行收尾(/cypher/execute
成功與 ExecutionError 路徑+webhook 路徑)對 trace 裡每顆 Component 節點
fire-and-forget 回寫,waitUntil 包、不增加執行同步延遲(仿 recordRecipeStats 慣例)。
成敗判定=trace error 或 output.success===false(makeHttpRunner 非 2xx 不 throw)。
- registry 位置沿 search-nodes 慣例:REGISTRY_BASE_URL 覆蓋,未設走 wasmWorkerUrl。
- 新增單測 13 個全綠;本地雙 wrangler dev 端到端實測:http_request 跑 5 次
(3 成功+2 失敗)→ success_rate 1→0.6、call_count 0→5,/cypher/search 同步可見。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
// 單元測試: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> = {}): 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<string, unknown> }> = [];
|
||
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');
|
||
});
|
||
});
|