// Cypher Executor 端到端測試 import { SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; import { GraphExecutor } from '../src/graph-executor'; import type { ComponentRunner, ExecutionGraph } from '../src/types'; describe('GET /', () => { it('回傳服務狀態', async () => { const res = await SELF.fetch('http://localhost/'); const data = await res.json() as Record; expect(res.status).toBe(200); expect(data.service).toBe('arcrun-cypher-executor'); }); }); describe('POST /validate', () => { it('驗證合法的圖定義', async () => { const res = await SELF.fetch('http://localhost/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'test-graph', name: '測試圖', nodes: [ { id: 'n1', type: 'Input' }, { id: 'n2', type: 'Output' }, ], edges: [ { from: 'n1', to: 'n2', type: 'PIPE' }, ], }), }); const data = await res.json() as { valid: boolean }; expect(res.status).toBe(200); expect(data.valid).toBe(true); }); it('偵測無效邊', async () => { const res = await SELF.fetch('http://localhost/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'bad-graph', name: '壞圖', nodes: [{ id: 'n1', type: 'Input' }], edges: [{ from: 'n1', to: 'n999', type: 'PIPE' }], }), }); expect(res.status).toBe(400); }); }); describe('POST /execute', () => { it('PIPE 鏈: Input → passthrough → Output', async () => { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: { id: 'g1', name: 'PIPE 測試', nodes: [ { id: 'input', type: 'Input', data: { message: 'hello' } }, { id: 'pass', type: 'Component', componentId: 'comp_passthrough' }, { id: 'output', type: 'Output' }, ], edges: [ { from: 'input', to: 'pass', type: 'PIPE' }, { from: 'pass', to: 'output', type: 'PIPE' }, ], }, context: {}, }), }); const data = await res.json() as { success: boolean; data: { message: string }; trace: unknown[] }; expect(res.status).toBe(200); expect(data.success).toBe(true); expect(data.data.message).toBe('hello'); expect(data.trace.length).toBeGreaterThanOrEqual(3); }); it('Component 執行: uppercase 轉換', async () => { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: { id: 'g2', name: 'uppercase 測試', nodes: [ { id: 'input', type: 'Input', data: { text: 'hello world' } }, { id: 'upper', type: 'Component', componentId: 'comp_uppercase' }, { id: 'output', type: 'Output' }, ], edges: [ { from: 'input', to: 'upper', type: 'PIPE' }, { from: 'upper', to: 'output', type: 'PIPE' }, ], }, context: {}, }), }); const data = await res.json() as { success: boolean; data: { text: string } }; expect(data.success).toBe(true); expect(data.data.text).toBe('HELLO WORLD'); }); it('PIPE 鏈: 多層 counter 累加', async () => { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: { id: 'g3', name: 'counter 測試', nodes: [ { id: 'input', type: 'Input', data: { count: 0 } }, { id: 'c1', type: 'Component', componentId: 'comp_counter' }, { id: 'c2', type: 'Component', componentId: 'comp_counter' }, { id: 'c3', type: 'Component', componentId: 'comp_counter' }, { id: 'output', type: 'Output' }, ], edges: [ { from: 'input', to: 'c1', type: 'PIPE' }, { from: 'c1', to: 'c2', type: 'PIPE' }, { from: 'c2', to: 'c3', type: 'PIPE' }, { from: 'c3', to: 'output', type: 'PIPE' }, ], }, context: {}, }), }); const data = await res.json() as { success: boolean; data: { count: number } }; expect(data.success).toBe(true); expect(data.data.count).toBe(3); }); it('IF 條件分支', async () => { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: { id: 'g4', name: 'IF 測試', nodes: [ { id: 'input', type: 'Input', data: { valid: true, text: 'go' } }, { id: 'upper', type: 'Component', componentId: 'comp_uppercase' }, { id: 'output', type: 'Output' }, ], edges: [ { from: 'input', to: 'upper', type: 'IF', condition: 'result.valid === true' }, { from: 'upper', to: 'output', type: 'PIPE' }, ], }, context: {}, }), }); const data = await res.json() as { success: boolean; data: { text: string } }; expect(data.success).toBe(true); expect(data.data.text).toBe('GO'); }); it('不存在的零件回傳失敗', async () => { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: { id: 'g5', name: '失敗測試', nodes: [ { id: 'input', type: 'Input', data: {} }, { id: 'bad', type: 'Component', componentId: 'comp_not_exist' }, ], edges: [ { from: 'input', to: 'bad', type: 'PIPE' }, ], }, context: {}, }), }); const data = await res.json() as { success: boolean; error: string }; expect(data.success).toBe(false); expect(data.error).toContain('不存在'); }); it('缺少必填欄位回傳 400', async () => { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph: { id: 'x' } }), }); expect(res.status).toBe(400); }); }); // t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試) describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => { it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => { // mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"} const failLoader = async (_: string): Promise => async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } }); const executor = new GraphExecutor(failLoader); const graph: ExecutionGraph = { id: 'foreach-fail-t117', name: 'FOREACH 全失敗', nodes: [ { id: 'input', type: 'Input', data: { items: ['a', 'b'] } }, { id: 'writer', type: 'Component', componentId: 'http_request' }, ], edges: [ { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' }, ], }; // t117 核心驗證:全部失敗 → throw(不再靜默) await expect(executor.execute(graph, {})).rejects.toThrow(/401/); }); it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => { let callCount = 0; // 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑) const mixedLoader = async (_: string): Promise => async () => { callCount++; if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' }; return { success: true, data: { ok: true } }; }; const executor = new GraphExecutor(mixedLoader); const graph: ExecutionGraph = { id: 'foreach-mixed-t117', name: 'FOREACH 部分失敗', nodes: [ { id: 'input', type: 'Input', data: { items: ['a', 'b'] } }, { id: 'writer', type: 'Component', componentId: 'http_request' }, ], edges: [ { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' }, ], }; // 部分失敗 → 不拋出,正常回傳 results 陣列 const result = await executor.execute(graph, {}); expect(result).toBeDefined(); }); }); // P8 短板齊平(2026-08-09):節點輸出只在「下游有 PIPE 邊會讀」時才寫 KV。 // 背景:BUILD-006 原本每個節點(含 FOREACH 每一圈)都 put 一次 EXEC_CONTEXT, // 但全 codebase 唯一讀點是 PIPE 邊的 kvGetNodeOutput——rag 系工作流 // (ON_SUCCESS+對每個)一張卡白燒 15 次 KV write,把免費層 1,000/日 // 壓成比 Workers AI neurons 更短的板。此測試鎖住「無 PIPE 出邊=零 KV put」 // 與「有 PIPE 出邊=照舊寫、_kv_outputs 照舊可讀」兩個行為。 describe('P8:節點輸出 KV 寫入只服務 PIPE 讀者', () => { // 計數型 KV mock:只記 put 次數(kvSetNodeOutput 只用到 put;get 給 PIPE 讀) function countingKv() { const store = new Map(); let puts = 0; const kv = { put: async (k: string, v: string) => { puts++; store.set(k, v); }, get: async (k: string) => store.get(k) ?? null, } as unknown as KVNamespace; return { kv, getPuts: () => puts }; } it('ON_SUCCESS+FOREACH 工作流(rag_ingest_card 形狀)→ 零 KV put', async () => { const loader = async (id: string): Promise => async () => { if (id === 'parse') { return { success: true, blocks: [{ n: 1 }, { n: 2 }, { n: 3 }], rels: [{ r: 1 }, { r: 2 }] }; } return { success: true, data: { ok: true } }; }; const executor = new GraphExecutor(loader); const graph: ExecutionGraph = { id: 'p8-no-pipe', name: 'rag 形狀(無 PIPE 邊)', nodes: [ { id: 'input', type: 'Input', data: {} }, { id: 'list_old', type: 'Component', componentId: 'http_request' }, { id: 'parse_card', type: 'Component', componentId: 'parse' }, { id: 'post_block', type: 'Component', componentId: 'http_request' }, { id: 'post_triplet', type: 'Component', componentId: 'http_request' }, ], edges: [ { from: 'input', to: 'list_old', type: 'ON_SUCCESS' }, { from: 'list_old', to: 'parse_card', type: 'ON_SUCCESS' }, { from: 'parse_card', to: 'post_block', type: 'FOREACH', iterator: 'block' }, { from: 'parse_card', to: 'post_triplet', type: 'FOREACH', iterator: 'rel' }, ], }; const { kv, getPuts } = countingKv(); const result = await executor.execute(graph, {}, kv); expect(result).toBeDefined(); // 修法前這裡是 8(list_old + parse_card + 3×post_block + 2×post_triplet + input 不寫) expect(getPuts()).toBe(0); }); it('PIPE 工作流 → 照舊寫 KV 且 _kv_outputs 傳遞不變(BUILD-006 語意保留)', async () => { const seen: Record[] = []; const loader = async (id: string): Promise => async (ctx) => { seen.push(ctx as Record); return { success: true, data: { from: id } }; }; const executor = new GraphExecutor(loader); const graph: ExecutionGraph = { id: 'p8-pipe', name: 'PIPE 鏈', nodes: [ { id: 'input', type: 'Input', data: { message: 'hi' } }, { id: 'a', type: 'Component', componentId: 'comp_a' }, { id: 'b', type: 'Component', componentId: 'comp_b' }, ], edges: [ { from: 'input', to: 'a', type: 'PIPE' }, { from: 'a', to: 'b', type: 'PIPE' }, ], }; const { kv, getPuts } = countingKv(); const result = await executor.execute(graph, {}, kv); expect(result).toBeDefined(); // a 有 PIPE 出邊 → 寫;b 沒有出邊 → 不寫(原本 a、b 都寫=2) expect(getPuts()).toBe(1); // 下游 b 收到的 context 帶 _kv_outputs.a(BUILD-006 讀路徑不變) const bCtx = seen[seen.length - 1]; expect((bCtx._kv_outputs as Record)?.a).toBeDefined(); }); });