Repository navigation
Expand file tree
/
Copy pathindex.test.ts
More file actions
176 lines (156 loc) · 7.25 KB
/
Copy pathindex.test.ts
File metadata and controls
176 lines (156 loc) · 7.25 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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
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 and provider, 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 providers: Array<{
id: string
name: string
execute: (
input: { query: string },
context: { signal: AbortSignal },
) => Promise<readonly { url: string; title?: string; content?: string; time: { published?: number } }[]>
}> = []
let defaultProvider: string | false | undefined
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) }),
},
websearch: {
transform: async (transform: (editor: {
add: (definition: typeof providers[number]) => void
default: { get: () => string | false | undefined; set: (selection: string | false) => void }
}) => void) =>
transform({
add: (definition) => providers.push(definition),
default: {
get: () => defaultProvider,
set: (selection) => { defaultProvider = selection },
},
}),
},
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 searchResponse = JSON.stringify({
organic_results: [{ title: "Headphones", link: "https://example.com/headphones", snippet: "Top result" }],
})
const fetchMock = vi.fn(async () => new Response(searchResponse, { status: 200 }))
vi.stubGlobal("fetch", fetchMock)
await SerpApiV2Plugin.setup(context)
expect(tools.map(({ name }) => name)).toContain("serpapi_rest_search")
expect(tools.map(({ name }) => name)).not.toContain("serpapi_search")
expect(providers.map(({ id }) => id)).toContain("serpapi")
expect(providers[0].name).toBe("SerpApi")
expect(defaultProvider).toBeUndefined()
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: |")
const restSearch = tools.find(({ name }) => name === "serpapi_rest_search")
if (!restSearch) throw new Error("serpapi_rest_search was not registered")
await expect(restSearch.execute({ engine: "amazon", q: "headphones" }, { signal: new AbortController().signal }))
.resolves.toEqual({ content: searchResponse })
await expect(providers[0].execute({ query: "headphones" }, { signal: new AbortController().signal }))
.resolves.toEqual([{
url: "https://example.com/headphones",
title: "Headphones",
content: "Top result",
time: {},
}])
expect(fetchMock).toHaveBeenCalledTimes(2)
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch https://serpapi.com/search.json?api_key=v2-test-key"))
await expect(providers[0].execute({ query: "headphones" }, { signal: new AbortController().signal }))
.rejects.toThrow("SerpApi web search failed: Failed to fetch https://serpapi.com/search.json?api_key=[REDACTED]")
})
})