alvin-bot 4.8.9 → 4.9.0

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Fix #5 — runSubAgent must preserve the full final text, even when the
3
+ * stream ends on a tool_use or is aborted mid-stream.
4
+ *
5
+ * Regressions this closes:
6
+ *
7
+ * (a) The SDK yields `text` chunks as accumulated strings, then tool
8
+ * calls, then more text, then finally a `done` chunk that ALSO
9
+ * carries the final accumulated text. The old runSubAgent read
10
+ * `text` from text-chunks only and ignored `done.text`. If the
11
+ * assistant's very last action was a tool call with no trailing
12
+ * text block, `finalText` kept the pre-tool text and the
13
+ * cron-jobs.json `lastResult` ended mid-sentence.
14
+ *
15
+ * (b) When queryWithFallback threw mid-stream (provider aborted,
16
+ * network error, etc.), the catch block set `output: ""` —
17
+ * throwing away whatever text had already streamed in before the
18
+ * failure. Users saw an empty "(empty output)" delivery.
19
+ *
20
+ * Contract:
21
+ * - Output = last non-empty value observed from (text.text | done.text)
22
+ * - On error / abort: output = whatever we'd buffered so far (never "")
23
+ */
24
+ import { describe, it, expect, beforeEach, vi } from "vitest";
25
+ import fs from "fs";
26
+ import os from "os";
27
+ import { resolve } from "path";
28
+ import type { StreamChunk } from "../src/providers/types.js";
29
+
30
+ const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-bot-finaltext-${process.pid}-${Date.now()}`);
31
+
32
+ beforeEach(() => {
33
+ if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
34
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
35
+ process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
36
+ delete process.env.MAX_SUBAGENTS;
37
+ vi.resetModules();
38
+ });
39
+
40
+ function mockStream(chunks: StreamChunk[] | (() => AsyncIterable<StreamChunk>)) {
41
+ vi.doMock("../src/engine.js", () => ({
42
+ getRegistry: () => ({
43
+ queryWithFallback: typeof chunks === "function"
44
+ ? chunks
45
+ : async function* () { for (const c of chunks) yield c; },
46
+ }),
47
+ }));
48
+ vi.doMock("../src/services/subagent-delivery.js", () => ({
49
+ deliverSubAgentResult: async () => { /* no-op */ },
50
+ attachBotApi: () => {},
51
+ __setBotApiForTest: () => {},
52
+ }));
53
+ }
54
+
55
+ async function runAndGetResult(prompt = "test") {
56
+ const mod = await import("../src/services/subagents.js");
57
+ return new Promise<{ output: string; status: string; tokensUsed: { input: number; output: number } }>((resolveResult) => {
58
+ mod.spawnSubAgent({
59
+ name: "test-agent",
60
+ prompt,
61
+ source: "cron",
62
+ parentChatId: 1,
63
+ onComplete: (r) => resolveResult({
64
+ output: r.output,
65
+ status: r.status,
66
+ tokensUsed: r.tokensUsed,
67
+ }),
68
+ }).catch(() => { /* spawn errors handled elsewhere */ });
69
+ });
70
+ }
71
+
72
+ describe("runSubAgent finalText (Fix #5)", () => {
73
+ it("uses done.text as the authoritative final output", async () => {
74
+ mockStream([
75
+ { type: "text", text: "Working on it…" },
76
+ { type: "tool_use", toolName: "Bash" },
77
+ { type: "text", text: "Intermediate finding: 5 results." },
78
+ { type: "tool_use", toolName: "Write" },
79
+ // No trailing text chunk — the assistant ended on a tool call,
80
+ // then the done chunk carries the authoritative final text.
81
+ { type: "done", text: "Job complete. Report at /tmp/out.html", inputTokens: 100, outputTokens: 50 },
82
+ ]);
83
+ const r = await runAndGetResult();
84
+ expect(r.status).toBe("completed");
85
+ expect(r.output).toBe("Job complete. Report at /tmp/out.html");
86
+ expect(r.tokensUsed).toEqual({ input: 100, output: 50 });
87
+ });
88
+
89
+ it("falls back to last text chunk when done has no text", async () => {
90
+ mockStream([
91
+ { type: "text", text: "First sentence." },
92
+ { type: "text", text: "Second sentence." },
93
+ { type: "done", inputTokens: 10, outputTokens: 5 },
94
+ ]);
95
+ const r = await runAndGetResult();
96
+ expect(r.output).toBe("Second sentence.");
97
+ });
98
+
99
+ it("preserves buffered text when stream errors mid-way", async () => {
100
+ mockStream(async function* () {
101
+ yield { type: "text", text: "Partial progress so far…" };
102
+ yield { type: "tool_use", toolName: "Bash" };
103
+ throw new Error("network: socket hang up");
104
+ });
105
+ const r = await runAndGetResult();
106
+ // Status can legitimately be "error" or "cancelled" — but output
107
+ // must NOT be an empty string. That's the regression.
108
+ expect(r.output.length).toBeGreaterThan(0);
109
+ expect(r.output).toContain("Partial progress");
110
+ });
111
+
112
+ it("preserves buffered text when the provider yields an error chunk", async () => {
113
+ mockStream([
114
+ { type: "text", text: "Started the task." },
115
+ { type: "text", text: "Started the task. More detail here." },
116
+ { type: "error", error: "Provider 'claude-sdk' failed: Request aborted" },
117
+ ]);
118
+ const r = await runAndGetResult();
119
+ expect(r.output).toContain("More detail");
120
+ });
121
+
122
+ it("returns empty output gracefully when nothing was buffered", async () => {
123
+ mockStream(async function* () {
124
+ throw new Error("immediate failure");
125
+ });
126
+ const r = await runAndGetResult();
127
+ // No text at all → empty is acceptable (nothing to preserve), but
128
+ // status must reflect the failure.
129
+ expect(r.output).toBe("");
130
+ expect(["error", "cancelled", "timeout"]).toContain(r.status);
131
+ });
132
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Fix #12 — grammy error noise filter.
3
+ *
4
+ * Regression: chunks like
5
+ * Fehler: Call to 'editMessageText' failed! (400: Bad Request:
6
+ * message is not modified: specified new message content and reply
7
+ * markup are exactly the same as a current content and reply markup
8
+ * of the message)
9
+ * were being sent to end users 2-3 times per day whenever a live-stream
10
+ * edit raced against itself. The v4.8.8 `bot.catch()` fix swallowed
11
+ * these at the middleware layer, but `telegram.ts` finalize() and
12
+ * `handlers/message.ts` error paths bypass bot.catch completely —
13
+ * they surface the raw grammy error via `ctx.reply()`.
14
+ *
15
+ * Contract: `isHarmlessTelegramError(err)` returns true for:
16
+ * - "message is not modified" (any language, any prefix)
17
+ * - "Call to 'editMessageText' failed" combined with the above
18
+ * - "query is too old" (harmless callback-answer race)
19
+ * - "MESSAGE_ID_INVALID" (user deleted the message before we edited it)
20
+ *
21
+ * Returns false for all other errors — they still need surfacing.
22
+ */
23
+ import { describe, it, expect } from "vitest";
24
+ import { isHarmlessTelegramError } from "../src/util/telegram-error-filter.js";
25
+
26
+ describe("isHarmlessTelegramError (Fix #12)", () => {
27
+ it("matches the exact production message", () => {
28
+ const err = new Error(
29
+ "Call to 'editMessageText' failed! (400: Bad Request: message is not modified: " +
30
+ "specified new message content and reply markup are exactly the same as a current " +
31
+ "content and reply markup of the message)",
32
+ );
33
+ expect(isHarmlessTelegramError(err)).toBe(true);
34
+ });
35
+
36
+ it("matches just the 'message is not modified' substring", () => {
37
+ expect(isHarmlessTelegramError(new Error("400: message is not modified"))).toBe(true);
38
+ });
39
+
40
+ it("matches 'specified new message content ... exactly the same'", () => {
41
+ expect(
42
+ isHarmlessTelegramError(
43
+ new Error("specified new message content and reply markup are exactly the same"),
44
+ ),
45
+ ).toBe(true);
46
+ });
47
+
48
+ it("matches 'query is too old' (answerCallbackQuery race)", () => {
49
+ expect(
50
+ isHarmlessTelegramError(new Error("Bad Request: query is too old and response timeout expired")),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it("matches 'message to edit not found' (user deleted)", () => {
55
+ expect(
56
+ isHarmlessTelegramError(new Error("Bad Request: message to edit not found")),
57
+ ).toBe(true);
58
+ });
59
+
60
+ it("matches MESSAGE_ID_INVALID", () => {
61
+ expect(isHarmlessTelegramError(new Error("Bad Request: MESSAGE_ID_INVALID"))).toBe(true);
62
+ });
63
+
64
+ it("accepts plain strings as well as Error objects", () => {
65
+ expect(isHarmlessTelegramError("message is not modified")).toBe(true);
66
+ });
67
+
68
+ it("accepts undefined / null as not harmless (caller decides)", () => {
69
+ expect(isHarmlessTelegramError(undefined)).toBe(false);
70
+ expect(isHarmlessTelegramError(null)).toBe(false);
71
+ });
72
+
73
+ it("does NOT swallow real errors", () => {
74
+ expect(isHarmlessTelegramError(new Error("Unauthorized"))).toBe(false);
75
+ expect(isHarmlessTelegramError(new Error("Too Many Requests: retry after 5"))).toBe(false);
76
+ expect(isHarmlessTelegramError(new Error("chat not found"))).toBe(false);
77
+ expect(isHarmlessTelegramError(new Error("stream error: provider timeout"))).toBe(false);
78
+ });
79
+
80
+ it("handles nested err.description from grammy", () => {
81
+ const err = new Error("anything") as Error & { description?: string };
82
+ err.description = "Bad Request: message is not modified";
83
+ expect(isHarmlessTelegramError(err)).toBe(true);
84
+ });
85
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Fix #4 — Watchdog brake must actually engage on chronic crashes.
3
+ *
4
+ * Regression: the previous logic reset crashCount after 5 min of clean
5
+ * uptime. Production logs showed the bot crashing ~5 times per hour, but
6
+ * each boot lived just long enough (>5 min, <10 min) to reset the counter.
7
+ * Result: `crashCount` never reached the brake threshold, the bot cycled
8
+ * for hours, and the daily job-alert silently lost its scheduled runs.
9
+ *
10
+ * New contract (pure function pair extracted to watchdog-brake.ts):
11
+ *
12
+ * decideBrakeAction(prevBeacon, now, opts)
13
+ * - returns `{ action: "proceed", crashCount, crashWindowStart }`
14
+ * on clean start or old previous beacon
15
+ * - returns `{ action: "proceed", crashCount: N }` when the last run
16
+ * exited recently but we're still under the brake threshold
17
+ * - returns `{ action: "brake", reason }` when either
18
+ * (a) N+1 crashes in a short window, or
19
+ * (b) the daily crash cap (default 20) is exceeded
20
+ *
21
+ * shouldResetCrashCounter(uptimeMs, opts) → boolean
22
+ * - default policy: only reset after 1 h of clean uptime (NOT 5 min)
23
+ */
24
+ import { describe, it, expect } from "vitest";
25
+ import {
26
+ decideBrakeAction,
27
+ shouldResetCrashCounter,
28
+ DEFAULTS,
29
+ type BeaconData,
30
+ } from "../src/services/watchdog-brake.js";
31
+
32
+ const ONE_MIN = 60_000;
33
+ const ONE_HOUR = 60 * ONE_MIN;
34
+
35
+ function beacon(partial: Partial<BeaconData> = {}): BeaconData {
36
+ return {
37
+ lastBeat: 0,
38
+ pid: 1,
39
+ bootTime: 0,
40
+ crashCount: 0,
41
+ crashWindowStart: 0,
42
+ dailyCrashCount: 0,
43
+ dailyCrashWindowStart: 0,
44
+ version: "test",
45
+ ...partial,
46
+ };
47
+ }
48
+
49
+ describe("decideBrakeAction (Fix #4)", () => {
50
+ it("proceeds on first boot (no previous beacon)", () => {
51
+ const now = 1_000_000;
52
+ const result = decideBrakeAction(null, now);
53
+ expect(result.action).toBe("proceed");
54
+ if (result.action === "proceed") {
55
+ expect(result.crashCount).toBe(0);
56
+ expect(result.crashWindowStart).toBe(now);
57
+ expect(result.dailyCrashCount).toBe(0);
58
+ }
59
+ });
60
+
61
+ it("proceeds when previous beacon is old (>STALE_MS) — clean exit", () => {
62
+ const now = 1_000_000_000;
63
+ const prev = beacon({ lastBeat: now - 10 * ONE_MIN, crashCount: 3 });
64
+ const result = decideBrakeAction(prev, now);
65
+ expect(result.action).toBe("proceed");
66
+ if (result.action === "proceed") {
67
+ // Old beacon → treat as clean, reset window counter (but keep daily)
68
+ expect(result.crashCount).toBe(0);
69
+ }
70
+ });
71
+
72
+ it("counts a restart after a fresh beacon as a crash", () => {
73
+ const now = 1_000_000_000;
74
+ const prev = beacon({
75
+ lastBeat: now - 15_000, // 15 s ago
76
+ crashCount: 2,
77
+ crashWindowStart: now - 5 * ONE_MIN,
78
+ dailyCrashCount: 2,
79
+ dailyCrashWindowStart: now - 2 * ONE_HOUR,
80
+ });
81
+ const result = decideBrakeAction(prev, now);
82
+ expect(result.action).toBe("proceed");
83
+ if (result.action === "proceed") {
84
+ expect(result.crashCount).toBe(3);
85
+ expect(result.dailyCrashCount).toBe(3);
86
+ }
87
+ });
88
+
89
+ it("engages brake when short-window threshold is crossed", () => {
90
+ const now = 1_000_000_000;
91
+ const prev = beacon({
92
+ lastBeat: now - 10_000,
93
+ crashCount: DEFAULTS.SHORT_BRAKE_THRESHOLD - 1, // one more = brake
94
+ crashWindowStart: now - 2 * ONE_MIN,
95
+ dailyCrashCount: 5,
96
+ dailyCrashWindowStart: now - ONE_HOUR,
97
+ });
98
+ const result = decideBrakeAction(prev, now);
99
+ expect(result.action).toBe("brake");
100
+ if (result.action === "brake") {
101
+ expect(result.reason).toMatch(/short.*window|threshold|crashes/i);
102
+ }
103
+ });
104
+
105
+ it("engages brake when daily cap is exceeded", () => {
106
+ const now = 1_000_000_000;
107
+ const prev = beacon({
108
+ lastBeat: now - 10_000,
109
+ crashCount: 1, // short window fine
110
+ crashWindowStart: now - 30 * ONE_MIN,
111
+ dailyCrashCount: DEFAULTS.DAILY_BRAKE_THRESHOLD - 1,
112
+ dailyCrashWindowStart: now - 12 * ONE_HOUR,
113
+ });
114
+ const result = decideBrakeAction(prev, now);
115
+ expect(result.action).toBe("brake");
116
+ if (result.action === "brake") {
117
+ expect(result.reason).toMatch(/daily|day/i);
118
+ }
119
+ });
120
+
121
+ it("rolls over daily counter when 24h window expires", () => {
122
+ const now = 1_000_000_000;
123
+ const prev = beacon({
124
+ lastBeat: now - 10_000,
125
+ crashCount: 1,
126
+ crashWindowStart: now - 30 * ONE_MIN,
127
+ dailyCrashCount: 18, // high
128
+ dailyCrashWindowStart: now - 25 * ONE_HOUR, // but window rolled over
129
+ });
130
+ const result = decideBrakeAction(prev, now);
131
+ expect(result.action).toBe("proceed");
132
+ if (result.action === "proceed") {
133
+ expect(result.dailyCrashCount).toBe(1); // fresh window
134
+ expect(result.dailyCrashWindowStart).toBe(now);
135
+ }
136
+ });
137
+ });
138
+
139
+ describe("shouldResetCrashCounter (Fix #4)", () => {
140
+ it("does NOT reset after 5 min of uptime (old buggy behaviour)", () => {
141
+ expect(shouldResetCrashCounter(5 * ONE_MIN)).toBe(false);
142
+ });
143
+
144
+ it("does NOT reset after 30 min of uptime", () => {
145
+ expect(shouldResetCrashCounter(30 * ONE_MIN)).toBe(false);
146
+ });
147
+
148
+ it("resets after 1 h of clean uptime", () => {
149
+ expect(shouldResetCrashCounter(ONE_HOUR)).toBe(true);
150
+ expect(shouldResetCrashCounter(ONE_HOUR + 1)).toBe(true);
151
+ });
152
+
153
+ it("can be overridden via opts.resetAfterMs", () => {
154
+ expect(shouldResetCrashCounter(10 * ONE_MIN, { resetAfterMs: 10 * ONE_MIN })).toBe(true);
155
+ expect(shouldResetCrashCounter(10 * ONE_MIN - 1, { resetAfterMs: 10 * ONE_MIN })).toBe(false);
156
+ });
157
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Fix #1 — Web server must release port on shutdown.
3
+ *
4
+ * Regression: on bot restart the previous http.Server kept listening on
5
+ * :3100 (because shutdown() never called server.close()). launchd then
6
+ * restarted the bot, the next boot tried server.listen(3100), hit
7
+ * EADDRINUSE, and the bot crashed uncaught. Crash-loop.
8
+ *
9
+ * Contract we're establishing:
10
+ * - src/web/server.ts must export `stopWebServer(server, opts?)`
11
+ * - It must resolve once `server.close()` finishes.
12
+ * - It must force-close idle/active sockets so close() can't hang
13
+ * forever (otherwise shutdown would block on the 5s launchd grace).
14
+ * - After stopWebServer() returns, a fresh http.Server must be able
15
+ * to listen(port) on the same port without EADDRINUSE.
16
+ */
17
+ import { describe, it, expect } from "vitest";
18
+ import http from "http";
19
+ import { once } from "events";
20
+ import { startWebServer, stopWebServer } from "../src/web/server.js";
21
+
22
+ function getFreePort(): Promise<number> {
23
+ return new Promise((resolve, reject) => {
24
+ const s = http.createServer();
25
+ s.listen(0, () => {
26
+ const addr = s.address();
27
+ if (typeof addr === "object" && addr) {
28
+ const p = addr.port;
29
+ s.close(() => resolve(p));
30
+ } else {
31
+ reject(new Error("no address"));
32
+ }
33
+ });
34
+ });
35
+ }
36
+
37
+ describe("stopWebServer (Fix #1)", () => {
38
+ it("closes an http.Server so the port becomes reusable", async () => {
39
+ const port = await getFreePort();
40
+
41
+ const server = http.createServer((_req, res) => {
42
+ res.end("ok");
43
+ });
44
+ await new Promise<void>((r) => server.listen(port, () => r()));
45
+
46
+ // Hold an open idle keep-alive socket — this is the exact state that
47
+ // prevented server.close() from resolving in production. A real
48
+ // stopWebServer() must break this stall.
49
+ const hanger = http.get(`http://127.0.0.1:${port}/`, () => { /* body */ });
50
+ await once(hanger, "response").catch(() => { /* swallow */ });
51
+
52
+ const t0 = Date.now();
53
+ await stopWebServer(server);
54
+ const elapsed = Date.now() - t0;
55
+
56
+ expect(elapsed).toBeLessThan(2000);
57
+
58
+ // Prove the port is actually free: a new server must be able to bind it.
59
+ const reuse = http.createServer();
60
+ await new Promise<void>((resolve, reject) => {
61
+ reuse.once("error", reject);
62
+ reuse.listen(port, () => resolve());
63
+ });
64
+ await new Promise<void>((r) => reuse.close(() => r()));
65
+ });
66
+
67
+ it("is safe to call on an already-closed server", async () => {
68
+ const port = await getFreePort();
69
+ const server = http.createServer();
70
+ await new Promise<void>((r) => server.listen(port, () => r()));
71
+ await stopWebServer(server);
72
+ // Calling twice must not throw
73
+ await expect(stopWebServer(server)).resolves.toBeUndefined();
74
+ });
75
+
76
+ it("is safe to call on a server that never listened", async () => {
77
+ const server = http.createServer();
78
+ await expect(stopWebServer(server)).resolves.toBeUndefined();
79
+ });
80
+
81
+ it("closes even when a client is holding a long-lived connection", async () => {
82
+ // Production-mirror: a long-polling /api/cron?wait=1 or similar.
83
+ // Before the fix, server.close() would hang on these sockets and the
84
+ // 5s launchd grace would kill the bot before the port was released.
85
+ const port = await getFreePort();
86
+ const server = http.createServer((_req, res) => {
87
+ // Never send a full response — keep the socket open until close.
88
+ res.writeHead(200, { "Content-Type": "text/plain" });
89
+ res.write("chunk-1");
90
+ // intentionally do NOT call res.end()
91
+ });
92
+ await new Promise<void>((r) => server.listen(port, () => r()));
93
+
94
+ const req = http.get(`http://127.0.0.1:${port}/hang`);
95
+ // Swallow the inevitable socket-close error once the server is torn down
96
+ req.on("error", () => { /* expected */ });
97
+ await once(req, "response").catch(() => { /* swallow */ });
98
+
99
+ const t0 = Date.now();
100
+ await stopWebServer(server);
101
+ expect(Date.now() - t0).toBeLessThan(2000);
102
+
103
+ // Port is reusable
104
+ const reuse = http.createServer();
105
+ await new Promise<void>((resolve, reject) => {
106
+ reuse.once("error", reject);
107
+ reuse.listen(port, () => resolve());
108
+ });
109
+ await new Promise<void>((r) => reuse.close(() => r()));
110
+ });
111
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Fix #2 — WhatsApp saveCreds must survive a vanished auth directory.
3
+ *
4
+ * Regression: `Unhandled rejection: ENOENT creds.json` in err.log when
5
+ * baileys fired a delayed `creds.update` event after the auth dir was
6
+ * gone (crash mid-init, trash, manual cleanup, etc.).
7
+ *
8
+ * Contract: we export a helper `makeResilientSaveCreds(authDir, inner)`
9
+ * from src/platforms/whatsapp-auth-helpers.ts. It wraps baileys' raw
10
+ * saveCreds so that an ENOENT triggers a mkdir-p + one retry before
11
+ * surfacing the error. Any other error bubbles up unchanged.
12
+ */
13
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
14
+ import fs from "fs";
15
+ import os from "os";
16
+ import { resolve, join } from "path";
17
+ import { makeResilientSaveCreds } from "../src/platforms/whatsapp-auth-helpers.js";
18
+
19
+ let authDir: string;
20
+
21
+ beforeEach(() => {
22
+ authDir = resolve(os.tmpdir(), `alvin-wa-auth-${process.pid}-${Date.now()}`);
23
+ fs.mkdirSync(authDir, { recursive: true });
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (fs.existsSync(authDir)) fs.rmSync(authDir, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("makeResilientSaveCreds (Fix #2)", () => {
31
+ it("calls the inner saveCreds on the happy path", async () => {
32
+ let calls = 0;
33
+ const inner = async () => { calls++; };
34
+ const wrapped = makeResilientSaveCreds(authDir, inner);
35
+ await wrapped();
36
+ expect(calls).toBe(1);
37
+ });
38
+
39
+ it("recreates the auth dir and retries when inner throws ENOENT", async () => {
40
+ let calls = 0;
41
+ const inner = async () => {
42
+ calls++;
43
+ if (calls === 1) {
44
+ // Mirror baileys fs.promises.writeFile behaviour
45
+ const err = new Error(
46
+ `ENOENT: no such file or directory, open '${join(authDir, "creds.json")}'`,
47
+ ) as NodeJS.ErrnoException;
48
+ err.code = "ENOENT";
49
+ throw err;
50
+ }
51
+ };
52
+ // Simulate the vanished dir
53
+ fs.rmSync(authDir, { recursive: true, force: true });
54
+ expect(fs.existsSync(authDir)).toBe(false);
55
+
56
+ const wrapped = makeResilientSaveCreds(authDir, inner);
57
+ await wrapped();
58
+
59
+ expect(calls).toBe(2);
60
+ expect(fs.existsSync(authDir)).toBe(true);
61
+ });
62
+
63
+ it("only retries once — a second ENOENT surfaces as error", async () => {
64
+ let calls = 0;
65
+ const inner = async () => {
66
+ calls++;
67
+ const err = new Error("ENOENT: no such file or directory") as NodeJS.ErrnoException;
68
+ err.code = "ENOENT";
69
+ throw err;
70
+ };
71
+ const wrapped = makeResilientSaveCreds(authDir, inner);
72
+ await expect(wrapped()).rejects.toThrow(/ENOENT/);
73
+ expect(calls).toBe(2);
74
+ });
75
+
76
+ it("surfaces non-ENOENT errors unchanged", async () => {
77
+ const inner = async () => {
78
+ const err = new Error("EACCES: permission denied") as NodeJS.ErrnoException;
79
+ err.code = "EACCES";
80
+ throw err;
81
+ };
82
+ const wrapped = makeResilientSaveCreds(authDir, inner);
83
+ await expect(wrapped()).rejects.toThrow(/EACCES/);
84
+ });
85
+
86
+ it("is safe to call concurrently", async () => {
87
+ let calls = 0;
88
+ const inner = async () => {
89
+ calls++;
90
+ await new Promise((r) => setTimeout(r, 5));
91
+ };
92
+ const wrapped = makeResilientSaveCreds(authDir, inner);
93
+ await Promise.all([wrapped(), wrapped(), wrapped()]);
94
+ expect(calls).toBe(3);
95
+ });
96
+ });