alvin-bot 4.18.0 → 4.18.2

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 (74) hide show
  1. package/AEC-PLUGINS-SOURCES.md +53 -0
  2. package/CHANGELOG.md +37 -2
  3. package/DESIGN-SKILLS-SOURCES.md +81 -0
  4. package/bin/cli.js +1 -1
  5. package/dist/providers/claude-sdk-provider.js +24 -0
  6. package/package.json +3 -1
  7. package/test/allowed-users-gate.test.ts +0 -98
  8. package/test/alvin-dispatch.test.ts +0 -220
  9. package/test/async-agent-chunk-flow.test.ts +0 -244
  10. package/test/async-agent-parser-staleness.test.ts +0 -412
  11. package/test/async-agent-parser-streamjson.test.ts +0 -273
  12. package/test/async-agent-parser.test.ts +0 -322
  13. package/test/async-agent-watcher.test.ts +0 -229
  14. package/test/background-bypass-integration.test.ts +0 -443
  15. package/test/background-bypass-stress.test.ts +0 -417
  16. package/test/background-bypass.test.ts +0 -127
  17. package/test/browser-webfetch.test.ts +0 -121
  18. package/test/claude-sdk-provider.test.ts +0 -115
  19. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  20. package/test/console-timestamps.test.ts +0 -98
  21. package/test/cron-progress-ticker.test.ts +0 -76
  22. package/test/cron-restart-resilience.test.ts +0 -191
  23. package/test/cron-run-resolver.test.ts +0 -133
  24. package/test/cron-runjobnow-throw.test.ts +0 -100
  25. package/test/debounce.test.ts +0 -60
  26. package/test/delivery-registry.test.ts +0 -71
  27. package/test/exec-guard-metachars.test.ts +0 -110
  28. package/test/file-permissions.test.ts +0 -130
  29. package/test/i18n.test.ts +0 -108
  30. package/test/list-subagents-merged.test.ts +0 -172
  31. package/test/memory-extractor.test.ts +0 -151
  32. package/test/memory-layers.test.ts +0 -169
  33. package/test/memory-sdk-injection.test.ts +0 -146
  34. package/test/memory-stress-restart.test.ts +0 -337
  35. package/test/multi-session-stress.test.ts +0 -255
  36. package/test/platform-session-key.test.ts +0 -69
  37. package/test/process-manager.test.ts +0 -186
  38. package/test/registry.test.ts +0 -201
  39. package/test/session-pending-background.test.ts +0 -59
  40. package/test/session-persistence.test.ts +0 -195
  41. package/test/slack-progress-ticker.test.ts +0 -123
  42. package/test/slack-slash-command.test.ts +0 -61
  43. package/test/slack-test-connection.test.ts +0 -176
  44. package/test/stress-scenarios.test.ts +0 -356
  45. package/test/stuck-timer.test.ts +0 -116
  46. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  47. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  48. package/test/subagent-delivery.test.ts +0 -273
  49. package/test/subagent-final-text.test.ts +0 -132
  50. package/test/subagent-stats.test.ts +0 -119
  51. package/test/subagent-toolset-allowlist.test.ts +0 -146
  52. package/test/subagents-commands.test.ts +0 -64
  53. package/test/subagents-config.test.ts +0 -114
  54. package/test/subagents-depth.test.ts +0 -58
  55. package/test/subagents-inheritance.test.ts +0 -67
  56. package/test/subagents-name-resolver.test.ts +0 -122
  57. package/test/subagents-priority-reject.test.ts +0 -88
  58. package/test/subagents-queue.test.ts +0 -127
  59. package/test/subagents-shutdown.test.ts +0 -126
  60. package/test/subagents-toolset.test.ts +0 -71
  61. package/test/sync-task-timeout.test.ts +0 -153
  62. package/test/system-prompt-background-hint.test.ts +0 -65
  63. package/test/telegram-error-filter.test.ts +0 -85
  64. package/test/telegram-workspace-command.test.ts +0 -78
  65. package/test/timing-safe-bearer.test.ts +0 -65
  66. package/test/watchdog-brake.test.ts +0 -157
  67. package/test/watcher-pending-count.test.ts +0 -228
  68. package/test/watcher-zombie-fix.test.ts +0 -252
  69. package/test/web-server-integration.test.ts +0 -189
  70. package/test/web-server-resilience.test.ts +0 -118
  71. package/test/web-server-shutdown.test.ts +0 -117
  72. package/test/whatsapp-auth-resilience.test.ts +0 -96
  73. package/test/workspaces.test.ts +0 -196
  74. package/vitest.config.ts +0 -17
