@apifuse/connector-sdk 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/bin/apifuse-check.ts +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/connectors-yaml.test.ts +135 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +665 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { TransportError } from "../errors";
|
|
4
|
+
import { normalizeResponse } from "../runtime/tls";
|
|
5
|
+
import type { DeclarativeTlsResponse } from "../types";
|
|
6
|
+
|
|
7
|
+
type MockSessionResponse = {
|
|
8
|
+
status: number;
|
|
9
|
+
body: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
rawHeaders?: Record<string, string> | [string, string][];
|
|
12
|
+
usedProtocol?: string;
|
|
13
|
+
cookies?:
|
|
14
|
+
| Record<string, { name: string; value: string }>
|
|
15
|
+
| Array<{ name: string; value: string }>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type MockSessionState = {
|
|
19
|
+
responses: MockSessionResponse[];
|
|
20
|
+
error?: Error;
|
|
21
|
+
getCalls: Array<{ url: string; options?: Record<string, unknown> }>;
|
|
22
|
+
postCalls: Array<{
|
|
23
|
+
url: string;
|
|
24
|
+
body: string | Buffer | null;
|
|
25
|
+
options?: Record<string, unknown>;
|
|
26
|
+
}>;
|
|
27
|
+
closed: boolean;
|
|
28
|
+
options: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const mockTlsState = {
|
|
32
|
+
sessions: [] as MockSessionState[],
|
|
33
|
+
queuedResponses: [] as MockSessionResponse[],
|
|
34
|
+
queuedErrors: [] as Error[],
|
|
35
|
+
moduleClients: 0,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
class MockModuleClient {
|
|
39
|
+
constructor() {
|
|
40
|
+
mockTlsState.moduleClients += 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class MockSessionClient {
|
|
45
|
+
private readonly state: MockSessionState;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
_moduleClient: MockModuleClient,
|
|
49
|
+
options: Record<string, unknown>,
|
|
50
|
+
) {
|
|
51
|
+
this.state = {
|
|
52
|
+
responses: mockTlsState.queuedResponses.splice(0),
|
|
53
|
+
error: mockTlsState.queuedErrors.shift(),
|
|
54
|
+
getCalls: [],
|
|
55
|
+
postCalls: [],
|
|
56
|
+
closed: false,
|
|
57
|
+
options,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
mockTlsState.sessions.push(this.state);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async get(url: string, options?: Record<string, unknown>) {
|
|
64
|
+
this.state.getCalls.push({ url, options });
|
|
65
|
+
|
|
66
|
+
if (this.state.error) {
|
|
67
|
+
throw this.state.error;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = this.state.responses.shift();
|
|
71
|
+
if (!response) {
|
|
72
|
+
throw new Error("No queued response for GET");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return response;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async post(
|
|
79
|
+
url: string,
|
|
80
|
+
body: string | Buffer | null,
|
|
81
|
+
options?: Record<string, unknown>,
|
|
82
|
+
) {
|
|
83
|
+
this.state.postCalls.push({ url, body, options });
|
|
84
|
+
|
|
85
|
+
if (this.state.error) {
|
|
86
|
+
throw this.state.error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = this.state.responses.shift();
|
|
90
|
+
if (!response) {
|
|
91
|
+
throw new Error("No queued response for POST");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
close() {
|
|
98
|
+
this.state.closed = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
mock.module("tlsclientwrapper", () => ({
|
|
103
|
+
ModuleClient: MockModuleClient,
|
|
104
|
+
SessionClient: MockSessionClient,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
describe("createTlsClient", () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
mockTlsState.sessions.length = 0;
|
|
110
|
+
mockTlsState.queuedResponses.length = 0;
|
|
111
|
+
mockTlsState.queuedErrors.length = 0;
|
|
112
|
+
mockTlsState.moduleClients = 0;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns fetch and createSession functions", async () => {
|
|
116
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
117
|
+
|
|
118
|
+
const client = createTlsClient("https://example.com");
|
|
119
|
+
|
|
120
|
+
expect(client.fetch).toBeFunction();
|
|
121
|
+
expect(client.createSession).toBeFunction();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns normalized response for successful fetch", async () => {
|
|
125
|
+
mockTlsState.queuedResponses.push({
|
|
126
|
+
status: 200,
|
|
127
|
+
body: '{"ok":true}',
|
|
128
|
+
headers: { "content-type": "text/plain" },
|
|
129
|
+
rawHeaders: [["content-type", "text/plain"]],
|
|
130
|
+
usedProtocol: "h2",
|
|
131
|
+
cookies: [{ name: "sid", value: "abc" }],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
135
|
+
const client = createTlsClient("https://example.com");
|
|
136
|
+
|
|
137
|
+
const response = (await client.fetch("/health", {
|
|
138
|
+
headers: { accept: "text/plain" },
|
|
139
|
+
})) as DeclarativeTlsResponse;
|
|
140
|
+
|
|
141
|
+
expect(response.status).toBe(200);
|
|
142
|
+
expect(response.ok).toBe(true);
|
|
143
|
+
expect(response.headers).toEqual({ "content-type": "text/plain" });
|
|
144
|
+
expect(response.rawHeaders).toEqual([["content-type", "text/plain"]]);
|
|
145
|
+
expect(response.httpVersion).toBe("h2");
|
|
146
|
+
expect(response.cookies.get("sid")).toBe("abc");
|
|
147
|
+
expect(response.cookies.getAll()).toEqual({ sid: "abc" });
|
|
148
|
+
expect(response.cookies.toString()).toBe("sid=abc");
|
|
149
|
+
await expect(response.json<{ ok: boolean }>()).resolves.toEqual({
|
|
150
|
+
ok: true,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("normalizes raw response metadata and false ok flag", async () => {
|
|
155
|
+
const response = normalizeResponse({
|
|
156
|
+
status: 400,
|
|
157
|
+
body: '{"error":true}',
|
|
158
|
+
headers: { "content-type": "application/json" },
|
|
159
|
+
rawHeaders: { "x-test": "1" },
|
|
160
|
+
cookies: { sid: { name: "sid", value: "xyz" } },
|
|
161
|
+
} as never) as DeclarativeTlsResponse;
|
|
162
|
+
|
|
163
|
+
expect(response.ok).toBe(false);
|
|
164
|
+
expect(response.rawHeaders).toEqual([["x-test", "1"]]);
|
|
165
|
+
expect(response.cookies.get("sid")).toBe("xyz");
|
|
166
|
+
await expect(response.json<{ error: boolean }>()).resolves.toEqual({
|
|
167
|
+
error: true,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("throws TransportError on HTTP 500", async () => {
|
|
172
|
+
mockTlsState.queuedResponses.push({
|
|
173
|
+
status: 500,
|
|
174
|
+
body: "boom",
|
|
175
|
+
headers: { "content-type": "text/plain" },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
179
|
+
const client = createTlsClient("https://example.com");
|
|
180
|
+
|
|
181
|
+
await expect(client.fetch("/fail")).rejects.toMatchObject({
|
|
182
|
+
name: "TransportError",
|
|
183
|
+
status: 500,
|
|
184
|
+
message: "HTTP 500: boom",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("createSession reuses the same session across fetches", async () => {
|
|
189
|
+
mockTlsState.queuedResponses.push(
|
|
190
|
+
{ status: 200, body: "first", headers: { a: "1" } },
|
|
191
|
+
{ status: 200, body: "second", headers: { a: "2" } },
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
195
|
+
const client = createTlsClient("https://example.com");
|
|
196
|
+
const session = client.createSession();
|
|
197
|
+
|
|
198
|
+
await session.fetch("/one");
|
|
199
|
+
await session.fetch("/two");
|
|
200
|
+
|
|
201
|
+
expect(mockTlsState.sessions).toHaveLength(1);
|
|
202
|
+
expect(mockTlsState.sessions[0]?.getCalls).toEqual([
|
|
203
|
+
{ url: "https://example.com/one", options: { headers: undefined } },
|
|
204
|
+
{ url: "https://example.com/two", options: { headers: undefined } },
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
session.close();
|
|
208
|
+
expect(mockTlsState.sessions[0]?.closed).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("maps chrome-131 profile to chrome_131 for SessionClient", async () => {
|
|
212
|
+
mockTlsState.queuedResponses.push({
|
|
213
|
+
status: 200,
|
|
214
|
+
body: "ok",
|
|
215
|
+
headers: { "content-type": "text/plain" },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
219
|
+
const client = createTlsClient("https://example.com", "chrome-131");
|
|
220
|
+
const session = client.createSession();
|
|
221
|
+
|
|
222
|
+
await session.fetch("/profile");
|
|
223
|
+
|
|
224
|
+
expect(mockTlsState.sessions[0]?.options).toMatchObject({
|
|
225
|
+
tlsClientIdentifier: "chrome_131",
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("maps chrome-146 profile to chrome_146 for SessionClient", async () => {
|
|
230
|
+
mockTlsState.queuedResponses.push({
|
|
231
|
+
status: 200,
|
|
232
|
+
body: "ok",
|
|
233
|
+
headers: { "content-type": "text/plain" },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
237
|
+
const client = createTlsClient("https://example.com", "chrome-146");
|
|
238
|
+
const session = client.createSession();
|
|
239
|
+
|
|
240
|
+
await session.fetch("/profile");
|
|
241
|
+
|
|
242
|
+
expect(mockTlsState.sessions[0]?.options).toMatchObject({
|
|
243
|
+
tlsClientIdentifier: "chrome_146",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("createSession accepts a profile override", async () => {
|
|
248
|
+
mockTlsState.queuedResponses.push({
|
|
249
|
+
status: 200,
|
|
250
|
+
body: "ok",
|
|
251
|
+
headers: { "content-type": "text/plain" },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
255
|
+
const client = createTlsClient("https://example.com", "chrome-146");
|
|
256
|
+
const session = client.createSession({ profile: "chrome-131" });
|
|
257
|
+
|
|
258
|
+
await session.fetch("/profile");
|
|
259
|
+
|
|
260
|
+
expect(mockTlsState.sessions[0]?.options).toMatchObject({
|
|
261
|
+
tlsClientIdentifier: "chrome_131",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("uses direct JA3 and re-creates session when JA3 changes", async () => {
|
|
266
|
+
mockTlsState.queuedResponses.push({
|
|
267
|
+
status: 200,
|
|
268
|
+
body: "ok-1",
|
|
269
|
+
headers: { "content-type": "text/plain" },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
273
|
+
const client = createTlsClient("https://example.com");
|
|
274
|
+
|
|
275
|
+
await client.fetch("/ja3-a", { tls: { ja3: "custom-ja3-a" } });
|
|
276
|
+
mockTlsState.queuedResponses.push({
|
|
277
|
+
status: 200,
|
|
278
|
+
body: "ok-2",
|
|
279
|
+
headers: { "content-type": "text/plain" },
|
|
280
|
+
});
|
|
281
|
+
await client.fetch("/ja3-b", { tls: { ja3: "custom-ja3-b" } });
|
|
282
|
+
|
|
283
|
+
expect(mockTlsState.sessions).toHaveLength(2);
|
|
284
|
+
expect(mockTlsState.sessions[0]?.options).toMatchObject({
|
|
285
|
+
tlsClientIdentifier: "custom-ja3-a",
|
|
286
|
+
});
|
|
287
|
+
expect(mockTlsState.sessions[1]?.options).toMatchObject({
|
|
288
|
+
tlsClientIdentifier: "custom-ja3-b",
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("passes headerOrder through to TLS request options", async () => {
|
|
293
|
+
mockTlsState.queuedResponses.push({
|
|
294
|
+
status: 200,
|
|
295
|
+
body: "ok",
|
|
296
|
+
headers: { "content-type": "text/plain" },
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
300
|
+
const client = createTlsClient("https://example.com");
|
|
301
|
+
|
|
302
|
+
await client.fetch("/headers", {
|
|
303
|
+
headers: { accept: "text/plain" },
|
|
304
|
+
headerOrder: ["content-type", "user-agent"],
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(mockTlsState.sessions[0]?.getCalls[0]?.options).toMatchObject({
|
|
308
|
+
headers: { accept: "text/plain" },
|
|
309
|
+
headerOrder: ["content-type", "user-agent"],
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("resolves profile identifiers via stealth profiles before heuristic fallback", async () => {
|
|
314
|
+
mockTlsState.queuedResponses.push({
|
|
315
|
+
status: 200,
|
|
316
|
+
body: "ok",
|
|
317
|
+
headers: { "content-type": "text/plain" },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
321
|
+
const client = createTlsClient("https://example.com", "ios-safari-26");
|
|
322
|
+
const session = client.createSession();
|
|
323
|
+
|
|
324
|
+
await session.fetch("/profile");
|
|
325
|
+
|
|
326
|
+
expect(mockTlsState.sessions[0]?.options).toMatchObject({
|
|
327
|
+
tlsClientIdentifier: "safari_ios_26_0",
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("wraps network failures in TransportError", async () => {
|
|
332
|
+
mockTlsState.queuedErrors.push(new Error("socket hang up"));
|
|
333
|
+
|
|
334
|
+
const { createTlsClient } = await import("../runtime/tls");
|
|
335
|
+
const client = createTlsClient("https://example.com");
|
|
336
|
+
|
|
337
|
+
await expect(client.fetch("/network")).rejects.toBeInstanceOf(
|
|
338
|
+
TransportError,
|
|
339
|
+
);
|
|
340
|
+
await expect(client.fetch("/network")).rejects.toMatchObject({
|
|
341
|
+
status: 0,
|
|
342
|
+
message: "Network error: Error: socket hang up",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type {
|
|
4
|
+
AuthMode,
|
|
5
|
+
BrowserEngine,
|
|
6
|
+
ConnectorContext,
|
|
7
|
+
ConnectorDefinition,
|
|
8
|
+
ConnectorMeta,
|
|
9
|
+
CookieJar,
|
|
10
|
+
StealthPlatform,
|
|
11
|
+
StealthProfile,
|
|
12
|
+
TlsFetchOptions,
|
|
13
|
+
TlsResponse,
|
|
14
|
+
} from "../types";
|
|
15
|
+
|
|
16
|
+
describe("ConnectorDefinition types", () => {
|
|
17
|
+
it("should allow valid connector meta", () => {
|
|
18
|
+
const meta = {
|
|
19
|
+
displayName: "CoinGecko Prices",
|
|
20
|
+
description: "Simple finance connector",
|
|
21
|
+
category: "finance",
|
|
22
|
+
tags: ["prices"],
|
|
23
|
+
icon: "./icon.png",
|
|
24
|
+
} satisfies ConnectorMeta;
|
|
25
|
+
|
|
26
|
+
expect(meta.displayName).toBe("CoinGecko Prices");
|
|
27
|
+
expect(meta.category).toBe("finance");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should enforce id format via pattern test", () => {
|
|
31
|
+
const validId = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)+$/.test(
|
|
32
|
+
"coingecko-prices",
|
|
33
|
+
);
|
|
34
|
+
expect(validId).toBe(true);
|
|
35
|
+
|
|
36
|
+
const invalidId = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)+$/.test("CoinGecko");
|
|
37
|
+
expect(invalidId).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should support all auth modes", () => {
|
|
41
|
+
const modes = [
|
|
42
|
+
"none",
|
|
43
|
+
"credentials",
|
|
44
|
+
"oauth2",
|
|
45
|
+
"api-key",
|
|
46
|
+
] as const satisfies readonly AuthMode[];
|
|
47
|
+
expect(modes).toContain("none");
|
|
48
|
+
expect(modes).toContain("credentials");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should type a complete connector definition", () => {
|
|
52
|
+
const definition = {
|
|
53
|
+
id: "coingecko-prices",
|
|
54
|
+
version: "1.0.0",
|
|
55
|
+
runtime: "standard" as const,
|
|
56
|
+
stealth: {
|
|
57
|
+
profile: "chrome-131",
|
|
58
|
+
platform: "macos" as StealthPlatform,
|
|
59
|
+
},
|
|
60
|
+
proxy: true,
|
|
61
|
+
browser: {
|
|
62
|
+
engine: "nodriver" as BrowserEngine,
|
|
63
|
+
},
|
|
64
|
+
meta: {
|
|
65
|
+
displayName: "CoinGecko Prices",
|
|
66
|
+
description: "Connector description",
|
|
67
|
+
category: "finance",
|
|
68
|
+
tags: ["prices"],
|
|
69
|
+
icon: "./icon.png",
|
|
70
|
+
},
|
|
71
|
+
operations: {
|
|
72
|
+
search: {
|
|
73
|
+
description: "Search prices",
|
|
74
|
+
input: z.object({ query: z.string() }),
|
|
75
|
+
output: z.object({ results: z.array(z.string()) }),
|
|
76
|
+
handler: async (_ctx: ConnectorContext, input: unknown) => {
|
|
77
|
+
const parsed = z.object({ query: z.string() }).parse(input);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
results: [parsed.query],
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
fixtures: {
|
|
84
|
+
request: { query: "bitcoin" },
|
|
85
|
+
response: { results: ["bitcoin"] },
|
|
86
|
+
},
|
|
87
|
+
hints: {
|
|
88
|
+
query: "Coin symbol or asset name",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
} satisfies ConnectorDefinition;
|
|
93
|
+
|
|
94
|
+
expect(definition.id).toBe("coingecko-prices");
|
|
95
|
+
expect(definition.operations.search.description).toBe("Search prices");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should type stealth profiles", () => {
|
|
99
|
+
const stealthProfile = {
|
|
100
|
+
name: "chrome-131-macos",
|
|
101
|
+
platform: "macos",
|
|
102
|
+
version: "131",
|
|
103
|
+
userAgent: "Mozilla/5.0",
|
|
104
|
+
headerOrder: ["Host", "User-Agent"],
|
|
105
|
+
} satisfies StealthProfile;
|
|
106
|
+
|
|
107
|
+
expect(stealthProfile.platform).toBe("macos");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should type TLS fetch options and response extensions", async () => {
|
|
111
|
+
const cookies: CookieJar = {
|
|
112
|
+
get: (name) => (name === "sid" ? "abc" : undefined),
|
|
113
|
+
getAll: () => ({ sid: "abc" }),
|
|
114
|
+
toString: () => "sid=abc",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const options = {
|
|
118
|
+
profile: "chrome-131",
|
|
119
|
+
headerOrder: ["host", "user-agent"],
|
|
120
|
+
tls: {
|
|
121
|
+
ja3: "771,4865-4866",
|
|
122
|
+
h2: { HEADER_TABLE_SIZE: 65536 },
|
|
123
|
+
},
|
|
124
|
+
} satisfies TlsFetchOptions;
|
|
125
|
+
|
|
126
|
+
const response: TlsResponse = {
|
|
127
|
+
status: 200,
|
|
128
|
+
ok: true,
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
rawHeaders: [["content-type", "application/json"]],
|
|
131
|
+
body: '{"ok":true}',
|
|
132
|
+
cookies,
|
|
133
|
+
json: async <T>() => JSON.parse('{"ok":true}') as T,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
expect(options.tls?.ja3).toBe("771,4865-4866");
|
|
137
|
+
expect(response.cookies.get("sid")).toBe("abc");
|
|
138
|
+
await expect(response.json<{ ok: boolean }>()).resolves.toEqual({
|
|
139
|
+
ok: true,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkResultCode,
|
|
5
|
+
isEmptyResult,
|
|
6
|
+
nullIfPlaceholder,
|
|
7
|
+
unwrapGovEnvelope,
|
|
8
|
+
} from "../recipes/gov-api";
|
|
9
|
+
import { toISODate } from "../utils/date";
|
|
10
|
+
import { pivotByField, unwrapEnvelope } from "../utils/parse";
|
|
11
|
+
import { stripHtml, truncate } from "../utils/text";
|
|
12
|
+
import { toBoolean, toFloat, toInt, toNumber } from "../utils/transform";
|
|
13
|
+
|
|
14
|
+
describe("transform utils", () => {
|
|
15
|
+
it("parses numbers and booleans", () => {
|
|
16
|
+
expect(toNumber("1.5")).toBe(1.5);
|
|
17
|
+
expect(toFloat("1.25", 1)).toBe(1.3);
|
|
18
|
+
expect(toInt("38.7")).toBe(39);
|
|
19
|
+
expect(toBoolean("true")).toBe(true);
|
|
20
|
+
expect(toBoolean(0)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("date utils", () => {
|
|
25
|
+
it("formats dates with timezone", () => {
|
|
26
|
+
expect(toISODate("20230101", "Asia/Seoul")).toBe(
|
|
27
|
+
"2023-01-01T00:00:00+09:00",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("text utils", () => {
|
|
33
|
+
it("strips html and truncates text", () => {
|
|
34
|
+
expect(stripHtml("<b>hello</b>")).toBe("hello");
|
|
35
|
+
expect(truncate("hello world", 5)).toBe("hello...");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("parse utils", () => {
|
|
40
|
+
it("unwraps envelopes and pivots arrays", () => {
|
|
41
|
+
expect(unwrapEnvelope({ a: { b: 1 } }, "a.b")).toBe(1);
|
|
42
|
+
expect(pivotByField([{ k: "A", v: 1 }], "k", "v")).toEqual({ A: 1 });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("gov-api recipe", () => {
|
|
47
|
+
it("checks result code and placeholder values", () => {
|
|
48
|
+
expect(checkResultCode({ resultCode: "00" })).toBe(true);
|
|
49
|
+
expect(nullIfPlaceholder("-", ["-"])).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("unwraps gov envelope and detects empty results", () => {
|
|
53
|
+
expect(
|
|
54
|
+
unwrapGovEnvelope({
|
|
55
|
+
response: { body: { items: { item: [{ id: 1 }] } } },
|
|
56
|
+
}),
|
|
57
|
+
).toEqual([{ id: 1 }]);
|
|
58
|
+
expect(isEmptyResult({ response: { header: { resultCode: "03" } } })).toBe(
|
|
59
|
+
true,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|