@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.
Files changed (67) hide show
  1. package/README.md +44 -0
  2. package/bin/apifuse-check.ts +408 -0
  3. package/bin/apifuse-dev.ts +222 -0
  4. package/bin/apifuse-init.ts +390 -0
  5. package/bin/apifuse-perf.ts +1101 -0
  6. package/bin/apifuse-record.ts +446 -0
  7. package/bin/apifuse-test.ts +688 -0
  8. package/bin/apifuse.ts +51 -0
  9. package/package.json +64 -0
  10. package/src/__tests__/auth.test.ts +396 -0
  11. package/src/__tests__/browser-auth.test.ts +180 -0
  12. package/src/__tests__/browser.test.ts +632 -0
  13. package/src/__tests__/connectors-yaml.test.ts +135 -0
  14. package/src/__tests__/define.test.ts +225 -0
  15. package/src/__tests__/errors.test.ts +69 -0
  16. package/src/__tests__/executor.test.ts +214 -0
  17. package/src/__tests__/http.test.ts +238 -0
  18. package/src/__tests__/insights.test.ts +210 -0
  19. package/src/__tests__/instrumentation.test.ts +290 -0
  20. package/src/__tests__/otlp.test.ts +141 -0
  21. package/src/__tests__/perf.test.ts +60 -0
  22. package/src/__tests__/proxy.test.ts +359 -0
  23. package/src/__tests__/recipes.test.ts +36 -0
  24. package/src/__tests__/serve.test.ts +233 -0
  25. package/src/__tests__/session.test.ts +231 -0
  26. package/src/__tests__/state.test.ts +100 -0
  27. package/src/__tests__/stealth.test.ts +57 -0
  28. package/src/__tests__/testing.test.ts +97 -0
  29. package/src/__tests__/tls.test.ts +345 -0
  30. package/src/__tests__/types.test.ts +142 -0
  31. package/src/__tests__/utils.test.ts +62 -0
  32. package/src/__tests__/waterfall.test.ts +270 -0
  33. package/src/config/connectors-yaml.ts +373 -0
  34. package/src/config/loader.ts +122 -0
  35. package/src/define.ts +137 -0
  36. package/src/dev.ts +38 -0
  37. package/src/errors.ts +68 -0
  38. package/src/index.test.ts +1 -0
  39. package/src/index.ts +100 -0
  40. package/src/protocol.ts +183 -0
  41. package/src/recipes/gov-api.ts +97 -0
  42. package/src/recipes/rest-api.ts +152 -0
  43. package/src/runtime/auth.ts +245 -0
  44. package/src/runtime/browser.ts +724 -0
  45. package/src/runtime/connector.ts +20 -0
  46. package/src/runtime/executor.ts +51 -0
  47. package/src/runtime/http.ts +248 -0
  48. package/src/runtime/insights.ts +456 -0
  49. package/src/runtime/instrumentation.ts +424 -0
  50. package/src/runtime/otlp.ts +171 -0
  51. package/src/runtime/perf.ts +73 -0
  52. package/src/runtime/session.ts +573 -0
  53. package/src/runtime/state.ts +124 -0
  54. package/src/runtime/tls.ts +410 -0
  55. package/src/runtime/trace.ts +261 -0
  56. package/src/runtime/waterfall.ts +245 -0
  57. package/src/serve.ts +665 -0
  58. package/src/stealth/profiles.ts +391 -0
  59. package/src/testing/helpers.ts +144 -0
  60. package/src/testing/index.ts +2 -0
  61. package/src/testing/run.ts +88 -0
  62. package/src/types/playwright-stealth.d.ts +9 -0
  63. package/src/types.ts +243 -0
  64. package/src/utils/date.ts +163 -0
  65. package/src/utils/parse.ts +66 -0
  66. package/src/utils/text.ts +20 -0
  67. package/src/utils/transform.ts +62 -0
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@apifuse/connector-sdk",
3
+ "version": "2.0.0-beta.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "ApiFuse Connector SDK — Build connectors with zero architectural constraints",
7
+ "license": "MIT",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "bin",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "apifuse",
20
+ "connector",
21
+ "sdk",
22
+ "api",
23
+ "zod",
24
+ "hono"
25
+ ],
26
+ "bin": {
27
+ "apifuse-init": "./bin/apifuse-init.ts",
28
+ "apifuse": "./bin/apifuse.ts"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "default": "./src/index.ts",
33
+ "import": "./src/index.ts",
34
+ "types": "./src/index.ts"
35
+ },
36
+ "./testing": {
37
+ "default": "./src/testing/index.ts",
38
+ "import": "./src/testing/index.ts",
39
+ "types": "./src/testing/index.ts"
40
+ }
41
+ },
42
+ "scripts": {
43
+ "lint": "biome check",
44
+ "lint:fix": "biome lint --write",
45
+ "format": "biome format --write",
46
+ "type-check": "tsc --noEmit",
47
+ "test": "bun test",
48
+ "check": "bun run lint && bun run type-check"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "^2.3.13",
52
+ "@clack/prompts": "^1.2.0",
53
+ "@types/bun": "latest",
54
+ "@types/node": "^25.1.0",
55
+ "typescript": "^5.9.3"
56
+ },
57
+ "dependencies": {
58
+ "hono": "^4.7.11",
59
+ "playwright": "^1.55.1",
60
+ "playwright-stealth": "^0.0.1",
61
+ "tlsclientwrapper": "^4.2.0",
62
+ "zod": "^4.3.6"
63
+ }
64
+ }
@@ -0,0 +1,396 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { AuthError, TransportError } from "../errors";
4
+ import { createAuthManager } from "../runtime/auth";
5
+ import { createSqliteSessionStore } from "../runtime/session";
6
+ import { createStateContext } from "../runtime/state";
7
+ import type {
8
+ AuthConfig,
9
+ AuthContext,
10
+ BrowserClient,
11
+ ConnectorContext,
12
+ HttpClient,
13
+ TlsClient,
14
+ } from "../types";
15
+
16
+ function parseAuthMarker(value: string | null): {
17
+ authenticated?: boolean;
18
+ refreshed?: boolean;
19
+ timestamp?: number;
20
+ } | null {
21
+ if (value === null) {
22
+ return null;
23
+ }
24
+
25
+ return JSON.parse(value) as {
26
+ authenticated?: boolean;
27
+ refreshed?: boolean;
28
+ timestamp?: number;
29
+ };
30
+ }
31
+
32
+ function createMockCtx(): ConnectorContext {
33
+ return {
34
+ http: {} as HttpClient,
35
+ tls: {} as TlsClient,
36
+ browser: {} as BrowserClient,
37
+ session: createSqliteSessionStore({ databasePath: ":memory:" }),
38
+ state: createStateContext("test-secret"),
39
+ trace: { span: async (_name, fn) => fn() },
40
+ auth: {} as AuthContext,
41
+ };
42
+ }
43
+
44
+ describe("createAuthManager", () => {
45
+ it("calls config.exchange with the provided ctx and credentials", async () => {
46
+ const ctx = createMockCtx();
47
+ const credentials = {
48
+ username: "demo-user",
49
+ password: "demo-pass",
50
+ };
51
+
52
+ let receivedCtx: ConnectorContext | undefined;
53
+ let receivedCredentials: Record<string, string> | undefined;
54
+
55
+ const manager = createAuthManager(
56
+ {
57
+ mode: "credentials",
58
+ exchange: async (exchangeCtx, exchangeCredentials) => {
59
+ receivedCtx = exchangeCtx;
60
+ receivedCredentials = exchangeCredentials;
61
+ },
62
+ },
63
+ ctx.session,
64
+ );
65
+
66
+ await manager.exchange(ctx, credentials);
67
+
68
+ expect(receivedCtx).toBe(ctx);
69
+ expect(receivedCredentials).toEqual(credentials);
70
+ expect(typeof receivedCtx?.auth.requestField).toBe("function");
71
+ });
72
+
73
+ it("stores an authenticated session marker after a successful exchange", async () => {
74
+ const ctx = createMockCtx();
75
+ const manager = createAuthManager(
76
+ {
77
+ mode: "credentials",
78
+ exchange: async () => {},
79
+ },
80
+ ctx.session,
81
+ );
82
+
83
+ await manager.exchange(ctx, { username: "demo-user" });
84
+
85
+ const marker = parseAuthMarker(await ctx.session.get("__auth__"));
86
+
87
+ expect(marker?.authenticated).toBeTrue();
88
+ expect(typeof marker?.timestamp).toBe("number");
89
+ });
90
+
91
+ it("wraps exchange failures as AuthError", async () => {
92
+ const ctx = createMockCtx();
93
+ const manager = createAuthManager(
94
+ {
95
+ mode: "credentials",
96
+ exchange: async () => {
97
+ throw new Error("otp failed");
98
+ },
99
+ },
100
+ ctx.session,
101
+ );
102
+
103
+ await expect(
104
+ manager.exchange(ctx, { username: "demo-user" }),
105
+ ).rejects.toBeInstanceOf(AuthError);
106
+ });
107
+
108
+ it("returns auth fields from config", () => {
109
+ const fields: AuthConfig["fields"] = [
110
+ { name: "username", label: "Username", type: "text", required: true },
111
+ { name: "otp", label: "OTP", type: "otp", deferred: true },
112
+ ];
113
+ const manager = createAuthManager(
114
+ {
115
+ mode: "credentials",
116
+ fields,
117
+ exchange: async () => {},
118
+ },
119
+ createSqliteSessionStore({ databasePath: ":memory:" }),
120
+ );
121
+
122
+ expect(manager.getFields()).toEqual(fields);
123
+ });
124
+
125
+ it("resolves deferred auth fields through requestField + resolveField", async () => {
126
+ const manager = createAuthManager(
127
+ {
128
+ mode: "credentials",
129
+ exchange: async () => {},
130
+ },
131
+ createSqliteSessionStore({ databasePath: ":memory:" }),
132
+ );
133
+ const auth = manager.createAuthContext();
134
+ const pendingOtp = auth.requestField("otp");
135
+
136
+ setTimeout(() => {
137
+ manager.resolveField("otp", "123456");
138
+ }, 0);
139
+
140
+ expect(await pendingOtp).toBe("123456");
141
+ });
142
+
143
+ it("reports auth-none mode", () => {
144
+ const manager = createAuthManager(
145
+ {
146
+ mode: "none",
147
+ },
148
+ createSqliteSessionStore({ databasePath: ":memory:" }),
149
+ );
150
+
151
+ expect(manager.isAuthNone()).toBeTrue();
152
+ });
153
+
154
+ it("calls config.refresh and stores a refreshed session marker", async () => {
155
+ const ctx = createMockCtx();
156
+ let receivedCtx: ConnectorContext | undefined;
157
+ const manager = createAuthManager(
158
+ {
159
+ mode: "credentials",
160
+ refresh: async (refreshCtx) => {
161
+ receivedCtx = refreshCtx;
162
+ },
163
+ },
164
+ ctx.session,
165
+ );
166
+
167
+ await manager.refresh(ctx);
168
+
169
+ const marker = parseAuthMarker(await ctx.session.get("__auth__"));
170
+
171
+ expect(receivedCtx).toBe(ctx);
172
+ expect(marker?.authenticated).toBeTrue();
173
+ expect(marker?.refreshed).toBeTrue();
174
+ expect(typeof marker?.timestamp).toBe("number");
175
+ });
176
+
177
+ it("calls config.disconnect and deletes the auth session marker", async () => {
178
+ const ctx = createMockCtx();
179
+ await ctx.session.set("__auth__", JSON.stringify({ authenticated: true }));
180
+ let receivedCtx: ConnectorContext | undefined;
181
+ const manager = createAuthManager(
182
+ {
183
+ mode: "credentials",
184
+ disconnect: async (disconnectCtx) => {
185
+ receivedCtx = disconnectCtx;
186
+ },
187
+ },
188
+ ctx.session,
189
+ );
190
+
191
+ await manager.disconnect(ctx);
192
+
193
+ expect(receivedCtx).toBe(ctx);
194
+ expect(await ctx.session.get("__auth__")).toBeNull();
195
+ });
196
+
197
+ it("deletes the auth session marker when disconnect is not configured", async () => {
198
+ const ctx = createMockCtx();
199
+ await ctx.session.set("__auth__", JSON.stringify({ authenticated: true }));
200
+ const manager = createAuthManager(
201
+ {
202
+ mode: "credentials",
203
+ },
204
+ ctx.session,
205
+ );
206
+
207
+ await manager.disconnect(ctx);
208
+
209
+ expect(await ctx.session.get("__auth__")).toBeNull();
210
+ });
211
+
212
+ it("wraps disconnect failures as AuthError", async () => {
213
+ const ctx = createMockCtx();
214
+ await ctx.session.set("__auth__", JSON.stringify({ authenticated: true }));
215
+ const manager = createAuthManager(
216
+ {
217
+ mode: "credentials",
218
+ disconnect: async () => {
219
+ throw new Error("logout failed");
220
+ },
221
+ },
222
+ ctx.session,
223
+ );
224
+
225
+ await expect(manager.disconnect(ctx)).rejects.toBeInstanceOf(AuthError);
226
+ expect(await ctx.session.get("__auth__")).toBeNull();
227
+ });
228
+
229
+ it("wraps refresh failures as AuthError", async () => {
230
+ const ctx = createMockCtx();
231
+ const manager = createAuthManager(
232
+ {
233
+ mode: "credentials",
234
+ refresh: async () => {
235
+ throw new Error("refresh failed");
236
+ },
237
+ },
238
+ ctx.session,
239
+ );
240
+
241
+ await expect(manager.refresh(ctx)).rejects.toBeInstanceOf(AuthError);
242
+ });
243
+
244
+ it("throws AuthError when refresh is not configured", async () => {
245
+ const ctx = createMockCtx();
246
+ const manager = createAuthManager(
247
+ {
248
+ mode: "credentials",
249
+ },
250
+ ctx.session,
251
+ );
252
+
253
+ await expect(manager.refresh(ctx)).rejects.toBeInstanceOf(AuthError);
254
+ });
255
+
256
+ it("returns operation result without refreshing when auto-refresh is unnecessary", async () => {
257
+ const ctx = createMockCtx();
258
+ let refreshCount = 0;
259
+ const manager = createAuthManager(
260
+ {
261
+ mode: "credentials",
262
+ refresh: async () => {
263
+ refreshCount += 1;
264
+ },
265
+ },
266
+ ctx.session,
267
+ );
268
+
269
+ const result = await manager.wrapWithAutoRefresh(ctx, async () => "ok");
270
+
271
+ expect(result).toBe("ok");
272
+ expect(refreshCount).toBe(0);
273
+ });
274
+
275
+ it("refreshes once and retries when the operation fails with 401", async () => {
276
+ const ctx = createMockCtx();
277
+ let refreshCount = 0;
278
+ let attempt = 0;
279
+ const manager = createAuthManager(
280
+ {
281
+ mode: "credentials",
282
+ refresh: async () => {
283
+ refreshCount += 1;
284
+ },
285
+ },
286
+ ctx.session,
287
+ );
288
+
289
+ const result = await manager.wrapWithAutoRefresh(ctx, async () => {
290
+ attempt += 1;
291
+ if (attempt === 1) {
292
+ throw new TransportError("Unauthorized", { status: 401 });
293
+ }
294
+
295
+ return "recovered";
296
+ });
297
+
298
+ expect(result).toBe("recovered");
299
+ expect(refreshCount).toBe(1);
300
+ expect(attempt).toBe(2);
301
+ });
302
+
303
+ it("deduplicates concurrent refreshes when multiple operations hit 401", async () => {
304
+ const ctx = createMockCtx();
305
+ let refreshCount = 0;
306
+ let releaseRefresh!: () => void;
307
+ const refreshBarrier = new Promise<void>((resolve) => {
308
+ releaseRefresh = resolve;
309
+ });
310
+ const manager = createAuthManager(
311
+ {
312
+ mode: "credentials",
313
+ refresh: async () => {
314
+ refreshCount += 1;
315
+ await refreshBarrier;
316
+ },
317
+ },
318
+ ctx.session,
319
+ );
320
+
321
+ function failWith401ThenSucceed(label: string) {
322
+ let attempt = 0;
323
+ return async () => {
324
+ attempt += 1;
325
+ if (attempt === 1) {
326
+ throw new TransportError(`Unauthorized: ${label}`, { status: 401 });
327
+ }
328
+
329
+ return label;
330
+ };
331
+ }
332
+
333
+ const pending = Promise.all([
334
+ manager.wrapWithAutoRefresh(ctx, failWith401ThenSucceed("one")),
335
+ manager.wrapWithAutoRefresh(ctx, failWith401ThenSucceed("two")),
336
+ manager.wrapWithAutoRefresh(ctx, failWith401ThenSucceed("three")),
337
+ ]);
338
+
339
+ await Promise.resolve();
340
+ expect(refreshCount).toBe(1);
341
+ releaseRefresh();
342
+
343
+ await expect(pending).resolves.toEqual(["one", "two", "three"]);
344
+ expect(refreshCount).toBe(1);
345
+ });
346
+
347
+ it("rethrows the original 401 when the retry also fails", async () => {
348
+ const ctx = createMockCtx();
349
+ let refreshCount = 0;
350
+ let attempt = 0;
351
+ const manager = createAuthManager(
352
+ {
353
+ mode: "credentials",
354
+ refresh: async () => {
355
+ refreshCount += 1;
356
+ },
357
+ },
358
+ ctx.session,
359
+ );
360
+ const originalError = new TransportError("Unauthorized", { status: 401 });
361
+
362
+ await expect(
363
+ manager.wrapWithAutoRefresh(ctx, async () => {
364
+ attempt += 1;
365
+ if (attempt === 1) {
366
+ throw originalError;
367
+ }
368
+
369
+ throw new TransportError("Still unauthorized", { status: 401 });
370
+ }),
371
+ ).rejects.toBe(originalError);
372
+ expect(refreshCount).toBe(1);
373
+ });
374
+
375
+ it("rethrows non-401 errors without refreshing", async () => {
376
+ const ctx = createMockCtx();
377
+ let refreshCount = 0;
378
+ const manager = createAuthManager(
379
+ {
380
+ mode: "credentials",
381
+ refresh: async () => {
382
+ refreshCount += 1;
383
+ },
384
+ },
385
+ ctx.session,
386
+ );
387
+ const failure = new TransportError("Bad request", { status: 400 });
388
+
389
+ await expect(
390
+ manager.wrapWithAutoRefresh(ctx, async () => {
391
+ throw failure;
392
+ }),
393
+ ).rejects.toBe(failure);
394
+ expect(refreshCount).toBe(0);
395
+ });
396
+ });
@@ -0,0 +1,180 @@
1
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ import { z } from "zod";
4
+
5
+ import { createAuthManager } from "../runtime/auth";
6
+ import { createSqliteSessionStore } from "../runtime/session";
7
+ import type { ConnectorContext, ConnectorDefinition } from "../types";
8
+
9
+ const readlineState = {
10
+ answers: [] as string[],
11
+ closeCount: 0,
12
+ questions: [] as string[],
13
+ };
14
+
15
+ mock.module("node:readline", () => ({
16
+ createInterface: () => ({
17
+ question: (prompt: string, callback: (answer: string) => void) => {
18
+ readlineState.questions.push(prompt);
19
+ callback(readlineState.answers.shift() ?? "");
20
+ },
21
+ close: () => {
22
+ readlineState.closeCount += 1;
23
+ },
24
+ }),
25
+ }));
26
+
27
+ const { createConnectorContext, runExchangeWithDeferredFieldPrompting } =
28
+ await import("../../bin/apifuse-dev");
29
+
30
+ function createConnector(runtime: "standard" | "browser"): ConnectorDefinition {
31
+ return {
32
+ id: `test-${runtime}`,
33
+ version: "1.0.0",
34
+ runtime,
35
+ meta: {
36
+ displayName: `Test ${runtime}`,
37
+ category: "test",
38
+ },
39
+ browser: runtime === "browser" ? { engine: "nodriver" } : undefined,
40
+ operations: {
41
+ auth: {
42
+ input: z.unknown(),
43
+ output: z.unknown(),
44
+ handler: async (_ctx, input: unknown) => input,
45
+ },
46
+ },
47
+ };
48
+ }
49
+
50
+ describe("browser auth flow", () => {
51
+ it("auth exchange receives a real browser client when runtime is browser", async () => {
52
+ const session = createSqliteSessionStore({ databasePath: ":memory:" });
53
+ let browserHasClose = false;
54
+ const authManager = createAuthManager(
55
+ {
56
+ mode: "credentials",
57
+ exchange: async (ctx) => {
58
+ browserHasClose = Reflect.has(ctx.browser as object, "close");
59
+ },
60
+ },
61
+ session,
62
+ );
63
+
64
+ const { ctx } = createConnectorContext(
65
+ createConnector("browser"),
66
+ session,
67
+ authManager,
68
+ );
69
+
70
+ await authManager.exchange(ctx, { username: "demo" });
71
+
72
+ expect(browserHasClose).toBeTrue();
73
+ });
74
+
75
+ it("uses browser stub when runtime is standard", async () => {
76
+ const session = createSqliteSessionStore({ databasePath: ":memory:" });
77
+ const authManager = createAuthManager(undefined, session);
78
+ const { ctx } = createConnectorContext(
79
+ createConnector("standard"),
80
+ session,
81
+ authManager,
82
+ );
83
+
84
+ expect(Reflect.has(ctx.browser as object, "close")).toBeFalse();
85
+ await expect(ctx.browser.newPage()).rejects.toMatchObject({
86
+ code: "BROWSER_RUNTIME_UNSUPPORTED",
87
+ });
88
+ });
89
+ });
90
+
91
+ describe("deferred OTP via getPendingFields", () => {
92
+ beforeEach(() => {
93
+ readlineState.answers.length = 0;
94
+ readlineState.closeCount = 0;
95
+ readlineState.questions.length = 0;
96
+ });
97
+
98
+ it("resolveField delivers OTP to a waiting requestField and exposes pending fields", async () => {
99
+ const session = createSqliteSessionStore({ databasePath: ":memory:" });
100
+ const manager = createAuthManager(
101
+ {
102
+ mode: "credentials",
103
+ exchange: async (ctx) => {
104
+ const otp = await ctx.auth.requestField("otp");
105
+ expect(otp).toBe("123456");
106
+ },
107
+ },
108
+ session,
109
+ );
110
+ const ctx: ConnectorContext = {
111
+ browser: {} as never,
112
+ http: {} as never,
113
+ tls: {} as never,
114
+ session,
115
+ state: {} as never,
116
+ trace: {
117
+ span: async <T>(_name: string, fn: () => Promise<T>) => await fn(),
118
+ },
119
+ auth: manager.createAuthContext(),
120
+ };
121
+
122
+ const exchangePromise = manager.exchange(ctx, {
123
+ phone: "+82",
124
+ password: "x",
125
+ });
126
+
127
+ await Promise.resolve();
128
+ expect(manager.getPendingFields()).toContain("otp");
129
+ manager.resolveField("otp", "123456");
130
+
131
+ await exchangePromise;
132
+ expect(manager.getPendingFields()).toEqual([]);
133
+ });
134
+
135
+ it("prompts for deferred OTP and resolves it through readline", async () => {
136
+ readlineState.answers.push(" 123456 ");
137
+ const session = createSqliteSessionStore({ databasePath: ":memory:" });
138
+ const manager = createAuthManager(
139
+ {
140
+ mode: "credentials",
141
+ exchange: async (ctx) => {
142
+ const otp = await ctx.auth.requestField("otp");
143
+ expect(otp).toBe("123456");
144
+ },
145
+ },
146
+ session,
147
+ );
148
+ const ctx: ConnectorContext = {
149
+ browser: {} as never,
150
+ http: {} as never,
151
+ tls: {} as never,
152
+ session,
153
+ state: {} as never,
154
+ trace: {
155
+ span: async <T>(_name: string, fn: () => Promise<T>) => await fn(),
156
+ },
157
+ auth: manager.createAuthContext(),
158
+ };
159
+
160
+ await runExchangeWithDeferredFieldPrompting(
161
+ manager,
162
+ ctx,
163
+ { username: "demo" },
164
+ { pollIntervalMs: 0 },
165
+ );
166
+
167
+ expect(readlineState.questions).toEqual(["\n[apifuse dev] Enter otp: "]);
168
+ expect(readlineState.closeCount).toBe(1);
169
+ expect(manager.getPendingFields()).toEqual([]);
170
+ });
171
+
172
+ it("getPendingFields returns empty when no fields are pending", () => {
173
+ const manager = createAuthManager(
174
+ undefined,
175
+ createSqliteSessionStore({ databasePath: ":memory:" }),
176
+ );
177
+
178
+ expect(manager.getPendingFields()).toEqual([]);
179
+ });
180
+ });