0860e84d22
leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」
+「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」
(兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。
- DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認)
- kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原)
- daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步
- 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標
vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正)
253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
// 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<string, unknown>;
|
||
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<ComponentRunner> =>
|
||
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<ComponentRunner> =>
|
||
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();
|
||
});
|
||
});
|