Repository navigation
Expand file tree
/
Copy pathindex.test.ts
More file actions
133 lines (115 loc) · 5.27 KB
/
Copy pathindex.test.ts
File metadata and controls
133 lines (115 loc) · 5.27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import { afterEach, describe, expect, it, vi } from "vitest"
import DefaultPlugin, { SerpApiPlugin, SerpApiV2Plugin } from "./index"
import type { Context as V2Context } from "@opencode/plugin/promise/plugin"
const originalApiKey = process.env.SERPAPI_API_KEY
async function pluginHooks() {
return SerpApiPlugin({} as never)
}
afterEach(() => {
vi.unstubAllGlobals()
if (originalApiKey === undefined) delete process.env.SERPAPI_API_KEY
else process.env.SERPAPI_API_KEY = originalApiKey
})
describe("serpapi_search", () => {
it("calls the REST API with mapped parameters and the trusted API key", async () => {
process.env.SERPAPI_API_KEY = "trusted-key"
const fetchMock = vi.fn(async (_input: string | URL | Request) => new Response('{"ok":true}', { status: 200 }))
vi.stubGlobal("fetch", fetchMock)
const search = (await pluginHooks()).tool?.serpapi_search
if (!search) throw new Error("serpapi_search was not registered")
const result = await search.execute(
{ engine: "amazon", q: "headphones", params: { api_key: "untrusted-key", page: 2 } },
{} as never,
)
expect(result).toBe('{"ok":true}')
expect(fetchMock).toHaveBeenCalledOnce()
const url = new URL(String(fetchMock.mock.calls[0][0]))
expect(url.origin + url.pathname).toBe("https://serpapi.com/search.json")
expect(Object.fromEntries(url.searchParams)).toEqual({
engine: "amazon",
page: "2",
k: "headphones",
api_key: "trusted-key",
})
})
it("returns HTTP failures as tool output", async () => {
process.env.SERPAPI_API_KEY = "test-key"
vi.stubGlobal("fetch", vi.fn(async () => new Response("rate limited", { status: 429 })))
const search = (await pluginHooks()).tool?.serpapi_search
if (!search) throw new Error("serpapi_search was not registered")
await expect(search.execute({ engine: "google_light", q: "news" }, {} as never)).resolves.toBe(
"SerpApi search failed: HTTP 429: rate limited",
)
})
it("does not make a request without an API key", async () => {
delete process.env.SERPAPI_API_KEY
const fetchMock = vi.fn()
vi.stubGlobal("fetch", fetchMock)
const search = (await pluginHooks()).tool?.serpapi_search
if (!search) throw new Error("serpapi_search was not registered")
await expect(search.execute({ q: "news" }, {} as never)).resolves.toBe(
"SERPAPI_API_KEY is not available to the OpenCode process.",
)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe("dual-version entrypoint", () => {
it("keeps the V1 server implementation on the default export", async () => {
expect(typeof DefaultPlugin.server).toBe("function")
const hooks = await DefaultPlugin.server({} as never)
expect(hooks.tool?.serpapi_search).toBeDefined()
})
})
describe("V1 configuration", () => {
it("does not auto-register MCP and still registers the bundled skill path", async () => {
process.env.SERPAPI_API_KEY = "test-key"
const hooks = await pluginHooks()
const config = {}
await hooks.config?.(config)
expect(config).not.toHaveProperty("mcp")
expect(config).toMatchObject({ skills: { paths: [expect.any(String)] } })
})
})
describe("OpenCode V2 adapter", () => {
it("registers the native search tool, remote MCP server, and bundled skill", async () => {
const tools: Array<{ name: string; execute: (input: unknown, context: { signal: AbortSignal }) => Promise<{ content: string }> }> = []
const servers: Record<string, unknown> = {}
const skills: Array<{ id: string; name: string; description?: string; content: string }> = []
const context = {
mcp: {
transform: async (transform: (editor: { get: (name: string) => unknown; set: (name: string, config: unknown) => void }) => void) =>
transform({
get: (name) => servers[name],
set: (name, config) => { servers[name] = config },
}),
},
tool: {
transform: async (transform: (editor: { add: (definition: typeof tools[number]) => void }) => void) =>
transform({ add: (definition) => tools.push(definition) }),
},
skill: {
transform: async (transform: (editor: { add: (skill: typeof skills[number]) => void }) => void) =>
transform({ add: (skill) => skills.push(skill) }),
},
} as unknown as V2Context
process.env.SERPAPI_API_KEY = "v2-test-key"
const fetchMock = vi.fn(async () => new Response('{"ok":true}', { status: 200 }))
vi.stubGlobal("fetch", fetchMock)
await SerpApiV2Plugin.setup(context)
expect(tools.map(({ name }) => name)).toContain("serpapi_search")
expect(servers.serpapi).toMatchObject({
type: "remote",
url: "https://mcp.serpapi.com/mcp",
oauth: false,
headers: { Authorization: "Bearer v2-test-key" },
})
expect(skills).toHaveLength(1)
expect(skills[0].name).toBe("serpapi-web-search")
expect(skills[0].description).toContain("SerpApi provides live web and structured search results")
expect(skills[0].content).toMatch(/^# SerpApi web search/)
expect(skills[0].content).not.toContain("description: |")
await expect(tools[0].execute({ engine: "amazon", q: "headphones" }, { signal: new AbortController().signal }))
.resolves.toEqual({ content: '{"ok":true}' })
expect(fetchMock).toHaveBeenCalledOnce()
})
})