@elvatis_com/openclaw-self-healing-elvatis 0.2.4
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/.ai/handoff/DASHBOARD.md +13 -0
- package/.ai/handoff/LOG.md +4 -0
- package/.ai/handoff/MANIFEST.json +177 -0
- package/.ai/handoff/NEXT_ACTIONS.md +54 -0
- package/.ai/handoff/STATUS.md +20 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/ROADMAP.md +92 -0
- package/SKILL.md +24 -0
- package/index.ts +834 -0
- package/openclaw.plugin.json +110 -0
- package/package.json +23 -0
- package/scripts/create-roadmap-issues.sh +249 -0
- package/test/index.test.ts +2682 -0
- package/test/monitor-integration.test.ts +1403 -0
- package/tsconfig.check.json +9 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
nowSec,
|
|
7
|
+
loadState,
|
|
8
|
+
saveState,
|
|
9
|
+
type State,
|
|
10
|
+
} from "../index.js";
|
|
11
|
+
import register from "../index.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function tmpDir(): string {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "self-heal-integ-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function emptyState(): State {
|
|
22
|
+
return {
|
|
23
|
+
limited: {},
|
|
24
|
+
pendingBackups: {},
|
|
25
|
+
whatsapp: {},
|
|
26
|
+
cron: { failCounts: {}, lastIssueCreatedAt: {} },
|
|
27
|
+
plugins: { lastDisableAt: {} },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mockApi(overrides: Record<string, any> = {}) {
|
|
32
|
+
const handlers: Record<string, Function[]> = {};
|
|
33
|
+
const services: any[] = [];
|
|
34
|
+
const emitted: { event: string; payload: any }[] = [];
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
pluginConfig: overrides.pluginConfig ?? {},
|
|
38
|
+
logger: {
|
|
39
|
+
info: vi.fn(),
|
|
40
|
+
warn: vi.fn(),
|
|
41
|
+
error: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
on(event: string, handler: Function) {
|
|
44
|
+
handlers[event] = handlers[event] || [];
|
|
45
|
+
handlers[event].push(handler);
|
|
46
|
+
},
|
|
47
|
+
emit(event: string, payload: any) {
|
|
48
|
+
emitted.push({ event, payload });
|
|
49
|
+
},
|
|
50
|
+
registerService(svc: any) {
|
|
51
|
+
services.push(svc);
|
|
52
|
+
},
|
|
53
|
+
runtime: {
|
|
54
|
+
system: {
|
|
55
|
+
runCommandWithTimeout: vi.fn().mockResolvedValue({
|
|
56
|
+
exitCode: 1,
|
|
57
|
+
stdout: "",
|
|
58
|
+
stderr: "not available",
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
// test helpers
|
|
63
|
+
_handlers: handlers,
|
|
64
|
+
_services: services,
|
|
65
|
+
_emitted: emitted,
|
|
66
|
+
_emit(event: string, ...args: any[]) {
|
|
67
|
+
for (const h of handlers[event] ?? []) h(...args);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Start the monitor service, wait for the initial tick, then stop. */
|
|
73
|
+
async function runOneTick(api: ReturnType<typeof mockApi>) {
|
|
74
|
+
const svc = api._services[0];
|
|
75
|
+
await svc.start();
|
|
76
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
77
|
+
await svc.stop();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build a command-matching predicate for runCommandWithTimeout call args. */
|
|
81
|
+
function cmdContains(call: any[], fragment: string): boolean {
|
|
82
|
+
const cmd = call[0]?.command?.join(" ") ?? "";
|
|
83
|
+
return cmd.includes(fragment);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Filter all runCommandWithTimeout calls by command fragment. */
|
|
87
|
+
function filterCmdCalls(api: ReturnType<typeof mockApi>, fragment: string): any[][] {
|
|
88
|
+
return api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
89
|
+
(c: any[]) => cmdContains(c, fragment)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Find emitted events by name. */
|
|
94
|
+
function findEmitted(api: ReturnType<typeof mockApi>, eventName: string) {
|
|
95
|
+
return api._emitted.filter((e) => e.event === eventName);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Monitor Integration Tests - Full Tick Cycle
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe("monitor service - integration tick flows", () => {
|
|
103
|
+
let dir: string;
|
|
104
|
+
let stateFile: string;
|
|
105
|
+
let sessionsFile: string;
|
|
106
|
+
let configFile: string;
|
|
107
|
+
let backupsDir: string;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
dir = tmpDir();
|
|
111
|
+
stateFile = path.join(dir, "state.json");
|
|
112
|
+
sessionsFile = path.join(dir, "sessions.json");
|
|
113
|
+
configFile = path.join(dir, "openclaw.json");
|
|
114
|
+
backupsDir = path.join(dir, "backups");
|
|
115
|
+
fs.writeFileSync(configFile, JSON.stringify({ valid: true }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// -------------------------------------------------------------------------
|
|
123
|
+
// WhatsApp disconnect streak -> restart path
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("WhatsApp disconnect streak -> restart path", () => {
|
|
127
|
+
it("increments disconnect streak on each tick with disconnected status", async () => {
|
|
128
|
+
const api = mockApi({
|
|
129
|
+
pluginConfig: {
|
|
130
|
+
stateFile,
|
|
131
|
+
sessionsFile,
|
|
132
|
+
configFile,
|
|
133
|
+
configBackupsDir: backupsDir,
|
|
134
|
+
modelOrder: ["model-a"],
|
|
135
|
+
autoFix: { restartWhatsappOnDisconnect: true, whatsappDisconnectThreshold: 5 },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
139
|
+
exitCode: 0,
|
|
140
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
141
|
+
stderr: "",
|
|
142
|
+
});
|
|
143
|
+
register(api);
|
|
144
|
+
|
|
145
|
+
await runOneTick(api);
|
|
146
|
+
|
|
147
|
+
const state = loadState(stateFile);
|
|
148
|
+
expect(state.whatsapp!.disconnectStreak).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("resets disconnect streak when WhatsApp is connected", async () => {
|
|
152
|
+
saveState(stateFile, {
|
|
153
|
+
...emptyState(),
|
|
154
|
+
whatsapp: { disconnectStreak: 3, lastRestartAt: nowSec() - 1000 },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const api = mockApi({
|
|
158
|
+
pluginConfig: {
|
|
159
|
+
stateFile,
|
|
160
|
+
sessionsFile,
|
|
161
|
+
configFile,
|
|
162
|
+
configBackupsDir: backupsDir,
|
|
163
|
+
modelOrder: ["model-a"],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
167
|
+
exitCode: 0,
|
|
168
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "connected" } } }),
|
|
169
|
+
stderr: "",
|
|
170
|
+
});
|
|
171
|
+
register(api);
|
|
172
|
+
|
|
173
|
+
await runOneTick(api);
|
|
174
|
+
|
|
175
|
+
const state = loadState(stateFile);
|
|
176
|
+
expect(state.whatsapp!.disconnectStreak).toBe(0);
|
|
177
|
+
expect(state.whatsapp!.lastSeenConnectedAt).toBeGreaterThan(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("triggers gateway restart when disconnect streak reaches threshold", async () => {
|
|
181
|
+
// Pre-seed state with streak at threshold - 1 so next tick triggers restart
|
|
182
|
+
saveState(stateFile, {
|
|
183
|
+
...emptyState(),
|
|
184
|
+
whatsapp: { disconnectStreak: 1, lastRestartAt: 0 },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const api = mockApi({
|
|
188
|
+
pluginConfig: {
|
|
189
|
+
stateFile,
|
|
190
|
+
sessionsFile,
|
|
191
|
+
configFile,
|
|
192
|
+
configBackupsDir: backupsDir,
|
|
193
|
+
modelOrder: ["model-a"],
|
|
194
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
198
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
199
|
+
if (cmd.includes("channels status")) {
|
|
200
|
+
return {
|
|
201
|
+
exitCode: 0,
|
|
202
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
203
|
+
stderr: "",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (cmd.includes("gateway restart")) {
|
|
207
|
+
return { exitCode: 0, stdout: "restarted", stderr: "" };
|
|
208
|
+
}
|
|
209
|
+
if (cmd.includes("gateway status")) {
|
|
210
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
211
|
+
}
|
|
212
|
+
return { exitCode: 1, stdout: "", stderr: "unknown" };
|
|
213
|
+
});
|
|
214
|
+
register(api);
|
|
215
|
+
|
|
216
|
+
await runOneTick(api);
|
|
217
|
+
|
|
218
|
+
// Verify gateway restart was called
|
|
219
|
+
const restartCalls = filterCmdCalls(api, "gateway restart");
|
|
220
|
+
expect(restartCalls.length).toBeGreaterThanOrEqual(1);
|
|
221
|
+
|
|
222
|
+
// Verify state was reset
|
|
223
|
+
const state = loadState(stateFile);
|
|
224
|
+
expect(state.whatsapp!.disconnectStreak).toBe(0);
|
|
225
|
+
expect(state.whatsapp!.lastRestartAt).toBeGreaterThan(0);
|
|
226
|
+
|
|
227
|
+
// Verify event emitted
|
|
228
|
+
const events = findEmitted(api, "self-heal:whatsapp-restart");
|
|
229
|
+
expect(events).toHaveLength(1);
|
|
230
|
+
expect(events[0].payload.dryRun).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not restart if minimum restart interval has not elapsed", async () => {
|
|
234
|
+
saveState(stateFile, {
|
|
235
|
+
...emptyState(),
|
|
236
|
+
whatsapp: { disconnectStreak: 5, lastRestartAt: nowSec() - 10 }, // restarted 10s ago
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const api = mockApi({
|
|
240
|
+
pluginConfig: {
|
|
241
|
+
stateFile,
|
|
242
|
+
sessionsFile,
|
|
243
|
+
configFile,
|
|
244
|
+
configBackupsDir: backupsDir,
|
|
245
|
+
modelOrder: ["model-a"],
|
|
246
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 300 },
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
250
|
+
exitCode: 0,
|
|
251
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
252
|
+
stderr: "",
|
|
253
|
+
});
|
|
254
|
+
register(api);
|
|
255
|
+
|
|
256
|
+
await runOneTick(api);
|
|
257
|
+
|
|
258
|
+
// Should NOT have restarted - interval not elapsed
|
|
259
|
+
const restartCalls = filterCmdCalls(api, "gateway restart");
|
|
260
|
+
expect(restartCalls).toHaveLength(0);
|
|
261
|
+
|
|
262
|
+
// Streak should still be incremented
|
|
263
|
+
const state = loadState(stateFile);
|
|
264
|
+
expect(state.whatsapp!.disconnectStreak).toBe(6);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not restart if openclaw.json config is invalid", async () => {
|
|
268
|
+
// Make the config file invalid
|
|
269
|
+
fs.writeFileSync(configFile, "NOT VALID JSON{{{");
|
|
270
|
+
saveState(stateFile, {
|
|
271
|
+
...emptyState(),
|
|
272
|
+
whatsapp: { disconnectStreak: 5, lastRestartAt: 0 },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const api = mockApi({
|
|
276
|
+
pluginConfig: {
|
|
277
|
+
stateFile,
|
|
278
|
+
sessionsFile,
|
|
279
|
+
configFile,
|
|
280
|
+
configBackupsDir: backupsDir,
|
|
281
|
+
modelOrder: ["model-a"],
|
|
282
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
286
|
+
exitCode: 0,
|
|
287
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
288
|
+
stderr: "",
|
|
289
|
+
});
|
|
290
|
+
register(api);
|
|
291
|
+
|
|
292
|
+
await runOneTick(api);
|
|
293
|
+
|
|
294
|
+
// Should NOT have restarted
|
|
295
|
+
const restartCalls = filterCmdCalls(api, "gateway restart");
|
|
296
|
+
expect(restartCalls).toHaveLength(0);
|
|
297
|
+
|
|
298
|
+
// Should log error about invalid config
|
|
299
|
+
expect(api.logger.error).toHaveBeenCalledWith(
|
|
300
|
+
expect.stringContaining("NOT restarting gateway: openclaw.json invalid")
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("backs up config before gateway restart and cleans up after", async () => {
|
|
305
|
+
saveState(stateFile, {
|
|
306
|
+
...emptyState(),
|
|
307
|
+
whatsapp: { disconnectStreak: 1, lastRestartAt: 0 },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const commandLog: string[] = [];
|
|
311
|
+
const api = mockApi({
|
|
312
|
+
pluginConfig: {
|
|
313
|
+
stateFile,
|
|
314
|
+
sessionsFile,
|
|
315
|
+
configFile,
|
|
316
|
+
configBackupsDir: backupsDir,
|
|
317
|
+
modelOrder: ["model-a"],
|
|
318
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
322
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
323
|
+
commandLog.push(cmd);
|
|
324
|
+
if (cmd.includes("channels status")) {
|
|
325
|
+
return {
|
|
326
|
+
exitCode: 0,
|
|
327
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
328
|
+
stderr: "",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (cmd.includes("gateway restart")) {
|
|
332
|
+
return { exitCode: 0, stdout: "restarted", stderr: "" };
|
|
333
|
+
}
|
|
334
|
+
if (cmd.includes("gateway status")) {
|
|
335
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
336
|
+
}
|
|
337
|
+
return { exitCode: 1, stdout: "", stderr: "" };
|
|
338
|
+
});
|
|
339
|
+
register(api);
|
|
340
|
+
|
|
341
|
+
await runOneTick(api);
|
|
342
|
+
|
|
343
|
+
// Verify backup was created
|
|
344
|
+
expect(api.logger.info).toHaveBeenCalledWith(
|
|
345
|
+
expect.stringContaining("backed up openclaw.json (pre-gateway-restart)")
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Verify restart happened after backup
|
|
349
|
+
const restartIdx = commandLog.findIndex((c) => c.includes("gateway restart"));
|
|
350
|
+
expect(restartIdx).toBeGreaterThan(-1);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// -------------------------------------------------------------------------
|
|
355
|
+
// Cron failure accumulation -> disable + issue create path
|
|
356
|
+
// -------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe("cron failure accumulation -> disable + issue create path", () => {
|
|
359
|
+
it("accumulates cron failure counts across ticks", async () => {
|
|
360
|
+
const api = mockApi({
|
|
361
|
+
pluginConfig: {
|
|
362
|
+
stateFile,
|
|
363
|
+
sessionsFile,
|
|
364
|
+
configFile,
|
|
365
|
+
configBackupsDir: backupsDir,
|
|
366
|
+
modelOrder: ["model-a"],
|
|
367
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 5 },
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
371
|
+
exitCode: 0,
|
|
372
|
+
stdout: JSON.stringify({
|
|
373
|
+
jobs: [{ id: "cron-1", name: "daily-report", state: { lastStatus: "error", lastError: "timeout" } }],
|
|
374
|
+
}),
|
|
375
|
+
stderr: "",
|
|
376
|
+
});
|
|
377
|
+
register(api);
|
|
378
|
+
|
|
379
|
+
// Run two ticks
|
|
380
|
+
await runOneTick(api);
|
|
381
|
+
let state = loadState(stateFile);
|
|
382
|
+
expect(state.cron!.failCounts!["cron-1"]).toBe(1);
|
|
383
|
+
|
|
384
|
+
// Second tick: re-register to get fresh service registration
|
|
385
|
+
const api2 = mockApi({
|
|
386
|
+
pluginConfig: {
|
|
387
|
+
stateFile,
|
|
388
|
+
sessionsFile,
|
|
389
|
+
configFile,
|
|
390
|
+
configBackupsDir: backupsDir,
|
|
391
|
+
modelOrder: ["model-a"],
|
|
392
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 5 },
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
api2.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
396
|
+
exitCode: 0,
|
|
397
|
+
stdout: JSON.stringify({
|
|
398
|
+
jobs: [{ id: "cron-1", name: "daily-report", state: { lastStatus: "error", lastError: "timeout" } }],
|
|
399
|
+
}),
|
|
400
|
+
stderr: "",
|
|
401
|
+
});
|
|
402
|
+
register(api2);
|
|
403
|
+
|
|
404
|
+
await runOneTick(api2);
|
|
405
|
+
state = loadState(stateFile);
|
|
406
|
+
expect(state.cron!.failCounts!["cron-1"]).toBe(2);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("resets fail count when cron job succeeds", async () => {
|
|
410
|
+
saveState(stateFile, {
|
|
411
|
+
...emptyState(),
|
|
412
|
+
cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const api = mockApi({
|
|
416
|
+
pluginConfig: {
|
|
417
|
+
stateFile,
|
|
418
|
+
sessionsFile,
|
|
419
|
+
configFile,
|
|
420
|
+
configBackupsDir: backupsDir,
|
|
421
|
+
modelOrder: ["model-a"],
|
|
422
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
426
|
+
exitCode: 0,
|
|
427
|
+
stdout: JSON.stringify({
|
|
428
|
+
jobs: [{ id: "cron-1", name: "daily-report", state: { lastStatus: "ok" } }],
|
|
429
|
+
}),
|
|
430
|
+
stderr: "",
|
|
431
|
+
});
|
|
432
|
+
register(api);
|
|
433
|
+
|
|
434
|
+
await runOneTick(api);
|
|
435
|
+
|
|
436
|
+
const state = loadState(stateFile);
|
|
437
|
+
expect(state.cron!.failCounts!["cron-1"]).toBe(0);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("disables cron and creates issue when failure threshold is reached", async () => {
|
|
441
|
+
saveState(stateFile, {
|
|
442
|
+
...emptyState(),
|
|
443
|
+
cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const api = mockApi({
|
|
447
|
+
pluginConfig: {
|
|
448
|
+
stateFile,
|
|
449
|
+
sessionsFile,
|
|
450
|
+
configFile,
|
|
451
|
+
configBackupsDir: backupsDir,
|
|
452
|
+
modelOrder: ["model-a"],
|
|
453
|
+
autoFix: {
|
|
454
|
+
disableFailingCrons: true,
|
|
455
|
+
cronFailThreshold: 3,
|
|
456
|
+
issueCooldownSec: 0,
|
|
457
|
+
issueRepo: "elvatis/test-repo",
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
462
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
463
|
+
if (cmd.includes("cron list")) {
|
|
464
|
+
return {
|
|
465
|
+
exitCode: 0,
|
|
466
|
+
stdout: JSON.stringify({
|
|
467
|
+
jobs: [{
|
|
468
|
+
id: "cron-1",
|
|
469
|
+
name: "daily-report",
|
|
470
|
+
state: { lastStatus: "error", lastError: "Connection refused" },
|
|
471
|
+
}],
|
|
472
|
+
}),
|
|
473
|
+
stderr: "",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (cmd.includes("gateway status")) {
|
|
477
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
478
|
+
}
|
|
479
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
480
|
+
});
|
|
481
|
+
register(api);
|
|
482
|
+
|
|
483
|
+
await runOneTick(api);
|
|
484
|
+
|
|
485
|
+
// Verify cron was disabled
|
|
486
|
+
const disableCalls = filterCmdCalls(api, "cron edit cron-1 --disable");
|
|
487
|
+
expect(disableCalls).toHaveLength(1);
|
|
488
|
+
|
|
489
|
+
// Verify issue was created
|
|
490
|
+
const issueCalls = filterCmdCalls(api, "gh issue create");
|
|
491
|
+
expect(issueCalls).toHaveLength(1);
|
|
492
|
+
|
|
493
|
+
// Verify event emitted
|
|
494
|
+
const events = findEmitted(api, "self-heal:cron-disabled");
|
|
495
|
+
expect(events).toHaveLength(1);
|
|
496
|
+
expect(events[0].payload.cronId).toBe("cron-1");
|
|
497
|
+
expect(events[0].payload.cronName).toBe("daily-report");
|
|
498
|
+
expect(events[0].payload.consecutiveFailures).toBe(3);
|
|
499
|
+
|
|
500
|
+
// Verify fail count reset after disable
|
|
501
|
+
const state = loadState(stateFile);
|
|
502
|
+
expect(state.cron!.failCounts!["cron-1"]).toBe(0);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("rate-limits issue creation via issueCooldownSec", async () => {
|
|
506
|
+
saveState(stateFile, {
|
|
507
|
+
...emptyState(),
|
|
508
|
+
cron: {
|
|
509
|
+
failCounts: { "cron-1": 2 },
|
|
510
|
+
lastIssueCreatedAt: { "cron-1": nowSec() - 100 }, // created 100s ago
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const api = mockApi({
|
|
515
|
+
pluginConfig: {
|
|
516
|
+
stateFile,
|
|
517
|
+
sessionsFile,
|
|
518
|
+
configFile,
|
|
519
|
+
configBackupsDir: backupsDir,
|
|
520
|
+
modelOrder: ["model-a"],
|
|
521
|
+
autoFix: {
|
|
522
|
+
disableFailingCrons: true,
|
|
523
|
+
cronFailThreshold: 3,
|
|
524
|
+
issueCooldownSec: 3600, // 1 hour cooldown
|
|
525
|
+
issueRepo: "elvatis/test-repo",
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
530
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
531
|
+
if (cmd.includes("cron list")) {
|
|
532
|
+
return {
|
|
533
|
+
exitCode: 0,
|
|
534
|
+
stdout: JSON.stringify({
|
|
535
|
+
jobs: [{
|
|
536
|
+
id: "cron-1",
|
|
537
|
+
name: "daily-report",
|
|
538
|
+
state: { lastStatus: "error", lastError: "timeout" },
|
|
539
|
+
}],
|
|
540
|
+
}),
|
|
541
|
+
stderr: "",
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (cmd.includes("gateway status")) {
|
|
545
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
546
|
+
}
|
|
547
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
548
|
+
});
|
|
549
|
+
register(api);
|
|
550
|
+
|
|
551
|
+
await runOneTick(api);
|
|
552
|
+
|
|
553
|
+
// Cron should still be disabled
|
|
554
|
+
const disableCalls = filterCmdCalls(api, "cron edit cron-1 --disable");
|
|
555
|
+
expect(disableCalls).toHaveLength(1);
|
|
556
|
+
|
|
557
|
+
// But issue should NOT be created (cooldown not elapsed)
|
|
558
|
+
const issueCalls = filterCmdCalls(api, "gh issue create");
|
|
559
|
+
expect(issueCalls).toHaveLength(0);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("does not disable cron if config file is invalid", async () => {
|
|
563
|
+
fs.writeFileSync(configFile, "INVALID JSON!!!");
|
|
564
|
+
saveState(stateFile, {
|
|
565
|
+
...emptyState(),
|
|
566
|
+
cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const api = mockApi({
|
|
570
|
+
pluginConfig: {
|
|
571
|
+
stateFile,
|
|
572
|
+
sessionsFile,
|
|
573
|
+
configFile,
|
|
574
|
+
configBackupsDir: backupsDir,
|
|
575
|
+
modelOrder: ["model-a"],
|
|
576
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
580
|
+
exitCode: 0,
|
|
581
|
+
stdout: JSON.stringify({
|
|
582
|
+
jobs: [{
|
|
583
|
+
id: "cron-1",
|
|
584
|
+
name: "daily-report",
|
|
585
|
+
state: { lastStatus: "error", lastError: "fail" },
|
|
586
|
+
}],
|
|
587
|
+
}),
|
|
588
|
+
stderr: "",
|
|
589
|
+
});
|
|
590
|
+
register(api);
|
|
591
|
+
|
|
592
|
+
await runOneTick(api);
|
|
593
|
+
|
|
594
|
+
// Should NOT have disabled cron
|
|
595
|
+
const disableCalls = filterCmdCalls(api, "cron edit");
|
|
596
|
+
expect(disableCalls).toHaveLength(0);
|
|
597
|
+
|
|
598
|
+
// Should log error
|
|
599
|
+
expect(api.logger.error).toHaveBeenCalledWith(
|
|
600
|
+
expect.stringContaining("NOT disabling cron: openclaw.json invalid")
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("tracks multiple cron jobs independently", async () => {
|
|
605
|
+
const api = mockApi({
|
|
606
|
+
pluginConfig: {
|
|
607
|
+
stateFile,
|
|
608
|
+
sessionsFile,
|
|
609
|
+
configFile,
|
|
610
|
+
configBackupsDir: backupsDir,
|
|
611
|
+
modelOrder: ["model-a"],
|
|
612
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
616
|
+
exitCode: 0,
|
|
617
|
+
stdout: JSON.stringify({
|
|
618
|
+
jobs: [
|
|
619
|
+
{ id: "cron-1", name: "report", state: { lastStatus: "error", lastError: "fail" } },
|
|
620
|
+
{ id: "cron-2", name: "backup", state: { lastStatus: "ok" } },
|
|
621
|
+
{ id: "cron-3", name: "cleanup", state: { lastStatus: "error", lastError: "disk full" } },
|
|
622
|
+
],
|
|
623
|
+
}),
|
|
624
|
+
stderr: "",
|
|
625
|
+
});
|
|
626
|
+
register(api);
|
|
627
|
+
|
|
628
|
+
await runOneTick(api);
|
|
629
|
+
|
|
630
|
+
const state = loadState(stateFile);
|
|
631
|
+
expect(state.cron!.failCounts!["cron-1"]).toBe(1);
|
|
632
|
+
expect(state.cron!.failCounts!["cron-2"]).toBe(0);
|
|
633
|
+
expect(state.cron!.failCounts!["cron-3"]).toBe(1);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// -------------------------------------------------------------------------
|
|
638
|
+
// Active model recovery probe -> cooldown removal path
|
|
639
|
+
// -------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
describe("active model recovery probe -> cooldown removal path", () => {
|
|
642
|
+
it("removes model from cooldown when probe succeeds during tick", async () => {
|
|
643
|
+
const hitAt = nowSec() - 400;
|
|
644
|
+
saveState(stateFile, {
|
|
645
|
+
...emptyState(),
|
|
646
|
+
limited: {
|
|
647
|
+
"model-a": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const api = mockApi({
|
|
652
|
+
pluginConfig: {
|
|
653
|
+
stateFile,
|
|
654
|
+
sessionsFile,
|
|
655
|
+
configFile,
|
|
656
|
+
configBackupsDir: backupsDir,
|
|
657
|
+
modelOrder: ["model-a", "model-b"],
|
|
658
|
+
probeEnabled: true,
|
|
659
|
+
probeIntervalSec: 300,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
663
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
664
|
+
if (cmd.includes("model probe")) {
|
|
665
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
666
|
+
}
|
|
667
|
+
return { exitCode: 1, stdout: "", stderr: "" };
|
|
668
|
+
});
|
|
669
|
+
register(api);
|
|
670
|
+
|
|
671
|
+
await runOneTick(api);
|
|
672
|
+
|
|
673
|
+
const state = loadState(stateFile);
|
|
674
|
+
expect(state.limited["model-a"]).toBeUndefined();
|
|
675
|
+
|
|
676
|
+
const events = findEmitted(api, "self-heal:model-recovered");
|
|
677
|
+
expect(events).toHaveLength(1);
|
|
678
|
+
expect(events[0].payload.model).toBe("model-a");
|
|
679
|
+
expect(events[0].payload.isPreferred).toBe(true);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("updates lastProbeAt but keeps cooldown when probe fails", async () => {
|
|
683
|
+
const hitAt = nowSec() - 400;
|
|
684
|
+
saveState(stateFile, {
|
|
685
|
+
...emptyState(),
|
|
686
|
+
limited: {
|
|
687
|
+
"model-a": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const api = mockApi({
|
|
692
|
+
pluginConfig: {
|
|
693
|
+
stateFile,
|
|
694
|
+
sessionsFile,
|
|
695
|
+
configFile,
|
|
696
|
+
configBackupsDir: backupsDir,
|
|
697
|
+
modelOrder: ["model-a", "model-b"],
|
|
698
|
+
probeEnabled: true,
|
|
699
|
+
probeIntervalSec: 300,
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
703
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
704
|
+
if (cmd.includes("model probe")) {
|
|
705
|
+
return { exitCode: 1, stdout: "", stderr: "still limited" };
|
|
706
|
+
}
|
|
707
|
+
return { exitCode: 1, stdout: "", stderr: "" };
|
|
708
|
+
});
|
|
709
|
+
register(api);
|
|
710
|
+
|
|
711
|
+
await runOneTick(api);
|
|
712
|
+
|
|
713
|
+
const state = loadState(stateFile);
|
|
714
|
+
expect(state.limited["model-a"]).toBeDefined();
|
|
715
|
+
expect(state.limited["model-a"].lastProbeAt).toBeGreaterThan(0);
|
|
716
|
+
|
|
717
|
+
const events = findEmitted(api, "self-heal:model-recovered");
|
|
718
|
+
expect(events).toHaveLength(0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("sets isPreferred=false for non-primary model recovery", async () => {
|
|
722
|
+
const hitAt = nowSec() - 400;
|
|
723
|
+
saveState(stateFile, {
|
|
724
|
+
...emptyState(),
|
|
725
|
+
limited: {
|
|
726
|
+
"model-b": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const api = mockApi({
|
|
731
|
+
pluginConfig: {
|
|
732
|
+
stateFile,
|
|
733
|
+
sessionsFile,
|
|
734
|
+
configFile,
|
|
735
|
+
configBackupsDir: backupsDir,
|
|
736
|
+
modelOrder: ["model-a", "model-b"],
|
|
737
|
+
probeEnabled: true,
|
|
738
|
+
probeIntervalSec: 300,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
742
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
743
|
+
if (cmd.includes("model probe")) {
|
|
744
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
745
|
+
}
|
|
746
|
+
return { exitCode: 1, stdout: "", stderr: "" };
|
|
747
|
+
});
|
|
748
|
+
register(api);
|
|
749
|
+
|
|
750
|
+
await runOneTick(api);
|
|
751
|
+
|
|
752
|
+
const events = findEmitted(api, "self-heal:model-recovered");
|
|
753
|
+
expect(events).toHaveLength(1);
|
|
754
|
+
expect(events[0].payload.isPreferred).toBe(false);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// -------------------------------------------------------------------------
|
|
759
|
+
// Config hot-reload during tick
|
|
760
|
+
// -------------------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
describe("config hot-reload during tick", () => {
|
|
763
|
+
it("applies new whatsappDisconnectThreshold from reloaded config", async () => {
|
|
764
|
+
saveState(stateFile, {
|
|
765
|
+
...emptyState(),
|
|
766
|
+
whatsapp: { disconnectStreak: 2, lastRestartAt: 0 },
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const api = mockApi({
|
|
770
|
+
pluginConfig: {
|
|
771
|
+
stateFile,
|
|
772
|
+
sessionsFile,
|
|
773
|
+
configFile,
|
|
774
|
+
configBackupsDir: backupsDir,
|
|
775
|
+
modelOrder: ["model-a"],
|
|
776
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
780
|
+
exitCode: 0,
|
|
781
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
782
|
+
stderr: "",
|
|
783
|
+
});
|
|
784
|
+
register(api);
|
|
785
|
+
|
|
786
|
+
// Raise threshold before tick so restart should NOT happen
|
|
787
|
+
api.pluginConfig = {
|
|
788
|
+
stateFile,
|
|
789
|
+
sessionsFile,
|
|
790
|
+
configFile,
|
|
791
|
+
configBackupsDir: backupsDir,
|
|
792
|
+
modelOrder: ["model-a"],
|
|
793
|
+
autoFix: { whatsappDisconnectThreshold: 10, whatsappMinRestartIntervalSec: 60 },
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
await runOneTick(api);
|
|
797
|
+
|
|
798
|
+
// Should NOT restart since threshold increased to 10
|
|
799
|
+
const restartCalls = filterCmdCalls(api, "gateway restart");
|
|
800
|
+
expect(restartCalls).toHaveLength(0);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("applies new cronFailThreshold from reloaded config", async () => {
|
|
804
|
+
saveState(stateFile, {
|
|
805
|
+
...emptyState(),
|
|
806
|
+
cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
const api = mockApi({
|
|
810
|
+
pluginConfig: {
|
|
811
|
+
stateFile,
|
|
812
|
+
sessionsFile,
|
|
813
|
+
configFile,
|
|
814
|
+
configBackupsDir: backupsDir,
|
|
815
|
+
modelOrder: ["model-a"],
|
|
816
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
820
|
+
exitCode: 0,
|
|
821
|
+
stdout: JSON.stringify({
|
|
822
|
+
jobs: [{ id: "cron-1", name: "report", state: { lastStatus: "error", lastError: "fail" } }],
|
|
823
|
+
}),
|
|
824
|
+
stderr: "",
|
|
825
|
+
});
|
|
826
|
+
register(api);
|
|
827
|
+
|
|
828
|
+
// Raise threshold so cron should NOT be disabled
|
|
829
|
+
api.pluginConfig = {
|
|
830
|
+
stateFile,
|
|
831
|
+
sessionsFile,
|
|
832
|
+
configFile,
|
|
833
|
+
configBackupsDir: backupsDir,
|
|
834
|
+
modelOrder: ["model-a"],
|
|
835
|
+
autoFix: { disableFailingCrons: true, cronFailThreshold: 10 },
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
await runOneTick(api);
|
|
839
|
+
|
|
840
|
+
const disableCalls = filterCmdCalls(api, "cron edit");
|
|
841
|
+
expect(disableCalls).toHaveLength(0);
|
|
842
|
+
|
|
843
|
+
// Fail count should still increment
|
|
844
|
+
const state = loadState(stateFile);
|
|
845
|
+
expect(state.cron!.failCounts!["cron-1"]).toBe(3);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("applies new dryRun flag from reloaded config mid-session", async () => {
|
|
849
|
+
saveState(stateFile, {
|
|
850
|
+
...emptyState(),
|
|
851
|
+
whatsapp: { disconnectStreak: 5, lastRestartAt: 0 },
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
const api = mockApi({
|
|
855
|
+
pluginConfig: {
|
|
856
|
+
stateFile,
|
|
857
|
+
sessionsFile,
|
|
858
|
+
configFile,
|
|
859
|
+
configBackupsDir: backupsDir,
|
|
860
|
+
modelOrder: ["model-a"],
|
|
861
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
865
|
+
exitCode: 0,
|
|
866
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
867
|
+
stderr: "",
|
|
868
|
+
});
|
|
869
|
+
register(api);
|
|
870
|
+
|
|
871
|
+
// Switch to dry-run before tick
|
|
872
|
+
api.pluginConfig = {
|
|
873
|
+
stateFile,
|
|
874
|
+
sessionsFile,
|
|
875
|
+
configFile,
|
|
876
|
+
configBackupsDir: backupsDir,
|
|
877
|
+
modelOrder: ["model-a"],
|
|
878
|
+
dryRun: true,
|
|
879
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
await runOneTick(api);
|
|
883
|
+
|
|
884
|
+
// Should NOT call actual restart (dry-run)
|
|
885
|
+
const restartCalls = filterCmdCalls(api, "gateway restart");
|
|
886
|
+
expect(restartCalls).toHaveLength(0);
|
|
887
|
+
|
|
888
|
+
// Should log dry-run message
|
|
889
|
+
expect(api.logger.info).toHaveBeenCalledWith(
|
|
890
|
+
expect.stringContaining("[dry-run] would restart gateway")
|
|
891
|
+
);
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// -------------------------------------------------------------------------
|
|
896
|
+
// Dry-run flag suppresses all side-effects
|
|
897
|
+
// -------------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
describe("dry-run flag suppresses all side-effects", () => {
|
|
900
|
+
it("does not restart gateway in dry-run mode", async () => {
|
|
901
|
+
saveState(stateFile, {
|
|
902
|
+
...emptyState(),
|
|
903
|
+
whatsapp: { disconnectStreak: 5, lastRestartAt: 0 },
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
const api = mockApi({
|
|
907
|
+
pluginConfig: {
|
|
908
|
+
stateFile,
|
|
909
|
+
sessionsFile,
|
|
910
|
+
configFile,
|
|
911
|
+
configBackupsDir: backupsDir,
|
|
912
|
+
modelOrder: ["model-a"],
|
|
913
|
+
dryRun: true,
|
|
914
|
+
autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
|
|
915
|
+
},
|
|
916
|
+
});
|
|
917
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
918
|
+
exitCode: 0,
|
|
919
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
|
|
920
|
+
stderr: "",
|
|
921
|
+
});
|
|
922
|
+
register(api);
|
|
923
|
+
|
|
924
|
+
await runOneTick(api);
|
|
925
|
+
|
|
926
|
+
const restartCalls = filterCmdCalls(api, "gateway restart");
|
|
927
|
+
expect(restartCalls).toHaveLength(0);
|
|
928
|
+
|
|
929
|
+
// But state should still be updated
|
|
930
|
+
const state = loadState(stateFile);
|
|
931
|
+
expect(state.whatsapp!.disconnectStreak).toBe(0);
|
|
932
|
+
expect(state.whatsapp!.lastRestartAt).toBeGreaterThan(0);
|
|
933
|
+
|
|
934
|
+
// dry-run event should still be emitted
|
|
935
|
+
const events = findEmitted(api, "self-heal:whatsapp-restart");
|
|
936
|
+
expect(events).toHaveLength(1);
|
|
937
|
+
expect(events[0].payload.dryRun).toBe(true);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("does not disable cron in dry-run mode", async () => {
|
|
941
|
+
saveState(stateFile, {
|
|
942
|
+
...emptyState(),
|
|
943
|
+
cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
const api = mockApi({
|
|
947
|
+
pluginConfig: {
|
|
948
|
+
stateFile,
|
|
949
|
+
sessionsFile,
|
|
950
|
+
configFile,
|
|
951
|
+
configBackupsDir: backupsDir,
|
|
952
|
+
modelOrder: ["model-a"],
|
|
953
|
+
dryRun: true,
|
|
954
|
+
autoFix: {
|
|
955
|
+
disableFailingCrons: true,
|
|
956
|
+
cronFailThreshold: 3,
|
|
957
|
+
issueCooldownSec: 0,
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
962
|
+
exitCode: 0,
|
|
963
|
+
stdout: JSON.stringify({
|
|
964
|
+
jobs: [{
|
|
965
|
+
id: "cron-1",
|
|
966
|
+
name: "daily-report",
|
|
967
|
+
state: { lastStatus: "error", lastError: "timeout" },
|
|
968
|
+
}],
|
|
969
|
+
}),
|
|
970
|
+
stderr: "",
|
|
971
|
+
});
|
|
972
|
+
register(api);
|
|
973
|
+
|
|
974
|
+
await runOneTick(api);
|
|
975
|
+
|
|
976
|
+
// Should NOT call cron edit or gh issue create
|
|
977
|
+
const disableCalls = filterCmdCalls(api, "cron edit");
|
|
978
|
+
expect(disableCalls).toHaveLength(0);
|
|
979
|
+
const issueCalls = filterCmdCalls(api, "gh issue create");
|
|
980
|
+
expect(issueCalls).toHaveLength(0);
|
|
981
|
+
|
|
982
|
+
// Should log dry-run messages
|
|
983
|
+
expect(api.logger.info).toHaveBeenCalledWith(
|
|
984
|
+
expect.stringContaining("[dry-run] would disable cron")
|
|
985
|
+
);
|
|
986
|
+
expect(api.logger.info).toHaveBeenCalledWith(
|
|
987
|
+
expect.stringContaining("[dry-run] would create GitHub issue")
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// Should emit event with dryRun=true
|
|
991
|
+
const events = findEmitted(api, "self-heal:cron-disabled");
|
|
992
|
+
expect(events).toHaveLength(1);
|
|
993
|
+
expect(events[0].payload.dryRun).toBe(true);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it("does not probe models in dry-run mode", async () => {
|
|
997
|
+
const hitAt = nowSec() - 400;
|
|
998
|
+
saveState(stateFile, {
|
|
999
|
+
...emptyState(),
|
|
1000
|
+
limited: {
|
|
1001
|
+
"model-a": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const api = mockApi({
|
|
1006
|
+
pluginConfig: {
|
|
1007
|
+
stateFile,
|
|
1008
|
+
sessionsFile,
|
|
1009
|
+
configFile,
|
|
1010
|
+
configBackupsDir: backupsDir,
|
|
1011
|
+
modelOrder: ["model-a", "model-b"],
|
|
1012
|
+
dryRun: true,
|
|
1013
|
+
probeEnabled: true,
|
|
1014
|
+
probeIntervalSec: 300,
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
register(api);
|
|
1018
|
+
|
|
1019
|
+
await runOneTick(api);
|
|
1020
|
+
|
|
1021
|
+
const probeCalls = filterCmdCalls(api, "model probe");
|
|
1022
|
+
expect(probeCalls).toHaveLength(0);
|
|
1023
|
+
|
|
1024
|
+
expect(api.logger.info).toHaveBeenCalledWith(
|
|
1025
|
+
expect.stringContaining("[dry-run] would probe model model-a")
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
// Model should still be in cooldown
|
|
1029
|
+
const state = loadState(stateFile);
|
|
1030
|
+
expect(state.limited["model-a"]).toBeDefined();
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// -------------------------------------------------------------------------
|
|
1035
|
+
// Status snapshot emission on every tick
|
|
1036
|
+
// -------------------------------------------------------------------------
|
|
1037
|
+
|
|
1038
|
+
describe("status snapshot on every tick", () => {
|
|
1039
|
+
it("emits self-heal:status event with correct health status", async () => {
|
|
1040
|
+
const api = mockApi({
|
|
1041
|
+
pluginConfig: {
|
|
1042
|
+
stateFile,
|
|
1043
|
+
sessionsFile,
|
|
1044
|
+
configFile,
|
|
1045
|
+
configBackupsDir: backupsDir,
|
|
1046
|
+
modelOrder: ["model-a", "model-b"],
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
register(api);
|
|
1050
|
+
|
|
1051
|
+
await runOneTick(api);
|
|
1052
|
+
|
|
1053
|
+
const events = findEmitted(api, "self-heal:status");
|
|
1054
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
1055
|
+
const snapshot = events[0].payload;
|
|
1056
|
+
expect(snapshot.health).toBe("healthy");
|
|
1057
|
+
expect(snapshot.activeModel).toBe("model-a");
|
|
1058
|
+
expect(snapshot.models).toHaveLength(2);
|
|
1059
|
+
expect(snapshot.generatedAt).toBeGreaterThan(0);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it("emits degraded health when a model is in cooldown", async () => {
|
|
1063
|
+
saveState(stateFile, {
|
|
1064
|
+
...emptyState(),
|
|
1065
|
+
limited: {
|
|
1066
|
+
"model-a": { lastHitAt: nowSec() - 10, nextAvailableAt: nowSec() + 9999 },
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const api = mockApi({
|
|
1071
|
+
pluginConfig: {
|
|
1072
|
+
stateFile,
|
|
1073
|
+
sessionsFile,
|
|
1074
|
+
configFile,
|
|
1075
|
+
configBackupsDir: backupsDir,
|
|
1076
|
+
modelOrder: ["model-a", "model-b"],
|
|
1077
|
+
probeEnabled: false,
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
register(api);
|
|
1081
|
+
|
|
1082
|
+
await runOneTick(api);
|
|
1083
|
+
|
|
1084
|
+
const events = findEmitted(api, "self-heal:status");
|
|
1085
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
1086
|
+
expect(events[0].payload.health).toBe("degraded");
|
|
1087
|
+
expect(events[0].payload.activeModel).toBe("model-b");
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("emits healing health when all models are in cooldown", async () => {
|
|
1091
|
+
saveState(stateFile, {
|
|
1092
|
+
...emptyState(),
|
|
1093
|
+
limited: {
|
|
1094
|
+
"model-a": { lastHitAt: nowSec() - 10, nextAvailableAt: nowSec() + 9999 },
|
|
1095
|
+
"model-b": { lastHitAt: nowSec() - 10, nextAvailableAt: nowSec() + 9999 },
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
const api = mockApi({
|
|
1100
|
+
pluginConfig: {
|
|
1101
|
+
stateFile,
|
|
1102
|
+
sessionsFile,
|
|
1103
|
+
configFile,
|
|
1104
|
+
configBackupsDir: backupsDir,
|
|
1105
|
+
modelOrder: ["model-a", "model-b"],
|
|
1106
|
+
probeEnabled: false,
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
register(api);
|
|
1110
|
+
|
|
1111
|
+
await runOneTick(api);
|
|
1112
|
+
|
|
1113
|
+
const events = findEmitted(api, "self-heal:status");
|
|
1114
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
1115
|
+
expect(events[0].payload.health).toBe("healing");
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("includes WhatsApp and cron status in snapshot", async () => {
|
|
1119
|
+
saveState(stateFile, {
|
|
1120
|
+
...emptyState(),
|
|
1121
|
+
whatsapp: { disconnectStreak: 3, lastSeenConnectedAt: nowSec() - 60 },
|
|
1122
|
+
cron: { failCounts: { "c1": 2, "c2": 0 }, lastIssueCreatedAt: {} },
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
const api = mockApi({
|
|
1126
|
+
pluginConfig: {
|
|
1127
|
+
stateFile,
|
|
1128
|
+
sessionsFile,
|
|
1129
|
+
configFile,
|
|
1130
|
+
configBackupsDir: backupsDir,
|
|
1131
|
+
modelOrder: ["model-a"],
|
|
1132
|
+
probeEnabled: false,
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
register(api);
|
|
1136
|
+
|
|
1137
|
+
await runOneTick(api);
|
|
1138
|
+
|
|
1139
|
+
const events = findEmitted(api, "self-heal:status");
|
|
1140
|
+
const snapshot = events[0].payload;
|
|
1141
|
+
expect(snapshot.whatsapp.status).toBe("disconnected");
|
|
1142
|
+
expect(snapshot.whatsapp.disconnectStreak).toBe(3);
|
|
1143
|
+
expect(snapshot.cron.trackedJobs).toBe(2);
|
|
1144
|
+
expect(snapshot.cron.failingJobs).toHaveLength(1);
|
|
1145
|
+
expect(snapshot.cron.failingJobs[0].id).toBe("c1");
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// -------------------------------------------------------------------------
|
|
1150
|
+
// Combined multi-domain tick
|
|
1151
|
+
// -------------------------------------------------------------------------
|
|
1152
|
+
|
|
1153
|
+
describe("combined multi-domain tick", () => {
|
|
1154
|
+
it("handles WhatsApp, cron, and probe healing in a single tick", async () => {
|
|
1155
|
+
const hitAt = nowSec() - 400;
|
|
1156
|
+
saveState(stateFile, {
|
|
1157
|
+
...emptyState(),
|
|
1158
|
+
whatsapp: { disconnectStreak: 0 },
|
|
1159
|
+
cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
|
|
1160
|
+
limited: {
|
|
1161
|
+
"model-b": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
|
|
1162
|
+
},
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
const api = mockApi({
|
|
1166
|
+
pluginConfig: {
|
|
1167
|
+
stateFile,
|
|
1168
|
+
sessionsFile,
|
|
1169
|
+
configFile,
|
|
1170
|
+
configBackupsDir: backupsDir,
|
|
1171
|
+
modelOrder: ["model-a", "model-b"],
|
|
1172
|
+
probeEnabled: true,
|
|
1173
|
+
probeIntervalSec: 300,
|
|
1174
|
+
autoFix: {
|
|
1175
|
+
disableFailingCrons: true,
|
|
1176
|
+
cronFailThreshold: 3,
|
|
1177
|
+
issueCooldownSec: 0,
|
|
1178
|
+
issueRepo: "elvatis/test-repo",
|
|
1179
|
+
whatsappDisconnectThreshold: 5,
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
1184
|
+
const cmd = opts?.command?.join(" ") ?? "";
|
|
1185
|
+
if (cmd.includes("channels status")) {
|
|
1186
|
+
return {
|
|
1187
|
+
exitCode: 0,
|
|
1188
|
+
stdout: JSON.stringify({ channels: { whatsapp: { status: "connected" } } }),
|
|
1189
|
+
stderr: "",
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
if (cmd.includes("cron list")) {
|
|
1193
|
+
return {
|
|
1194
|
+
exitCode: 0,
|
|
1195
|
+
stdout: JSON.stringify({
|
|
1196
|
+
jobs: [{
|
|
1197
|
+
id: "cron-1",
|
|
1198
|
+
name: "report",
|
|
1199
|
+
state: { lastStatus: "error", lastError: "timeout" },
|
|
1200
|
+
}],
|
|
1201
|
+
}),
|
|
1202
|
+
stderr: "",
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
if (cmd.includes("model probe")) {
|
|
1206
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
1207
|
+
}
|
|
1208
|
+
if (cmd.includes("gateway status")) {
|
|
1209
|
+
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
1210
|
+
}
|
|
1211
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
1212
|
+
});
|
|
1213
|
+
register(api);
|
|
1214
|
+
|
|
1215
|
+
await runOneTick(api);
|
|
1216
|
+
|
|
1217
|
+
const state = loadState(stateFile);
|
|
1218
|
+
|
|
1219
|
+
// WhatsApp: connected, streak reset
|
|
1220
|
+
expect(state.whatsapp!.disconnectStreak).toBe(0);
|
|
1221
|
+
expect(state.whatsapp!.lastSeenConnectedAt).toBeGreaterThan(0);
|
|
1222
|
+
|
|
1223
|
+
// Cron: threshold reached, disabled
|
|
1224
|
+
const disableCalls = filterCmdCalls(api, "cron edit cron-1 --disable");
|
|
1225
|
+
expect(disableCalls).toHaveLength(1);
|
|
1226
|
+
const cronEvents = findEmitted(api, "self-heal:cron-disabled");
|
|
1227
|
+
expect(cronEvents).toHaveLength(1);
|
|
1228
|
+
|
|
1229
|
+
// Probe: model-b recovered
|
|
1230
|
+
expect(state.limited["model-b"]).toBeUndefined();
|
|
1231
|
+
const recoveryEvents = findEmitted(api, "self-heal:model-recovered");
|
|
1232
|
+
expect(recoveryEvents).toHaveLength(1);
|
|
1233
|
+
expect(recoveryEvents[0].payload.model).toBe("model-b");
|
|
1234
|
+
|
|
1235
|
+
// Status snapshot emitted
|
|
1236
|
+
const statusEvents = findEmitted(api, "self-heal:status");
|
|
1237
|
+
expect(statusEvents.length).toBeGreaterThanOrEqual(1);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it("handles all domains being inactive gracefully", async () => {
|
|
1241
|
+
const api = mockApi({
|
|
1242
|
+
pluginConfig: {
|
|
1243
|
+
stateFile,
|
|
1244
|
+
sessionsFile,
|
|
1245
|
+
configFile,
|
|
1246
|
+
configBackupsDir: backupsDir,
|
|
1247
|
+
modelOrder: ["model-a"],
|
|
1248
|
+
autoFix: {
|
|
1249
|
+
restartWhatsappOnDisconnect: false,
|
|
1250
|
+
disableFailingCrons: false,
|
|
1251
|
+
disableFailingPlugins: false,
|
|
1252
|
+
},
|
|
1253
|
+
probeEnabled: false,
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
register(api);
|
|
1257
|
+
|
|
1258
|
+
await runOneTick(api);
|
|
1259
|
+
|
|
1260
|
+
// Only status event should be emitted
|
|
1261
|
+
const statusEvents = findEmitted(api, "self-heal:status");
|
|
1262
|
+
expect(statusEvents.length).toBeGreaterThanOrEqual(1);
|
|
1263
|
+
expect(statusEvents[0].payload.health).toBe("healthy");
|
|
1264
|
+
|
|
1265
|
+
// No healing commands should have been run (no WA check, no cron check, no probes)
|
|
1266
|
+
// Note: startup cleanup calls "openclaw gateway status" once, so we check
|
|
1267
|
+
// that no healing-specific commands were issued
|
|
1268
|
+
const healingCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1269
|
+
(c: any[]) => {
|
|
1270
|
+
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1271
|
+
return cmd.includes("channels status") || cmd.includes("cron list") || cmd.includes("model probe");
|
|
1272
|
+
}
|
|
1273
|
+
);
|
|
1274
|
+
expect(healingCalls).toHaveLength(0);
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
// -------------------------------------------------------------------------
|
|
1279
|
+
// Edge cases and error handling
|
|
1280
|
+
// -------------------------------------------------------------------------
|
|
1281
|
+
|
|
1282
|
+
describe("edge cases and error handling", () => {
|
|
1283
|
+
it("handles command timeout gracefully during tick", async () => {
|
|
1284
|
+
const api = mockApi({
|
|
1285
|
+
pluginConfig: {
|
|
1286
|
+
stateFile,
|
|
1287
|
+
sessionsFile,
|
|
1288
|
+
configFile,
|
|
1289
|
+
configBackupsDir: backupsDir,
|
|
1290
|
+
modelOrder: ["model-a"],
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
api.runtime.system.runCommandWithTimeout.mockRejectedValue(
|
|
1294
|
+
new Error("command timed out")
|
|
1295
|
+
);
|
|
1296
|
+
register(api);
|
|
1297
|
+
|
|
1298
|
+
// Should not throw
|
|
1299
|
+
await runOneTick(api);
|
|
1300
|
+
|
|
1301
|
+
// Status should still be emitted
|
|
1302
|
+
const statusEvents = findEmitted(api, "self-heal:status");
|
|
1303
|
+
expect(statusEvents.length).toBeGreaterThanOrEqual(1);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it("handles malformed JSON from channels status command", async () => {
|
|
1307
|
+
const api = mockApi({
|
|
1308
|
+
pluginConfig: {
|
|
1309
|
+
stateFile,
|
|
1310
|
+
sessionsFile,
|
|
1311
|
+
configFile,
|
|
1312
|
+
configBackupsDir: backupsDir,
|
|
1313
|
+
modelOrder: ["model-a"],
|
|
1314
|
+
},
|
|
1315
|
+
});
|
|
1316
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
1317
|
+
exitCode: 0,
|
|
1318
|
+
stdout: "NOT JSON {{{",
|
|
1319
|
+
stderr: "",
|
|
1320
|
+
});
|
|
1321
|
+
register(api);
|
|
1322
|
+
|
|
1323
|
+
// Should not throw
|
|
1324
|
+
await runOneTick(api);
|
|
1325
|
+
|
|
1326
|
+
// WhatsApp: malformed JSON means safeJsonParse returns undefined,
|
|
1327
|
+
// so the wa object is undefined, connected=false, and streak increments by 1
|
|
1328
|
+
const state = loadState(stateFile);
|
|
1329
|
+
expect(state.whatsapp!.disconnectStreak).toBe(1);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it("handles malformed JSON from cron list command", async () => {
|
|
1333
|
+
const api = mockApi({
|
|
1334
|
+
pluginConfig: {
|
|
1335
|
+
stateFile,
|
|
1336
|
+
sessionsFile,
|
|
1337
|
+
configFile,
|
|
1338
|
+
configBackupsDir: backupsDir,
|
|
1339
|
+
modelOrder: ["model-a"],
|
|
1340
|
+
autoFix: { disableFailingCrons: true },
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
1344
|
+
exitCode: 0,
|
|
1345
|
+
stdout: "this is not json",
|
|
1346
|
+
stderr: "",
|
|
1347
|
+
});
|
|
1348
|
+
register(api);
|
|
1349
|
+
|
|
1350
|
+
// Should not throw
|
|
1351
|
+
await runOneTick(api);
|
|
1352
|
+
|
|
1353
|
+
const statusEvents = findEmitted(api, "self-heal:status");
|
|
1354
|
+
expect(statusEvents.length).toBeGreaterThanOrEqual(1);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it("persists state at end of tick even when no healing actions taken", async () => {
|
|
1358
|
+
const api = mockApi({
|
|
1359
|
+
pluginConfig: {
|
|
1360
|
+
stateFile,
|
|
1361
|
+
sessionsFile,
|
|
1362
|
+
configFile,
|
|
1363
|
+
configBackupsDir: backupsDir,
|
|
1364
|
+
modelOrder: ["model-a"],
|
|
1365
|
+
probeEnabled: false,
|
|
1366
|
+
autoFix: { restartWhatsappOnDisconnect: false, disableFailingCrons: false },
|
|
1367
|
+
},
|
|
1368
|
+
});
|
|
1369
|
+
register(api);
|
|
1370
|
+
|
|
1371
|
+
await runOneTick(api);
|
|
1372
|
+
|
|
1373
|
+
// State file should exist and be valid
|
|
1374
|
+
expect(fs.existsSync(stateFile)).toBe(true);
|
|
1375
|
+
const state = loadState(stateFile);
|
|
1376
|
+
expect(state.limited).toBeDefined();
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it("uses whatsapp connected=true as alternative connected indicator", async () => {
|
|
1380
|
+
const api = mockApi({
|
|
1381
|
+
pluginConfig: {
|
|
1382
|
+
stateFile,
|
|
1383
|
+
sessionsFile,
|
|
1384
|
+
configFile,
|
|
1385
|
+
configBackupsDir: backupsDir,
|
|
1386
|
+
modelOrder: ["model-a"],
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
api.runtime.system.runCommandWithTimeout.mockResolvedValue({
|
|
1390
|
+
exitCode: 0,
|
|
1391
|
+
stdout: JSON.stringify({ channels: { whatsapp: { connected: true } } }),
|
|
1392
|
+
stderr: "",
|
|
1393
|
+
});
|
|
1394
|
+
register(api);
|
|
1395
|
+
|
|
1396
|
+
await runOneTick(api);
|
|
1397
|
+
|
|
1398
|
+
const state = loadState(stateFile);
|
|
1399
|
+
expect(state.whatsapp!.disconnectStreak).toBe(0);
|
|
1400
|
+
expect(state.whatsapp!.lastSeenConnectedAt).toBeGreaterThan(0);
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
});
|