@@ -1,417 +0,0 @@
1
- /**
2
- * v4.12.3 — Stress + edge-case tests for the bypass path.
3
- *
4
- * These tests exercise scenarios that aren't part of the happy path
5
- * but should hold up in real-world use:
6
- * - Many parallel sessions
7
- * - Rapid churn (launch/deliver cycles)
8
- * - Memory hygiene (no residual in-memory state after delivery)
9
- * - Race conditions: delivery fires while counter is mid-update
10
- * - Extreme counter drift (more deliveries than launches)
11
- * - waitUntilProcessingFalse timeout paths
12
- */
13
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
- import fs from "fs";
15
- import os from "os";
16
- import { resolve } from "path";
17
-
18
- const TEST_DATA_DIR = resolve(
19
- os.tmpdir(),
20
- `alvin-bypass-stress-${process.pid}-${Date.now()}`,
21
- );
22
-
23
- beforeEach(async () => {
24
- if (fs.existsSync(TEST_DATA_DIR)) {
25
- fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
26
- }
27
- fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
28
- process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
29
- vi.resetModules();
30
- vi.doMock("../src/services/subagent-delivery.js", () => ({
31
- deliverSubAgentResult: async () => {},
32
- attachBotApi: () => {},
33
- __setBotApiForTest: () => {},
34
- }));
35
- });
36
-
37
- afterEach(async () => {
38
- try {
39
- const mod = await import("../src/services/async-agent-watcher.js");
40
- mod.stopWatcher();
41
- mod.__resetForTest();
42
- } catch {
43
- /* ignore */
44
- }
45
- });
46
-
47
- function writeCompletedJsonl(path: string, text: string): void {
48
- const lines =
49
- [
50
- JSON.stringify({
51
- type: "assistant",
52
- isSidechain: true,
53
- agentId: "x",
54
- message: {
55
- role: "assistant",
56
- content: [{ type: "text", text }],
57
- stop_reason: "end_turn",
58
- usage: { input_tokens: 1, output_tokens: 1 },
59
- },
60
- }),
61
- ].join("\n") + "\n";
62
- fs.mkdirSync(resolve(path, ".."), { recursive: true });
63
- fs.writeFileSync(path, lines, "utf-8");
64
- }
65
-
66
- describe("v4.12.3 bypass — stress + edge cases", () => {
67
- it("100 parallel sessions each launch and deliver one agent — counters isolated", async () => {
68
- const { getSession } = await import("../src/services/session.js");
69
- const { handleToolResultChunk } = await import(
70
- "../src/handlers/async-agent-chunk-handler.js"
71
- );
72
- const watcher = await import("../src/services/async-agent-watcher.js");
73
-
74
- const N = 100;
75
- const sessionKeys: string[] = [];
76
-
77
- // Launch phase
78
- for (let i = 0; i < N; i++) {
79
- const sk = `stress-parallel-${i}`;
80
- sessionKeys.push(sk);
81
- const s = getSession(sk);
82
- s.pendingBackgroundCount = 0;
83
-
84
- const outPath = `${TEST_DATA_DIR}/p-${i}.jsonl`;
85
- handleToolResultChunk(
86
- {
87
- type: "tool_result",
88
- toolUseId: `p_${i}`,
89
- toolResultContent:
90
- "Async agent launched successfully.\n" +
91
- `agentId: p-${i}\n` +
92
- `output_file: ${outPath}\n`,
93
- },
94
- {
95
- chatId: i,
96
- userId: i,
97
- sessionKey: sk,
98
- lastToolUseInput: { description: `task ${i}`, prompt: "p" },
99
- },
100
- );
101
- }
102
-
103
- // Verify all have count=1
104
- for (const sk of sessionKeys) {
105
- expect(getSession(sk).pendingBackgroundCount).toBe(1);
106
- }
107
-
108
- // Complete phase
109
- for (let i = 0; i < N; i++) {
110
- writeCompletedJsonl(`${TEST_DATA_DIR}/p-${i}.jsonl`, `done ${i}`);
111
- }
112
- await watcher.pollOnce();
113
-
114
- // Verify all back to 0
115
- for (const sk of sessionKeys) {
116
- expect(getSession(sk).pendingBackgroundCount).toBe(0);
117
- }
118
-
119
- // Verify watcher in-memory state is empty
120
- expect(watcher.listPendingAgents()).toHaveLength(0);
121
- });
122
-
123
- it("churn: 200 rapid launch/deliver cycles on one session — counter stays [0,1]", async () => {
124
- const { getSession } = await import("../src/services/session.js");
125
- const { handleToolResultChunk } = await import(
126
- "../src/handlers/async-agent-chunk-handler.js"
127
- );
128
- const watcher = await import("../src/services/async-agent-watcher.js");
129
-
130
- const sk = "churn-hot";
131
- const s = getSession(sk);
132
- s.pendingBackgroundCount = 0;
133
-
134
- for (let i = 0; i < 200; i++) {
135
- const outPath = `${TEST_DATA_DIR}/churn-${i}.jsonl`;
136
- handleToolResultChunk(
137
- {
138
- type: "tool_result",
139
- toolUseId: `c_${i}`,
140
- toolResultContent:
141
- "Async agent launched successfully.\n" +
142
- `agentId: c-${i}\n` +
143
- `output_file: ${outPath}\n`,
144
- },
145
- {
146
- chatId: 1,
147
- userId: 1,
148
- sessionKey: sk,
149
- lastToolUseInput: { description: `task ${i}`, prompt: "p" },
150
- },
151
- );
152
- expect(s.pendingBackgroundCount).toBe(1);
153
-
154
- writeCompletedJsonl(outPath, `done ${i}`);
155
- await watcher.pollOnce();
156
- expect(s.pendingBackgroundCount).toBe(0);
157
- }
158
-
159
- // Final sanity
160
- expect(watcher.listPendingAgents()).toHaveLength(0);
161
- });
162
-
163
- it("extreme drift: 10 deliveries but only 1 launch — counter clamps at 0", async () => {
164
- const { getSession } = await import("../src/services/session.js");
165
- const watcher = await import("../src/services/async-agent-watcher.js");
166
-
167
- const sk = "drift-extreme";
168
- const s = getSession(sk);
169
- s.pendingBackgroundCount = 1;
170
-
171
- // Register 10 agents to the same session, but keep the counter at 1
172
- // (simulating a scenario where the handler increment got lost on 9 of them)
173
- for (let i = 0; i < 10; i++) {
174
- const outPath = `${TEST_DATA_DIR}/drift-${i}.jsonl`;
175
- watcher.registerPendingAgent({
176
- agentId: `drift-${i}`,
177
- outputFile: outPath,
178
- description: `drift ${i}`,
179
- prompt: "p",
180
- chatId: 1,
181
- userId: 1,
182
- toolUseId: null,
183
- sessionKey: sk,
184
- });
185
- writeCompletedJsonl(outPath, `done ${i}`);
186
- }
187
-
188
- await watcher.pollOnce();
189
-
190
- // First delivery takes counter from 1 → 0.
191
- // The next 9 deliveries try to decrement from 0 and clamp.
192
- expect(s.pendingBackgroundCount).toBe(0);
193
- });
194
-
195
- it("user /new during pending — counter reset is safe", async () => {
196
- const { getSession, resetSession } = await import("../src/services/session.js");
197
- const { handleToolResultChunk } = await import(
198
- "../src/handlers/async-agent-chunk-handler.js"
199
- );
200
- const watcher = await import("../src/services/async-agent-watcher.js");
201
-
202
- const sk = "reset-during-pending";
203
- const s = getSession(sk);
204
- s.pendingBackgroundCount = 0;
205
-
206
- // Launch 3 agents
207
- for (let i = 0; i < 3; i++) {
208
- const outPath = `${TEST_DATA_DIR}/reset-${i}.jsonl`;
209
- handleToolResultChunk(
210
- {
211
- type: "tool_result",
212
- toolUseId: `r_${i}`,
213
- toolResultContent:
214
- "Async agent launched successfully.\n" +
215
- `agentId: reset-${i}\n` +
216
- `output_file: ${outPath}\n`,
217
- },
218
- {
219
- chatId: 1,
220
- userId: 1,
221
- sessionKey: sk,
222
- lastToolUseInput: { description: `task ${i}`, prompt: "p" },
223
- },
224
- );
225
- }
226
- expect(s.pendingBackgroundCount).toBe(3);
227
-
228
- // User issues /new while all 3 are running
229
- resetSession(sk);
230
- expect(s.pendingBackgroundCount).toBe(0);
231
-
232
- // Watcher delivers all 3 afterwards
233
- for (let i = 0; i < 3; i++) {
234
- writeCompletedJsonl(`${TEST_DATA_DIR}/reset-${i}.jsonl`, `done ${i}`);
235
- }
236
- await watcher.pollOnce();
237
-
238
- // Counter should remain 0 (clamped)
239
- expect(s.pendingBackgroundCount).toBe(0);
240
- });
241
-
242
- it("session removed from Map before delivery — decrement is no-op, no crash", async () => {
243
- const { getAllSessions } = await import("../src/services/session.js");
244
- const { handleToolResultChunk } = await import(
245
- "../src/handlers/async-agent-chunk-handler.js"
246
- );
247
- const watcher = await import("../src/services/async-agent-watcher.js");
248
-
249
- const sk = "ephemeral-session";
250
- const s = getAllSessions();
251
- // Use the standard path to ensure getSession works first
252
- const { getSession } = await import("../src/services/session.js");
253
- const session = getSession(sk);
254
- session.pendingBackgroundCount = 0;
255
-
256
- const outPath = `${TEST_DATA_DIR}/eph.jsonl`;
257
- handleToolResultChunk(
258
- {
259
- type: "tool_result",
260
- toolUseId: "eph_1",
261
- toolResultContent:
262
- "Async agent launched successfully.\n" +
263
- "agentId: eph-1\n" +
264
- `output_file: ${outPath}\n`,
265
- },
266
- {
267
- chatId: 1,
268
- userId: 1,
269
- sessionKey: sk,
270
- lastToolUseInput: { description: "d", prompt: "p" },
271
- },
272
- );
273
- expect(session.pendingBackgroundCount).toBe(1);
274
-
275
- // Nuke the session from the map (simulates TTL cleanup)
276
- s.delete(sk);
277
-
278
- writeCompletedJsonl(outPath, "done");
279
- await expect(watcher.pollOnce()).resolves.not.toThrow();
280
- });
281
-
282
- it("mixed rollout: pre-v4.12.3 persisted entries (no sessionKey) mixed with new entries", async () => {
283
- const { getSession } = await import("../src/services/session.js");
284
- const watcher = await import("../src/services/async-agent-watcher.js");
285
-
286
- // v4.12.3 session with counter
287
- const sk = "mixed-v412";
288
- const s = getSession(sk);
289
- s.pendingBackgroundCount = 1;
290
-
291
- // New-style entry with sessionKey
292
- const newPath = `${TEST_DATA_DIR}/new.jsonl`;
293
- watcher.registerPendingAgent({
294
- agentId: "new-agent",
295
- outputFile: newPath,
296
- description: "new",
297
- prompt: "p",
298
- chatId: 1,
299
- userId: 1,
300
- toolUseId: null,
301
- sessionKey: sk,
302
- });
303
-
304
- // Old-style entry without sessionKey (pre-v4.12.3)
305
- const oldPath = `${TEST_DATA_DIR}/old.jsonl`;
306
- watcher.registerPendingAgent({
307
- agentId: "old-agent",
308
- outputFile: oldPath,
309
- description: "old",
310
- prompt: "p",
311
- chatId: 2,
312
- userId: 2,
313
- toolUseId: null,
314
- // sessionKey intentionally omitted
315
- });
316
-
317
- writeCompletedJsonl(newPath, "new done");
318
- writeCompletedJsonl(oldPath, "old done");
319
- await watcher.pollOnce();
320
-
321
- // New agent decrements our counter; old agent is a no-op
322
- expect(s.pendingBackgroundCount).toBe(0);
323
- expect(watcher.listPendingAgents()).toHaveLength(0);
324
- });
325
-
326
- it("waitUntilProcessingFalse: flag flips right at the tick boundary", async () => {
327
- const { waitUntilProcessingFalse } = await import(
328
- "../src/handlers/background-bypass.js"
329
- );
330
- const session = { isProcessing: true };
331
- // Start waiting, then flip asynchronously
332
- const waitPromise = waitUntilProcessingFalse(session, 2000, 10);
333
- setTimeout(() => { session.isProcessing = false; }, 15);
334
- const result = await waitPromise;
335
- expect(result).toBe(true);
336
- });
337
-
338
- it("waitUntilProcessingFalse: timeout respected", async () => {
339
- const { waitUntilProcessingFalse } = await import(
340
- "../src/handlers/background-bypass.js"
341
- );
342
- const session = { isProcessing: true };
343
- const start = Date.now();
344
- const result = await waitUntilProcessingFalse(session, 200, 25);
345
- const elapsed = Date.now() - start;
346
- expect(result).toBe(false);
347
- expect(elapsed).toBeGreaterThanOrEqual(180); // allow small jitter
348
- expect(elapsed).toBeLessThan(400);
349
- });
350
-
351
- it(
352
- "high load: 50 sessions, each with 4 parallel agents (200 total) — " +
353
- "all deliver, all counters return to 0",
354
- async () => {
355
- const { getSession } = await import("../src/services/session.js");
356
- const { handleToolResultChunk } = await import(
357
- "../src/handlers/async-agent-chunk-handler.js"
358
- );
359
- const watcher = await import("../src/services/async-agent-watcher.js");
360
-
361
- const S = 50;
362
- const A = 4;
363
- const sessionKeys: string[] = [];
364
- const allPaths: string[] = [];
365
-
366
- for (let i = 0; i < S; i++) {
367
- const sk = `load-s-${i}`;
368
- sessionKeys.push(sk);
369
- const s = getSession(sk);
370
- s.pendingBackgroundCount = 0;
371
-
372
- for (let j = 0; j < A; j++) {
373
- const outPath = `${TEST_DATA_DIR}/load-${i}-${j}.jsonl`;
374
- allPaths.push(outPath);
375
- handleToolResultChunk(
376
- {
377
- type: "tool_result",
378
- toolUseId: `load_${i}_${j}`,
379
- toolResultContent:
380
- "Async agent launched successfully.\n" +
381
- `agentId: load-${i}-${j}\n` +
382
- `output_file: ${outPath}\n`,
383
- },
384
- {
385
- chatId: i,
386
- userId: i,
387
- sessionKey: sk,
388
- lastToolUseInput: {
389
- description: `task ${i}-${j}`,
390
- prompt: "p",
391
- },
392
- },
393
- );
394
- }
395
- }
396
-
397
- // Every session has A agents pending
398
- for (const sk of sessionKeys) {
399
- expect(getSession(sk).pendingBackgroundCount).toBe(A);
400
- }
401
-
402
- // Deliver all
403
- for (const p of allPaths) {
404
- writeCompletedJsonl(p, "done");
405
- }
406
- await watcher.pollOnce();
407
-
408
- // All counters back to 0
409
- for (const sk of sessionKeys) {
410
- expect(getSession(sk).pendingBackgroundCount).toBe(0);
411
- }
412
-
413
- // No residual state
414
- expect(watcher.listPendingAgents()).toHaveLength(0);
415
- },
416
- );
417
- });
@@ -1,127 +0,0 @@
1
- /**
2
- * v4.12.3 — background-bypass pure helpers.
3
- *
4
- * These helpers factor out the SDK-resume-bypass decision from the
5
- * message handler so it can be unit tested without grammy Context
6
- * mocks. The real handler composes these functions — they're only
7
- * state machines over session fields + time.
8
- */
9
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
- import {
11
- shouldBypassQueue,
12
- shouldBypassSdkResume,
13
- waitUntilProcessingFalse,
14
- } from "../src/handlers/background-bypass.js";
15
-
16
- describe("shouldBypassQueue (v4.12.3)", () => {
17
- it("returns false when session is not processing", () => {
18
- expect(
19
- shouldBypassQueue({
20
- isProcessing: false,
21
- pendingBackgroundCount: 5,
22
- abortController: new AbortController(),
23
- }),
24
- ).toBe(false);
25
- });
26
-
27
- it("returns false when no background agent is pending", () => {
28
- expect(
29
- shouldBypassQueue({
30
- isProcessing: true,
31
- pendingBackgroundCount: 0,
32
- abortController: new AbortController(),
33
- }),
34
- ).toBe(false);
35
- });
36
-
37
- it("returns false when no abortController exists (can't abort)", () => {
38
- expect(
39
- shouldBypassQueue({
40
- isProcessing: true,
41
- pendingBackgroundCount: 2,
42
- abortController: null,
43
- }),
44
- ).toBe(false);
45
- });
46
-
47
- it("returns true when processing, background pending, and abortable", () => {
48
- expect(
49
- shouldBypassQueue({
50
- isProcessing: true,
51
- pendingBackgroundCount: 1,
52
- abortController: new AbortController(),
53
- }),
54
- ).toBe(true);
55
- });
56
-
57
- it("returns true even with multiple pending agents", () => {
58
- expect(
59
- shouldBypassQueue({
60
- isProcessing: true,
61
- pendingBackgroundCount: 3,
62
- abortController: new AbortController(),
63
- }),
64
- ).toBe(true);
65
- });
66
-
67
- it("returns false if abortController is already aborted — nothing left to abort", () => {
68
- const ac = new AbortController();
69
- ac.abort();
70
- expect(
71
- shouldBypassQueue({
72
- isProcessing: true,
73
- pendingBackgroundCount: 1,
74
- abortController: ac,
75
- }),
76
- ).toBe(false);
77
- });
78
- });
79
-
80
- describe("shouldBypassSdkResume (v4.12.3)", () => {
81
- it("returns true when pendingBackgroundCount > 0 — old SDK session is blocked, need fresh", () => {
82
- expect(shouldBypassSdkResume({ pendingBackgroundCount: 1 })).toBe(true);
83
- expect(shouldBypassSdkResume({ pendingBackgroundCount: 5 })).toBe(true);
84
- });
85
-
86
- it("returns false when no background pending — safe to resume", () => {
87
- expect(shouldBypassSdkResume({ pendingBackgroundCount: 0 })).toBe(false);
88
- });
89
- });
90
-
91
- describe("waitUntilProcessingFalse (v4.12.3)", () => {
92
- beforeEach(() => vi.useFakeTimers());
93
- afterEach(() => vi.useRealTimers());
94
-
95
- it("resolves immediately when already not processing", async () => {
96
- const session = { isProcessing: false };
97
- const p = waitUntilProcessingFalse(session, 5000);
98
- await vi.advanceTimersByTimeAsync(0);
99
- await expect(p).resolves.toBe(true);
100
- });
101
-
102
- it("waits until isProcessing flips, then resolves true", async () => {
103
- const session = { isProcessing: true };
104
- const p = waitUntilProcessingFalse(session, 5000);
105
- await vi.advanceTimersByTimeAsync(200);
106
- session.isProcessing = false;
107
- await vi.advanceTimersByTimeAsync(100);
108
- await expect(p).resolves.toBe(true);
109
- });
110
-
111
- it("gives up after timeout if still processing, resolves false", async () => {
112
- const session = { isProcessing: true };
113
- const p = waitUntilProcessingFalse(session, 1000);
114
- await vi.advanceTimersByTimeAsync(1100);
115
- await expect(p).resolves.toBe(false);
116
- });
117
-
118
- it("uses the provided tick interval (default 50ms)", async () => {
119
- const session = { isProcessing: true };
120
- const p = waitUntilProcessingFalse(session, 500, 25);
121
- // Flip after 130ms of "waiting" — should detect on the next 25ms tick
122
- await vi.advanceTimersByTimeAsync(130);
123
- session.isProcessing = false;
124
- await vi.advanceTimersByTimeAsync(30);
125
- await expect(p).resolves.toBe(true);
126
- });
127
- });
@@ -1,121 +0,0 @@
1
- /**
2
- * Fix #11 (minimal) — webfetch Tier 0 for browser-manager.
3
- *
4
- * Background: the current browser fallback chain is
5
- * gateway → cdp → hub-stealth → cli
6
- * Every tier spawns playwright (or talks to a CDP-controlled Chrome),
7
- * which is slow and occasionally impossible under load. Many scraping
8
- * tasks only need plain HTTP — an RSS feed, a JSON API, an OG meta-
9
- * tag sniff. For those, Node's native `fetch` is 100× faster and
10
- * doesn't need a browser at all.
11
- *
12
- * Contract: `webfetchNavigate(url)` returns `{ title, url }` for a
13
- * successful GET, or throws a distinct `WebfetchFailed` error that the
14
- * cascade can catch and fall through to the next tier. Title is the
15
- * first `<title>` tag content; if none, the URL is returned.
16
- *
17
- * Keep it small — this is a Tier 0 helper, not a full scraper.
18
- */
19
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
20
- import {
21
- webfetchNavigate,
22
- WebfetchFailed,
23
- parseTitle,
24
- } from "../src/services/browser-webfetch.js";
25
-
26
- describe("parseTitle (Fix #11)", () => {
27
- it("extracts a simple <title>", () => {
28
- expect(parseTitle("<html><head><title>Hello World</title></head></html>")).toBe("Hello World");
29
- });
30
-
31
- it("handles whitespace and newlines", () => {
32
- expect(parseTitle("<title>\n Multi line \n</title>")).toBe("Multi line");
33
- });
34
-
35
- it("returns empty string when there's no title", () => {
36
- expect(parseTitle("<html><body>no title</body></html>")).toBe("");
37
- });
38
-
39
- it("decodes basic HTML entities", () => {
40
- expect(parseTitle("<title>A &amp; B</title>")).toBe("A & B");
41
- expect(parseTitle("<title>&quot;quoted&quot;</title>")).toBe('"quoted"');
42
- });
43
-
44
- it("is case-insensitive for the tag name", () => {
45
- expect(parseTitle("<HEAD><TITLE>Foo</TITLE></HEAD>")).toBe("Foo");
46
- });
47
- });
48
-
49
- describe("webfetchNavigate (Fix #11)", () => {
50
- let originalFetch: typeof fetch;
51
-
52
- beforeEach(() => {
53
- originalFetch = globalThis.fetch;
54
- });
55
- afterEach(() => {
56
- globalThis.fetch = originalFetch;
57
- });
58
-
59
- it("returns title + url on a 200 response", async () => {
60
- globalThis.fetch = vi.fn(async () =>
61
- new Response(
62
- "<html><head><title>GitHub · alvbln/alvin-bot</title></head></html>",
63
- { status: 200, headers: { "content-type": "text/html" } },
64
- ),
65
- ) as unknown as typeof fetch;
66
-
67
- const result = await webfetchNavigate("https://github.com/alvbln/alvin-bot");
68
- expect(result.title).toBe("GitHub · alvbln/alvin-bot");
69
- expect(result.url).toBe("https://github.com/alvbln/alvin-bot");
70
- });
71
-
72
- it("throws WebfetchFailed with the HTTP status on 4xx/5xx", async () => {
73
- globalThis.fetch = vi.fn(async () =>
74
- new Response("blocked", { status: 403 }),
75
- ) as unknown as typeof fetch;
76
-
77
- await expect(webfetchNavigate("https://example.com")).rejects.toThrow(WebfetchFailed);
78
- try {
79
- await webfetchNavigate("https://example.com");
80
- } catch (err) {
81
- expect((err as WebfetchFailed).status).toBe(403);
82
- }
83
- });
84
-
85
- it("throws WebfetchFailed when the response is not HTML and forceHtml=true", async () => {
86
- globalThis.fetch = vi.fn(async () =>
87
- new Response('{"json":true}', {
88
- status: 200,
89
- headers: { "content-type": "application/json" },
90
- }),
91
- ) as unknown as typeof fetch;
92
-
93
- await expect(
94
- webfetchNavigate("https://api.example.com/data", { forceHtml: true }),
95
- ).rejects.toThrow(WebfetchFailed);
96
- });
97
-
98
- it("accepts non-HTML responses when forceHtml is false (default)", async () => {
99
- globalThis.fetch = vi.fn(async () =>
100
- new Response("plain text", {
101
- status: 200,
102
- headers: { "content-type": "text/plain" },
103
- }),
104
- ) as unknown as typeof fetch;
105
-
106
- const result = await webfetchNavigate("https://example.com/raw");
107
- // No <title> in plain text → falls back to URL as display title
108
- expect(result.url).toBe("https://example.com/raw");
109
- expect(result.title).toBe("https://example.com/raw");
110
- });
111
-
112
- it("wraps network errors in WebfetchFailed so the cascade can catch a single type", async () => {
113
- globalThis.fetch = vi.fn(async () => {
114
- throw new Error("getaddrinfo ENOTFOUND nonexistent.invalid");
115
- }) as unknown as typeof fetch;
116
-
117
- await expect(
118
- webfetchNavigate("https://nonexistent.invalid/"),
119
- ).rejects.toThrow(WebfetchFailed);
120
- });
121
- });