Files
superwork/vscode/src/codeReader/callTree/requests.test.ts
T

394 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <div>{data?.length}</div>',
'}',
'',
'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<string[]> {
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<string, string>, method: string): Promise<string[]> {
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<Response> => {',
' 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 <T>(path: string): Promise<T> => {',
' const res = await authedFetch(path, { method: "GET" });',
' return res.json() as Promise<T>;',
'};',
'',
'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<string[]> => (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`,
])
})
})