import { describe, expect, it } from 'vitest' import { INDEX_GRAMMARS, MemoryIndexHost } from '../testSupport' import { WorkspaceIndexes } from '../workspaceIndex' import { frontendRequests } from './requests' const STORE = [ 'export function useAgentStore() {', ' let later = () => fetch("/api/v1/agents/later", { method: "PATCH" })', ' return later', '}', '', 'class AgentApi {', ` rename = async (id: string) => { await this.http.PATCH(\`/api/v1/agents/\${id}\`) }`, '}', '', ].join('\n') const PAGE = [ 'import { api } from "@/lib/api"', '', 'const MyAgentsPage = async () => {', ' const { data } = await api.GET("/api/v1/agents", { params: { query: {} } })', ' return
{data?.length}
', '}', '', 'export default MyAgentsPage', '', ].join('\n') const AGENTS = [ 'export async function fetchDefaultAgent() {', ' const res = await fetch("/api/v1/agents/default", { credentials: "include" })', ' return res.json()', '}', '', 'export const createAgent = async (body: unknown) => {', ` await fetch(\`/api/v1/agents?force=\${1}\`, { method: "POST", body: JSON.stringify(body) })`, '}', '', 'export function useRemoveAgent(id: string) {', ` return useMutation({ mutationFn: () => api.DELETE(\`/api/v1/agents/\${id}\`) })`, '}', '', 'export async function guessed(method: string, init: RequestInit) {', ' await fetch("/api/v1/agents", { method })', ' await fetch("/api/v1/agents", init)', ' await fetch("/api/v1/agents", { ...init })', ' await fetch(url)', ' await axios.get("/api/v1/agents")', '}', '', 'class Client {', ` load(orderId: string) { /* 中文 */ return this.http.GET(\`/api/v1/commerce/orders/\${orderId}\` as never) }`, '}', '', 'const pending = fetch(\'/api/v1/health#top\')', '', ].join('\n') /** 方法是 method 的请求,一处一行:文件 调用者@名字位置 方法 网址 调用处。 */ async function sent(indexes: WorkspaceIndexes, method: string): Promise { const found = await frontendRequests((index, key) => indexes.find(index, key), method) return found.map(({ location, entry }) => { const file = location.uri.replace(`${MemoryIndexHost.FOLDER}/`, '') const call = `${entry.at.line}:${entry.at.character}` return `${file} ${entry.caller ?? '(顶层)'}@${location.start.line}:${location.start.character} ${entry.key} ${entry.path} ${call}` }) } async function requests(files: Record, method: string): Promise { return sent(new WorkspaceIndexes(new MemoryIndexHost(files, INDEX_GRAMMARS)), method) } describe('前端发请求的地方', () => { const files = { 'web/src/app/(main)/my-agents/page.tsx': PAGE, 'web/src/lib/agents.ts': AGENTS } it('认 X.GET 这类成员名是大写 HTTP 方法的调用,名字归最内层有名字的函数(箭头函数取赋值的名字)', async () => { expect(await requests(files, 'GET')).toEqual([ 'web/src/app/(main)/my-agents/page.tsx MyAgentsPage@2:6 GET /api/v1/agents 3:25', 'web/src/lib/agents.ts fetchDefaultAgent@0:22 GET /api/v1/agents/default 1:20', `web/src/lib/agents.ts load@22:2 GET /api/v1/commerce/orders/\${orderId} 22:42`, 'web/src/lib/agents.ts (顶层)@25:16 GET /api/v1/health 25:16', ]) }) it('fetch 的方法看 method 字面量,没写是 GET;method 不是字面量、第二个参数不是对象字面量、带展开的都不认;查询串去掉', async () => { expect(await requests(files, 'POST')).toEqual(['web/src/lib/agents.ts createAgent@5:13 POST /api/v1/agents 6:8']) }) it('赋给 let 的箭头函数不算有名字,归到外层;赋给类字段的取字段名', async () => { expect(await requests({ 'web/src/lib/store.ts': STORE }, 'PATCH')).toEqual([ 'web/src/lib/store.ts useAgentStore@0:16 PATCH /api/v1/agents/later 1:20', `web/src/lib/store.ts rename@6:2 PATCH /api/v1/agents/\${id} 6:41`, ]) }) it('匿名回调里发的请求归到外层有名字的函数', async () => { expect(await requests(files, 'DELETE')).toEqual([`web/src/lib/agents.ts useRemoveAgent@9:16 DELETE /api/v1/agents/\${id} 10:41`]) }) }) /** 带注释、结尾逗号的 tsconfig,`@/` 指 web/src。 */ const TSCONFIG = [ '{', ' "compilerOptions": {', ' /* Bundler mode */', ' "paths": {', ' "@/*": ["./src/*"],', ' },', ' },', '}', '', ].join('\n') /** 照搬 luffy 的 authedFetch:调用处的选项展开进给 fetch 的第二个参数,401 时自己调自己重试。 */ const AUTHED_FETCH = [ 'import { useAuthGate } from "@/lib/auth/auth-gate";', '', 'export const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {', ' const headers = new Headers(init?.headers);', ' const res = await fetch(input, {', ' credentials: "include",', ' ...init,', ' headers,', ' });', ' if (res.status !== 401) return res;', ' headers.set("x-auth-retried", "1");', ' return authedFetch(input, { ...init, headers });', '};', '', ].join('\n') const USE_IDE = [ 'import { useCallback } from "react";', 'import { authedFetch } from "@/lib/api/authed-fetch";', '', 'export function useIde(sessionId: string) {', ' const start = useCallback(async () => {', ' const res = await authedFetch(', ` \`/api/v1/sessions/\${sessionId}/sandbox/ide/start\`,`, ' { method: "POST" },', ' );', ' return res.ok;', ' }, [sessionId]);', ` const status = () => authedFetch(\`/api/v1/sessions/\${sessionId}/sandbox/ide\`)`, ' const guessed = (init: RequestInit) => authedFetch("/api/v1/sessions", init)', ' const items = () => authedFetch(itemsPath(sessionId), { method: "POST" })', ' return { start, status, guessed, items };', '}', '', 'void authedFetch("/api/v1/health")', '', ].join('\n') /** 第二层:把网址转给 authedFetch(相对路径导入)。 */ const HTTP = [ 'import { authedFetch } from "./api/authed-fetch";', '', 'export function send(url: string, init?: RequestInit) {', ' return authedFetch(url, { ...init, cache: "no-store" });', '}', '', 'export const postJson = (url: string, body: unknown) =>', ' authedFetch(url, { method: "POST", body: JSON.stringify(body) });', '', 'export const putBoth = (url: string, init?: RequestInit) => authedFetch(url, { ...init, method: "PUT" });', '', 'export const byMethod = (url: string, method: string) => fetch(url, { method });', '', `export const prefixed = (url: string) => { url = \`/api/v1\${url}\`; return fetch(url) };`, '', ].join('\n') /** 第三层:把网址转给 send,写死 DELETE。 */ const REMOVE = [ 'import { send } from "@/lib/http";', '', 'export const remove = (url: string) => send(url, { method: "DELETE" });', '', ].join('\n') const SAVE = [ 'import { byMethod, postJson, prefixed, putBoth, send } from "@/lib/http";', 'import { remove } from "@/lib/agents";', '', 'export async function save(id: string) {', ` await send(\`/api/v1/agents/\${id}\`, { method: "PATCH" });`, ' await send("/api/v1/agents");', ' await postJson("/api/v1/agents", { name: "a" });', ' await putBoth("/api/v1/agents/x", {});', ' await byMethod("/api/v1/agents", "POST");', ' await prefixed("/agents");', ` await remove(\`/api/v1/agents/\${id}\`);`, '}', '', ].join('\n') /** 本文件定义、没导出的包装函数,调用时带泛型实参。 */ const REFERRAL = [ 'import { authedFetch } from "@/lib/api/authed-fetch";', '', 'const getJson = async (path: string): Promise => {', ' const res = await authedFetch(path, { method: "GET" });', ' return res.json() as Promise;', '};', '', 'export const fetchCenter = () => getJson<{ code: string }>("/api/v1/referral/center");', '', ].join('\n') /** 和包装函数同名、却不是包装函数的:本文件定义的、从包导入的、导入不到的、被参数挡住的。 */ const SAME_NAMES = [ 'import { authedFetch as logged } from "some-lib";', 'import { postJson } from "@/lib/http";', 'import { send } from "@/lib/missing";', '', 'const authedFetch = (input: string) => console.log(input);', '', 'export function noise(postJson: (url: string) => void) {', ' authedFetch("/api/v1/agents");', ' logged("/api/v1/agents");', ' send("/api/v1/agents");', ' postJson("/api/v1/agents");', '}', '', 'export const outside = () => postJson("/api/v1/agents", {});', '', ].join('\n') const CACHE = [ 'const getJson = (path: string) => localStorage.getItem(path);', '', 'export const cached = () => getJson("/api/v1/referral/center");', '', ].join('\n') describe('经包装函数发的请求', () => { const ide = { 'web/tsconfig.json': TSCONFIG, 'web/src/lib/api/authed-fetch.ts': AUTHED_FETCH, 'web/src/app/(main)/chat/code/use-ide.ts': USE_IDE } const IDE_POST = `web/src/app/(main)/chat/code/use-ide.ts useIde@3:16 POST /api/v1/sessions/\${sessionId}/sandbox/ide/start 5:22` it('把收到的网址原样转给 fetch 的是包装函数:方法照 fetch 的规则从调用处的第二个参数读,网址跨行写也认,网址不是字面量的不认', async () => { expect(await requests(ide, 'POST')).toEqual([IDE_POST]) expect(await requests(ide, 'GET')).toEqual([ `web/src/app/(main)/chat/code/use-ide.ts status@11:8 GET /api/v1/sessions/\${sessionId}/sandbox/ide 11:23`, 'web/src/app/(main)/chat/code/use-ide.ts (顶层)@17:5 GET /api/v1/health 17:5', ]) }) it('多层转手、跨文件(相对路径、路径别名导入)都认;写死方法的取写死的,方法定不下来的不认', async () => { const files = { ...ide, 'web/src/lib/http.ts': HTTP, 'web/src/lib/agents.ts': REMOVE, 'web/src/app/agents/page.tsx': SAVE } expect(await requests(files, 'PATCH')).toEqual([`web/src/app/agents/page.tsx save@3:22 PATCH /api/v1/agents/\${id} 4:8`]) expect(await requests(files, 'DELETE')).toEqual([`web/src/app/agents/page.tsx save@3:22 DELETE /api/v1/agents/\${id} 10:8`]) expect(await requests(files, 'POST')).toEqual([IDE_POST, 'web/src/app/agents/page.tsx save@3:22 POST /api/v1/agents 6:8']) expect((await requests(files, 'GET')).filter(line => line.startsWith('web/src/app/agents/'))).toEqual(['web/src/app/agents/page.tsx save@3:22 GET /api/v1/agents 5:8']) expect(await requests(files, 'PUT')).toEqual([]) }) it('调用的名字按本文件的定义、import 来源、参数判定指的是哪个函数,判定不了的不认', async () => { const files = { ...ide, 'web/src/lib/http.ts': HTTP, 'web/src/lib/other.ts': SAME_NAMES, 'web/src/app/referral/api.ts': REFERRAL, 'web/src/app/cache/api.ts': CACHE } expect((await requests(files, 'GET')).filter(line => !line.includes('use-ide.ts'))).toEqual(['web/src/app/referral/api.ts fetchCenter@7:13 GET /api/v1/referral/center 7:33']) expect((await requests(files, 'POST')).filter(line => !line.includes('use-ide.ts'))).toEqual(['web/src/lib/other.ts outside@13:13 POST /api/v1/agents 13:29']) }) it('包装函数、路径别名改了,结果跟着变', async () => { const host = new MemoryIndexHost(ide, INDEX_GRAMMARS) const indexes = new WorkspaceIndexes(host) expect(await sent(indexes, 'POST')).toEqual([IDE_POST]) host.emit('change', 'web/src/lib/api/authed-fetch.ts', 'export const authedFetch = async (input: string) => new Response(input)\n') expect(await sent(indexes, 'POST')).toEqual([]) host.emit('change', 'web/src/lib/api/authed-fetch.ts', 'export const authedFetch = (input: string, init?: RequestInit) => fetch(input, { method: "PUT" })\n') expect(await sent(indexes, 'PUT')).toEqual([ `web/src/app/(main)/chat/code/use-ide.ts useIde@3:16 PUT /api/v1/sessions/\${sessionId}/sandbox/ide/start 5:22`, `web/src/app/(main)/chat/code/use-ide.ts status@11:8 PUT /api/v1/sessions/\${sessionId}/sandbox/ide 11:23`, 'web/src/app/(main)/chat/code/use-ide.ts guessed@12:8 PUT /api/v1/sessions 12:41', 'web/src/app/(main)/chat/code/use-ide.ts (顶层)@17:5 PUT /api/v1/health 17:5', ]) host.emit('change', 'web/src/lib/api/authed-fetch.ts', AUTHED_FETCH) expect(await sent(indexes, 'POST')).toEqual([IDE_POST]) host.emit('change', 'web/tsconfig.json', TSCONFIG.replace('./src/*', './lib/*')) expect(await sent(indexes, 'POST')).toEqual([]) }) }) /** 导出的常量,值引用另一份文件导出的常量;PONG 和 config.ts 的 PING 互相引用。 */ const ROOT = [ 'import { PING } from "./config";', '', 'export const API_ROOT = "/api";', `export const PONG = \`\${PING}/pong\`;`, '', ].join('\n') const CONFIG = [ 'import { API_ROOT, PONG } from "./root";', '', `export const API_V1 = \`\${API_ROOT}/v1\`;`, `export const PING = \`\${PONG}/ping\`;`, '', ].join('\n') /** 照搬 luffy 的 chat/code/api.ts:网址开头拼本文件的 API_PREFIX;也拼导入的、引用常量的,和取不到值的。 */ const SESSION_API = [ 'import { API_V1, PING } from "@/lib/config";', 'import { API_ROOT } from "../../../../lib/root";', 'import { BASE } from "some-lib";', '', 'const API_PREFIX = "/api/v1";', 'const VERSION = `v2`;', `const SESSIONS = \`\${API_PREFIX}/sessions\` as const;`, 'const DYNAMIC = process.env.API ?? "/api";', `const DERIVED = \`\${DYNAMIC}/v1\`;`, 'let MUTABLE = "/api/v1";', `const LOOP_A = \`\${LOOP_B}/a\`;`, `const LOOP_B = \`\${LOOP_A}/b\`;`, '', `export const cancel = (id: string) => fetch(\`\${API_PREFIX}/sessions/\${id}/cancel\`, { method: "POST" });`, `export const versioned = (id: string) => fetch(\`/api/\${VERSION}/agents/\${id}\`);`, `export const stream = (id: string) => fetch(\`\${SESSIONS}/\${id}/stream\`);`, `export const models = (id: string) => fetch(\`\${API_V1}/agents/\${id}/models\`);`, `export const health = () => fetch(\`\${API_ROOT}/health\`);`, '', 'export function shadowed(API_PREFIX: string) {', ' const API_V1 = DYNAMIC;', ` return [fetch(\`\${API_PREFIX}/agents\`), fetch(\`\${API_V1}/agents\`)];`, '}', '', 'export const unresolved = (cfg: { prefix: string }) => [', ` fetch(\`\${DYNAMIC}/agents\`),`, ` fetch(\`\${DERIVED}/agents\`),`, ` fetch(\`\${MUTABLE}/agents\`),`, ` fetch(\`\${LOOP_A}/agents\`),`, ` fetch(\`\${PING}/agents\`),`, ` fetch(\`\${BASE}/agents\`),`, ` fetch(\`\${cfg.prefix}/agents\`),`, ` fetch(\`\${String(API_PREFIX)}/agents\`),`, '];', '', ].join('\n') describe('网址里拼的常量', () => { const files = { 'web/tsconfig.json': TSCONFIG, 'web/src/lib/root.ts': ROOT, 'web/src/lib/config.ts': CONFIG, 'web/src/app/(main)/chat/code/api.ts': SESSION_API } const API = 'web/src/app/(main)/chat/code/api.ts' it('网址模板里拼的名字指向本文件的字符串常量就拼进它的值,开头、中间段都算,值引用常量的逐层展开', async () => { expect(await requests(files, 'POST')).toEqual([`${API} cancel@13:13 POST /api/v1/sessions/\${id}/cancel 13:38`]) expect((await requests(files, 'GET')).slice(0, 2)).toEqual([ `${API} versioned@14:13 GET /api/v2/agents/\${id} 14:41`, `${API} stream@15:13 GET /api/v1/sessions/\${id}/stream 15:38`, ]) }) it('导入的常量(路径别名、相对路径)按 import 来源对到导出它的文件,值里又导入的接着展开', async () => { expect((await requests(files, 'GET')).slice(2, 4)).toEqual([ `${API} models@16:13 GET /api/v1/agents/\${id}/models 16:38`, `${API} health@17:13 GET /api/health 17:28`, ]) }) it('被参数、局部变量挡住的,不是字面量的,有循环的,包里导入的,不是名字本身的,都照写成参数段', async () => { expect((await requests(files, 'GET')).slice(4)).toEqual([ `${API} shadowed@19:16 GET \${API_PREFIX}/agents 21:10`, `${API} shadowed@19:16 GET \${API_V1}/agents 21:41`, `${API} unresolved@24:13 GET \${DYNAMIC}/agents 25:2`, `${API} unresolved@24:13 GET \${DERIVED}/agents 26:2`, `${API} unresolved@24:13 GET \${MUTABLE}/agents 27:2`, `${API} unresolved@24:13 GET \${LOOP_A}/agents 28:2`, `${API} unresolved@24:13 GET \${PING}/agents 29:2`, `${API} unresolved@24:13 GET \${BASE}/agents 30:2`, `${API} unresolved@24:13 GET \${cfg.prefix}/agents 31:2`, `${API} unresolved@24:13 GET \${String(API_PREFIX)}/agents 32:2`, ]) }) it('导出常量的文件改了,结果跟着变', async () => { const host = new MemoryIndexHost(files, INDEX_GRAMMARS) const indexes = new WorkspaceIndexes(host) const imported = async (): Promise => (await sent(indexes, 'GET')).filter(line => / (?:models|health)@/.test(line)) expect(await imported()).toEqual([ `${API} models@16:13 GET /api/v1/agents/\${id}/models 16:38`, `${API} health@17:13 GET /api/health 17:28`, ]) host.emit('change', 'web/src/lib/root.ts', 'export const API_ROOT = "/gateway";\n') expect(await imported()).toEqual([ `${API} models@16:13 GET /gateway/v1/agents/\${id}/models 16:38`, `${API} health@17:13 GET /gateway/health 17:28`, ]) host.emit('change', 'web/src/lib/root.ts', 'const API_ROOT = "/gateway";\n') expect(await imported()).toEqual([ `${API} models@16:13 GET \${API_V1}/agents/\${id}/models 16:38`, `${API} health@17:13 GET \${API_ROOT}/health 17:28`, ]) }) })