@calltelemetry/openclaw-linear 0.9.3 → 0.9.5
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/package.json +1 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +172 -0
- package/src/__test__/smoke-linear-api.test.ts +2 -1
- package/src/__test__/webhook-scenarios.test.ts +3 -0
- package/src/agent/agent.ts +9 -7
- package/src/agent/watchdog.ts +2 -1
- package/src/api/linear-api.ts +2 -1
- package/src/api/oauth-callback.ts +2 -1
- package/src/infra/cli.ts +2 -2
- package/src/infra/codex-worktree.ts +2 -2
- package/src/infra/config-paths.test.ts +3 -0
- package/src/infra/doctor.test.ts +931 -1
- package/src/infra/multi-repo.test.ts +11 -9
- package/src/infra/multi-repo.ts +3 -2
- package/src/infra/shared-profiles.ts +2 -1
- package/src/infra/template.test.ts +2 -2
- package/src/pipeline/active-session.test.ts +96 -1
- package/src/pipeline/active-session.ts +60 -0
- package/src/pipeline/artifacts.ts +1 -1
- package/src/pipeline/pipeline.test.ts +3 -0
- package/src/pipeline/webhook-dedup.test.ts +3 -0
- package/src/pipeline/webhook.test.ts +2268 -0
- package/src/pipeline/webhook.ts +24 -6
- package/src/tools/claude-tool.ts +1 -1
- package/src/tools/cli-shared.test.ts +3 -0
- package/src/tools/cli-shared.ts +3 -1
- package/src/tools/code-tool.test.ts +3 -0
- package/src/tools/code-tool.ts +1 -1
- package/src/tools/codex-tool.ts +1 -1
- package/src/tools/gemini-tool.ts +1 -1
package/src/infra/doctor.test.ts
CHANGED
|
@@ -1290,7 +1290,7 @@ describe("buildSummary — additional branches", () => {
|
|
|
1290
1290
|
// ---------------------------------------------------------------------------
|
|
1291
1291
|
|
|
1292
1292
|
describe("runDoctor — additional branches", () => {
|
|
1293
|
-
|
|
1293
|
+
it("applies --fix to auth-profiles.json permissions when fixable check exists", async () => {
|
|
1294
1294
|
// We need a scenario where checkAuth produces a fixable permissions check
|
|
1295
1295
|
// The AUTH_PROFILES_PATH is mocked to /tmp/test-auth-profiles.json
|
|
1296
1296
|
// Write a file there with wrong permissions so statSync succeeds
|
|
@@ -1318,3 +1318,933 @@ describe("runDoctor — additional branches", () => {
|
|
|
1318
1318
|
}
|
|
1319
1319
|
});
|
|
1320
1320
|
});
|
|
1321
|
+
|
|
1322
|
+
// ---------------------------------------------------------------------------
|
|
1323
|
+
// checkFilesAndDirs — lock file branches
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
|
|
1326
|
+
describe("checkFilesAndDirs — lock file branches", () => {
|
|
1327
|
+
let tmpDir: string;
|
|
1328
|
+
|
|
1329
|
+
beforeEach(() => {
|
|
1330
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-lock-"));
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it("warns about stale lock file without --fix", async () => {
|
|
1334
|
+
const statePath = join(tmpDir, "state.json");
|
|
1335
|
+
const lockPath = statePath + ".lock";
|
|
1336
|
+
writeFileSync(statePath, '{}');
|
|
1337
|
+
writeFileSync(lockPath, "locked");
|
|
1338
|
+
// Make the lock file appear stale by backdating its mtime
|
|
1339
|
+
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1340
|
+
const { utimesSync } = await import("node:fs");
|
|
1341
|
+
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1342
|
+
|
|
1343
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1344
|
+
dispatches: { active: {}, completed: {} },
|
|
1345
|
+
sessionMap: {},
|
|
1346
|
+
processedEvents: [],
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1350
|
+
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1351
|
+
expect(lockCheck?.severity).toBe("warn");
|
|
1352
|
+
expect(lockCheck?.fixable).toBe(true);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
it("removes stale lock file with --fix", async () => {
|
|
1356
|
+
const statePath = join(tmpDir, "state.json");
|
|
1357
|
+
const lockPath = statePath + ".lock";
|
|
1358
|
+
writeFileSync(statePath, '{}');
|
|
1359
|
+
writeFileSync(lockPath, "locked");
|
|
1360
|
+
const staleTime = Date.now() - 60_000;
|
|
1361
|
+
const { utimesSync } = await import("node:fs");
|
|
1362
|
+
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1363
|
+
|
|
1364
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1365
|
+
dispatches: { active: {}, completed: {} },
|
|
1366
|
+
sessionMap: {},
|
|
1367
|
+
processedEvents: [],
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1371
|
+
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1372
|
+
expect(lockCheck?.severity).toBe("pass");
|
|
1373
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it("warns about active (non-stale) lock file", async () => {
|
|
1377
|
+
const statePath = join(tmpDir, "state.json");
|
|
1378
|
+
const lockPath = statePath + ".lock";
|
|
1379
|
+
writeFileSync(statePath, '{}');
|
|
1380
|
+
writeFileSync(lockPath, "locked");
|
|
1381
|
+
// Lock file is fresh (just created), so lockAge < LOCK_STALE_MS
|
|
1382
|
+
|
|
1383
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1384
|
+
dispatches: { active: {}, completed: {} },
|
|
1385
|
+
sessionMap: {},
|
|
1386
|
+
processedEvents: [],
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1390
|
+
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
1391
|
+
expect(lockCheck?.severity).toBe("warn");
|
|
1392
|
+
expect(lockCheck?.label).toContain("may be in use");
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
// ---------------------------------------------------------------------------
|
|
1397
|
+
// checkFilesAndDirs — prompt variable edge cases
|
|
1398
|
+
// ---------------------------------------------------------------------------
|
|
1399
|
+
|
|
1400
|
+
describe("checkFilesAndDirs — prompt variable edge cases", () => {
|
|
1401
|
+
it("reports when variable missing from worker.task but present in audit.task", async () => {
|
|
1402
|
+
vi.mocked(loadPrompts).mockReturnValueOnce({
|
|
1403
|
+
worker: {
|
|
1404
|
+
system: "ok",
|
|
1405
|
+
task: "Do something", // missing all vars
|
|
1406
|
+
},
|
|
1407
|
+
audit: {
|
|
1408
|
+
system: "ok",
|
|
1409
|
+
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
1410
|
+
},
|
|
1411
|
+
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
1412
|
+
} as any);
|
|
1413
|
+
|
|
1414
|
+
const checks = await checkFilesAndDirs();
|
|
1415
|
+
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
1416
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
1417
|
+
expect(promptCheck?.label).toContain("worker.task missing");
|
|
1418
|
+
// Crucially, audit.task should NOT be missing
|
|
1419
|
+
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
it("reports loadPrompts throwing non-Error value", async () => {
|
|
1423
|
+
vi.mocked(loadPrompts).mockImplementationOnce(() => { throw "raw string error"; });
|
|
1424
|
+
|
|
1425
|
+
const checks = await checkFilesAndDirs();
|
|
1426
|
+
const promptCheck = checks.find((c) => c.label.includes("Failed to load prompts"));
|
|
1427
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
1428
|
+
expect(promptCheck?.detail).toContain("raw string error");
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
// ---------------------------------------------------------------------------
|
|
1433
|
+
// checkFilesAndDirs — worktree & base repo edge cases
|
|
1434
|
+
// ---------------------------------------------------------------------------
|
|
1435
|
+
|
|
1436
|
+
describe("checkFilesAndDirs — worktree & base repo edge cases", () => {
|
|
1437
|
+
it("reports worktree base dir does not exist", async () => {
|
|
1438
|
+
const checks = await checkFilesAndDirs({
|
|
1439
|
+
worktreeBaseDir: "/tmp/nonexistent-worktree-dir-" + Date.now(),
|
|
1440
|
+
});
|
|
1441
|
+
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir does not exist"));
|
|
1442
|
+
expect(wtCheck?.severity).toBe("warn");
|
|
1443
|
+
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it("reports base repo does not exist", async () => {
|
|
1447
|
+
const checks = await checkFilesAndDirs({
|
|
1448
|
+
codexBaseRepo: "/tmp/nonexistent-repo-" + Date.now(),
|
|
1449
|
+
});
|
|
1450
|
+
const repoCheck = checks.find((c) => c.label.includes("Base repo does not exist"));
|
|
1451
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
1452
|
+
expect(repoCheck?.fix).toContain("codexBaseRepo");
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
it("reports base repo exists but is not a git repo", async () => {
|
|
1456
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
1457
|
+
const checks = await checkFilesAndDirs({
|
|
1458
|
+
codexBaseRepo: tmpDir,
|
|
1459
|
+
});
|
|
1460
|
+
const repoCheck = checks.find((c) => c.label.includes("Base repo is not a git repo"));
|
|
1461
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
1462
|
+
expect(repoCheck?.fix).toContain("git init");
|
|
1463
|
+
});
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
// ---------------------------------------------------------------------------
|
|
1467
|
+
// checkFilesAndDirs — tilde path resolution branches
|
|
1468
|
+
// ---------------------------------------------------------------------------
|
|
1469
|
+
|
|
1470
|
+
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
1471
|
+
it("resolves ~/... dispatch state path", async () => {
|
|
1472
|
+
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
1473
|
+
|
|
1474
|
+
// Providing a path with ~/ triggers the tilde resolution branch
|
|
1475
|
+
const checks = await checkFilesAndDirs({
|
|
1476
|
+
dispatchStatePath: "~/nonexistent-state-file.json",
|
|
1477
|
+
});
|
|
1478
|
+
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
1479
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
1480
|
+
expect(stateCheck).toBeDefined();
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it("resolves ~/... worktree base dir path", async () => {
|
|
1484
|
+
const checks = await checkFilesAndDirs({
|
|
1485
|
+
worktreeBaseDir: "~/nonexistent-worktree-base",
|
|
1486
|
+
});
|
|
1487
|
+
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
1488
|
+
expect(wtCheck).toBeDefined();
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// ---------------------------------------------------------------------------
|
|
1493
|
+
// checkDispatchHealth — orphaned worktree singular
|
|
1494
|
+
// ---------------------------------------------------------------------------
|
|
1495
|
+
|
|
1496
|
+
describe("checkDispatchHealth — edge cases", () => {
|
|
1497
|
+
it("reports single orphaned worktree (singular)", async () => {
|
|
1498
|
+
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
1499
|
+
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
1500
|
+
]);
|
|
1501
|
+
|
|
1502
|
+
const checks = await checkDispatchHealth();
|
|
1503
|
+
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
1504
|
+
expect(orphanCheck?.severity).toBe("warn");
|
|
1505
|
+
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
1506
|
+
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
1510
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1511
|
+
dispatches: {
|
|
1512
|
+
active: {},
|
|
1513
|
+
completed: {
|
|
1514
|
+
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1515
|
+
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
sessionMap: {},
|
|
1519
|
+
processedEvents: [],
|
|
1520
|
+
});
|
|
1521
|
+
vi.mocked(pruneCompleted).mockResolvedValueOnce(2);
|
|
1522
|
+
|
|
1523
|
+
const checks = await checkDispatchHealth(undefined, true);
|
|
1524
|
+
const pruneCheck = checks.find((c) => c.label.includes("Pruned"));
|
|
1525
|
+
expect(pruneCheck?.severity).toBe("pass");
|
|
1526
|
+
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it("reports single stale dispatch (singular)", async () => {
|
|
1530
|
+
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
1531
|
+
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
1532
|
+
]);
|
|
1533
|
+
|
|
1534
|
+
const checks = await checkDispatchHealth();
|
|
1535
|
+
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
1536
|
+
expect(staleCheck?.severity).toBe("warn");
|
|
1537
|
+
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
1538
|
+
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
1539
|
+
});
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// ---------------------------------------------------------------------------
|
|
1543
|
+
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
1544
|
+
// ---------------------------------------------------------------------------
|
|
1545
|
+
|
|
1546
|
+
describe("checkConnectivity — webhook non-ok body", () => {
|
|
1547
|
+
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
1548
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
1549
|
+
if (url.includes("localhost")) {
|
|
1550
|
+
return { ok: true, text: async () => "pong" };
|
|
1551
|
+
}
|
|
1552
|
+
throw new Error("unexpected");
|
|
1553
|
+
}));
|
|
1554
|
+
|
|
1555
|
+
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
1556
|
+
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
1557
|
+
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
1558
|
+
// Since body !== "ok", it falls into the warn branch
|
|
1559
|
+
expect(webhookCheck?.severity).toBe("warn");
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
// ---------------------------------------------------------------------------
|
|
1564
|
+
// formatReport — icon function TTY branches
|
|
1565
|
+
// ---------------------------------------------------------------------------
|
|
1566
|
+
|
|
1567
|
+
describe("formatReport — TTY icon rendering", () => {
|
|
1568
|
+
it("renders colored icons when stdout.isTTY is true", () => {
|
|
1569
|
+
const origIsTTY = process.stdout.isTTY;
|
|
1570
|
+
try {
|
|
1571
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
1572
|
+
|
|
1573
|
+
const report = {
|
|
1574
|
+
sections: [{
|
|
1575
|
+
name: "Test",
|
|
1576
|
+
checks: [
|
|
1577
|
+
{ label: "pass check", severity: "pass" as const },
|
|
1578
|
+
{ label: "warn check", severity: "warn" as const },
|
|
1579
|
+
{ label: "fail check", severity: "fail" as const },
|
|
1580
|
+
],
|
|
1581
|
+
}],
|
|
1582
|
+
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
const output = formatReport(report);
|
|
1586
|
+
// TTY output includes ANSI escape codes
|
|
1587
|
+
expect(output).toContain("\x1b[32m"); // green for pass
|
|
1588
|
+
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
1589
|
+
expect(output).toContain("\x1b[31m"); // red for fail
|
|
1590
|
+
} finally {
|
|
1591
|
+
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
// ---------------------------------------------------------------------------
|
|
1597
|
+
// checkCodingTools — codingTool fallback to "codex" in label
|
|
1598
|
+
// ---------------------------------------------------------------------------
|
|
1599
|
+
|
|
1600
|
+
describe("checkCodingTools — codingTool null fallback", () => {
|
|
1601
|
+
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
1602
|
+
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
1603
|
+
codingTool: undefined,
|
|
1604
|
+
backends: { codex: { aliases: ["codex"] } },
|
|
1605
|
+
} as any);
|
|
1606
|
+
|
|
1607
|
+
const checks = checkCodingTools();
|
|
1608
|
+
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
1609
|
+
expect(configCheck?.severity).toBe("pass");
|
|
1610
|
+
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// ---------------------------------------------------------------------------
|
|
1615
|
+
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
1616
|
+
// ---------------------------------------------------------------------------
|
|
1617
|
+
|
|
1618
|
+
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
1619
|
+
it("handles non-Error thrown during dispatch state read", async () => {
|
|
1620
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
1621
|
+
const statePath = join(tmpDir, "state.json");
|
|
1622
|
+
writeFileSync(statePath, '{}');
|
|
1623
|
+
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
1624
|
+
|
|
1625
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
1626
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
1627
|
+
expect(stateCheck?.severity).toBe("fail");
|
|
1628
|
+
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
// ---------------------------------------------------------------------------
|
|
1633
|
+
// checkFilesAndDirs — lock file branches
|
|
1634
|
+
// ---------------------------------------------------------------------------
|
|
1635
|
+
|
|
1636
|
+
describe("checkFilesAndDirs — lock file branches", () => {
|
|
1637
|
+
let tmpDir: string;
|
|
1638
|
+
|
|
1639
|
+
beforeEach(() => {
|
|
1640
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-lock-"));
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
it("warns about stale lock file without --fix", async () => {
|
|
1644
|
+
const statePath = join(tmpDir, "state.json");
|
|
1645
|
+
const lockPath = statePath + ".lock";
|
|
1646
|
+
writeFileSync(statePath, '{}');
|
|
1647
|
+
writeFileSync(lockPath, "locked");
|
|
1648
|
+
// Make the lock file appear stale by backdating its mtime
|
|
1649
|
+
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1650
|
+
const { utimesSync } = await import("node:fs");
|
|
1651
|
+
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1652
|
+
|
|
1653
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1654
|
+
dispatches: { active: {}, completed: {} },
|
|
1655
|
+
sessionMap: {},
|
|
1656
|
+
processedEvents: [],
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1660
|
+
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1661
|
+
expect(lockCheck?.severity).toBe("warn");
|
|
1662
|
+
expect(lockCheck?.fixable).toBe(true);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
it("removes stale lock file with --fix", async () => {
|
|
1666
|
+
const statePath = join(tmpDir, "state.json");
|
|
1667
|
+
const lockPath = statePath + ".lock";
|
|
1668
|
+
writeFileSync(statePath, '{}');
|
|
1669
|
+
writeFileSync(lockPath, "locked");
|
|
1670
|
+
const staleTime = Date.now() - 60_000;
|
|
1671
|
+
const { utimesSync } = await import("node:fs");
|
|
1672
|
+
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1673
|
+
|
|
1674
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1675
|
+
dispatches: { active: {}, completed: {} },
|
|
1676
|
+
sessionMap: {},
|
|
1677
|
+
processedEvents: [],
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1681
|
+
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1682
|
+
expect(lockCheck?.severity).toBe("pass");
|
|
1683
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
it("warns about active (non-stale) lock file", async () => {
|
|
1687
|
+
const statePath = join(tmpDir, "state.json");
|
|
1688
|
+
const lockPath = statePath + ".lock";
|
|
1689
|
+
writeFileSync(statePath, '{}');
|
|
1690
|
+
writeFileSync(lockPath, "locked");
|
|
1691
|
+
// Lock file is fresh (just created), so lockAge < LOCK_STALE_MS
|
|
1692
|
+
|
|
1693
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1694
|
+
dispatches: { active: {}, completed: {} },
|
|
1695
|
+
sessionMap: {},
|
|
1696
|
+
processedEvents: [],
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1700
|
+
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
1701
|
+
expect(lockCheck?.severity).toBe("warn");
|
|
1702
|
+
expect(lockCheck?.label).toContain("may be in use");
|
|
1703
|
+
});
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
// ---------------------------------------------------------------------------
|
|
1707
|
+
// checkFilesAndDirs — prompt variable edge cases
|
|
1708
|
+
// ---------------------------------------------------------------------------
|
|
1709
|
+
|
|
1710
|
+
describe("checkFilesAndDirs — prompt variable edge cases", () => {
|
|
1711
|
+
it("reports when variable missing from worker.task but present in audit.task", async () => {
|
|
1712
|
+
vi.mocked(loadPrompts).mockReturnValueOnce({
|
|
1713
|
+
worker: {
|
|
1714
|
+
system: "ok",
|
|
1715
|
+
task: "Do something", // missing all vars
|
|
1716
|
+
},
|
|
1717
|
+
audit: {
|
|
1718
|
+
system: "ok",
|
|
1719
|
+
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
1720
|
+
},
|
|
1721
|
+
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
1722
|
+
} as any);
|
|
1723
|
+
|
|
1724
|
+
const checks = await checkFilesAndDirs();
|
|
1725
|
+
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
1726
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
1727
|
+
expect(promptCheck?.label).toContain("worker.task missing");
|
|
1728
|
+
// Crucially, audit.task should NOT be missing
|
|
1729
|
+
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it("reports loadPrompts throwing non-Error value", async () => {
|
|
1733
|
+
vi.mocked(loadPrompts).mockImplementationOnce(() => { throw "raw string error"; });
|
|
1734
|
+
|
|
1735
|
+
const checks = await checkFilesAndDirs();
|
|
1736
|
+
const promptCheck = checks.find((c) => c.label.includes("Failed to load prompts"));
|
|
1737
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
1738
|
+
expect(promptCheck?.detail).toContain("raw string error");
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
// ---------------------------------------------------------------------------
|
|
1743
|
+
// checkFilesAndDirs — worktree & base repo edge cases
|
|
1744
|
+
// ---------------------------------------------------------------------------
|
|
1745
|
+
|
|
1746
|
+
describe("checkFilesAndDirs — worktree & base repo edge cases", () => {
|
|
1747
|
+
it("reports worktree base dir does not exist", async () => {
|
|
1748
|
+
const checks = await checkFilesAndDirs({
|
|
1749
|
+
worktreeBaseDir: "/tmp/nonexistent-worktree-dir-" + Date.now(),
|
|
1750
|
+
});
|
|
1751
|
+
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir does not exist"));
|
|
1752
|
+
expect(wtCheck?.severity).toBe("warn");
|
|
1753
|
+
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
it("reports base repo does not exist", async () => {
|
|
1757
|
+
const checks = await checkFilesAndDirs({
|
|
1758
|
+
codexBaseRepo: "/tmp/nonexistent-repo-" + Date.now(),
|
|
1759
|
+
});
|
|
1760
|
+
const repoCheck = checks.find((c) => c.label.includes("Base repo does not exist"));
|
|
1761
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
1762
|
+
expect(repoCheck?.fix).toContain("codexBaseRepo");
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it("reports base repo exists but is not a git repo", async () => {
|
|
1766
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
1767
|
+
const checks = await checkFilesAndDirs({
|
|
1768
|
+
codexBaseRepo: tmpDir,
|
|
1769
|
+
});
|
|
1770
|
+
const repoCheck = checks.find((c) => c.label.includes("Base repo is not a git repo"));
|
|
1771
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
1772
|
+
expect(repoCheck?.fix).toContain("git init");
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
// ---------------------------------------------------------------------------
|
|
1777
|
+
// checkFilesAndDirs — tilde path resolution branches
|
|
1778
|
+
// ---------------------------------------------------------------------------
|
|
1779
|
+
|
|
1780
|
+
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
1781
|
+
it("resolves ~/... dispatch state path", async () => {
|
|
1782
|
+
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
1783
|
+
|
|
1784
|
+
// Providing a path with ~/ triggers the tilde resolution branch
|
|
1785
|
+
const checks = await checkFilesAndDirs({
|
|
1786
|
+
dispatchStatePath: "~/nonexistent-state-file.json",
|
|
1787
|
+
});
|
|
1788
|
+
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
1789
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
1790
|
+
expect(stateCheck).toBeDefined();
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it("resolves ~/... worktree base dir path", async () => {
|
|
1794
|
+
const checks = await checkFilesAndDirs({
|
|
1795
|
+
worktreeBaseDir: "~/nonexistent-worktree-base",
|
|
1796
|
+
});
|
|
1797
|
+
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
1798
|
+
expect(wtCheck).toBeDefined();
|
|
1799
|
+
});
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
// ---------------------------------------------------------------------------
|
|
1803
|
+
// checkDispatchHealth — orphaned worktree singular
|
|
1804
|
+
// ---------------------------------------------------------------------------
|
|
1805
|
+
|
|
1806
|
+
describe("checkDispatchHealth — edge cases", () => {
|
|
1807
|
+
it("reports single orphaned worktree (singular)", async () => {
|
|
1808
|
+
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
1809
|
+
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
1810
|
+
]);
|
|
1811
|
+
|
|
1812
|
+
const checks = await checkDispatchHealth();
|
|
1813
|
+
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
1814
|
+
expect(orphanCheck?.severity).toBe("warn");
|
|
1815
|
+
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
1816
|
+
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
1820
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1821
|
+
dispatches: {
|
|
1822
|
+
active: {},
|
|
1823
|
+
completed: {
|
|
1824
|
+
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1825
|
+
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1826
|
+
},
|
|
1827
|
+
},
|
|
1828
|
+
sessionMap: {},
|
|
1829
|
+
processedEvents: [],
|
|
1830
|
+
});
|
|
1831
|
+
vi.mocked(pruneCompleted).mockResolvedValueOnce(2);
|
|
1832
|
+
|
|
1833
|
+
const checks = await checkDispatchHealth(undefined, true);
|
|
1834
|
+
const pruneCheck = checks.find((c) => c.label.includes("Pruned"));
|
|
1835
|
+
expect(pruneCheck?.severity).toBe("pass");
|
|
1836
|
+
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it("reports single stale dispatch (singular)", async () => {
|
|
1840
|
+
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
1841
|
+
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
1842
|
+
]);
|
|
1843
|
+
|
|
1844
|
+
const checks = await checkDispatchHealth();
|
|
1845
|
+
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
1846
|
+
expect(staleCheck?.severity).toBe("warn");
|
|
1847
|
+
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
1848
|
+
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
1849
|
+
});
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
// ---------------------------------------------------------------------------
|
|
1853
|
+
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
1854
|
+
// ---------------------------------------------------------------------------
|
|
1855
|
+
|
|
1856
|
+
describe("checkConnectivity — webhook non-ok body", () => {
|
|
1857
|
+
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
1858
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
1859
|
+
if (url.includes("localhost")) {
|
|
1860
|
+
return { ok: true, text: async () => "pong" };
|
|
1861
|
+
}
|
|
1862
|
+
throw new Error("unexpected");
|
|
1863
|
+
}));
|
|
1864
|
+
|
|
1865
|
+
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
1866
|
+
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
1867
|
+
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
1868
|
+
// Since body !== "ok", it falls into the warn branch
|
|
1869
|
+
expect(webhookCheck?.severity).toBe("warn");
|
|
1870
|
+
});
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
// ---------------------------------------------------------------------------
|
|
1874
|
+
// formatReport — icon function TTY branches
|
|
1875
|
+
// ---------------------------------------------------------------------------
|
|
1876
|
+
|
|
1877
|
+
describe("formatReport — TTY icon rendering", () => {
|
|
1878
|
+
it("renders colored icons when stdout.isTTY is true", () => {
|
|
1879
|
+
const origIsTTY = process.stdout.isTTY;
|
|
1880
|
+
try {
|
|
1881
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
1882
|
+
|
|
1883
|
+
const report = {
|
|
1884
|
+
sections: [{
|
|
1885
|
+
name: "Test",
|
|
1886
|
+
checks: [
|
|
1887
|
+
{ label: "pass check", severity: "pass" as const },
|
|
1888
|
+
{ label: "warn check", severity: "warn" as const },
|
|
1889
|
+
{ label: "fail check", severity: "fail" as const },
|
|
1890
|
+
],
|
|
1891
|
+
}],
|
|
1892
|
+
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
1893
|
+
};
|
|
1894
|
+
|
|
1895
|
+
const output = formatReport(report);
|
|
1896
|
+
// TTY output includes ANSI escape codes
|
|
1897
|
+
expect(output).toContain("\x1b[32m"); // green for pass
|
|
1898
|
+
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
1899
|
+
expect(output).toContain("\x1b[31m"); // red for fail
|
|
1900
|
+
} finally {
|
|
1901
|
+
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
// ---------------------------------------------------------------------------
|
|
1907
|
+
// checkCodingTools — codingTool fallback to "codex" in label
|
|
1908
|
+
// ---------------------------------------------------------------------------
|
|
1909
|
+
|
|
1910
|
+
describe("checkCodingTools — codingTool null fallback", () => {
|
|
1911
|
+
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
1912
|
+
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
1913
|
+
codingTool: undefined,
|
|
1914
|
+
backends: { codex: { aliases: ["codex"] } },
|
|
1915
|
+
} as any);
|
|
1916
|
+
|
|
1917
|
+
const checks = checkCodingTools();
|
|
1918
|
+
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
1919
|
+
expect(configCheck?.severity).toBe("pass");
|
|
1920
|
+
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
1921
|
+
});
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
// ---------------------------------------------------------------------------
|
|
1925
|
+
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
1926
|
+
// ---------------------------------------------------------------------------
|
|
1927
|
+
|
|
1928
|
+
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
1929
|
+
it("handles non-Error thrown during dispatch state read", async () => {
|
|
1930
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
1931
|
+
const statePath = join(tmpDir, "state.json");
|
|
1932
|
+
writeFileSync(statePath, '{}');
|
|
1933
|
+
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
1934
|
+
|
|
1935
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
1936
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
1937
|
+
expect(stateCheck?.severity).toBe("fail");
|
|
1938
|
+
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
1939
|
+
});
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
// ---------------------------------------------------------------------------
|
|
1943
|
+
// checkFilesAndDirs — lock file branches
|
|
1944
|
+
// ---------------------------------------------------------------------------
|
|
1945
|
+
|
|
1946
|
+
describe("checkFilesAndDirs — lock file branches", () => {
|
|
1947
|
+
let tmpDir: string;
|
|
1948
|
+
|
|
1949
|
+
beforeEach(() => {
|
|
1950
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-lock-"));
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
it("warns about stale lock file without --fix", async () => {
|
|
1954
|
+
const statePath = join(tmpDir, "state.json");
|
|
1955
|
+
const lockPath = statePath + ".lock";
|
|
1956
|
+
writeFileSync(statePath, '{}');
|
|
1957
|
+
writeFileSync(lockPath, "locked");
|
|
1958
|
+
// Make the lock file appear stale by backdating its mtime
|
|
1959
|
+
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1960
|
+
const { utimesSync } = await import("node:fs");
|
|
1961
|
+
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1962
|
+
|
|
1963
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1964
|
+
dispatches: { active: {}, completed: {} },
|
|
1965
|
+
sessionMap: {},
|
|
1966
|
+
processedEvents: [],
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1970
|
+
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1971
|
+
expect(lockCheck?.severity).toBe("warn");
|
|
1972
|
+
expect(lockCheck?.fixable).toBe(true);
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
it("removes stale lock file with --fix", async () => {
|
|
1976
|
+
const statePath = join(tmpDir, "state.json");
|
|
1977
|
+
const lockPath = statePath + ".lock";
|
|
1978
|
+
writeFileSync(statePath, '{}');
|
|
1979
|
+
writeFileSync(lockPath, "locked");
|
|
1980
|
+
const staleTime = Date.now() - 60_000;
|
|
1981
|
+
const { utimesSync } = await import("node:fs");
|
|
1982
|
+
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1983
|
+
|
|
1984
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1985
|
+
dispatches: { active: {}, completed: {} },
|
|
1986
|
+
sessionMap: {},
|
|
1987
|
+
processedEvents: [],
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1991
|
+
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1992
|
+
expect(lockCheck?.severity).toBe("pass");
|
|
1993
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
it("warns about active (non-stale) lock file", async () => {
|
|
1997
|
+
const statePath = join(tmpDir, "state.json");
|
|
1998
|
+
const lockPath = statePath + ".lock";
|
|
1999
|
+
writeFileSync(statePath, '{}');
|
|
2000
|
+
writeFileSync(lockPath, "locked");
|
|
2001
|
+
// Lock file is fresh (just created), so lockAge < LOCK_STALE_MS
|
|
2002
|
+
|
|
2003
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
2004
|
+
dispatches: { active: {}, completed: {} },
|
|
2005
|
+
sessionMap: {},
|
|
2006
|
+
processedEvents: [],
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
2010
|
+
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
2011
|
+
expect(lockCheck?.severity).toBe("warn");
|
|
2012
|
+
expect(lockCheck?.label).toContain("may be in use");
|
|
2013
|
+
});
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
// ---------------------------------------------------------------------------
|
|
2017
|
+
// checkFilesAndDirs — prompt variable edge cases
|
|
2018
|
+
// ---------------------------------------------------------------------------
|
|
2019
|
+
|
|
2020
|
+
describe("checkFilesAndDirs — prompt variable edge cases", () => {
|
|
2021
|
+
it("reports when variable missing from worker.task but present in audit.task", async () => {
|
|
2022
|
+
vi.mocked(loadPrompts).mockReturnValueOnce({
|
|
2023
|
+
worker: {
|
|
2024
|
+
system: "ok",
|
|
2025
|
+
task: "Do something", // missing all vars
|
|
2026
|
+
},
|
|
2027
|
+
audit: {
|
|
2028
|
+
system: "ok",
|
|
2029
|
+
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
2030
|
+
},
|
|
2031
|
+
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
2032
|
+
} as any);
|
|
2033
|
+
|
|
2034
|
+
const checks = await checkFilesAndDirs();
|
|
2035
|
+
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
2036
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
2037
|
+
expect(promptCheck?.label).toContain("worker.task missing");
|
|
2038
|
+
// Crucially, audit.task should NOT be missing
|
|
2039
|
+
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
it("reports loadPrompts throwing non-Error value", async () => {
|
|
2043
|
+
vi.mocked(loadPrompts).mockImplementationOnce(() => { throw "raw string error"; });
|
|
2044
|
+
|
|
2045
|
+
const checks = await checkFilesAndDirs();
|
|
2046
|
+
const promptCheck = checks.find((c) => c.label.includes("Failed to load prompts"));
|
|
2047
|
+
expect(promptCheck?.severity).toBe("fail");
|
|
2048
|
+
expect(promptCheck?.detail).toContain("raw string error");
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
// ---------------------------------------------------------------------------
|
|
2053
|
+
// checkFilesAndDirs — worktree & base repo edge cases
|
|
2054
|
+
// ---------------------------------------------------------------------------
|
|
2055
|
+
|
|
2056
|
+
describe("checkFilesAndDirs — worktree & base repo edge cases", () => {
|
|
2057
|
+
it("reports worktree base dir does not exist", async () => {
|
|
2058
|
+
const checks = await checkFilesAndDirs({
|
|
2059
|
+
worktreeBaseDir: "/tmp/nonexistent-worktree-dir-" + Date.now(),
|
|
2060
|
+
});
|
|
2061
|
+
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir does not exist"));
|
|
2062
|
+
expect(wtCheck?.severity).toBe("warn");
|
|
2063
|
+
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it("reports base repo does not exist", async () => {
|
|
2067
|
+
const checks = await checkFilesAndDirs({
|
|
2068
|
+
codexBaseRepo: "/tmp/nonexistent-repo-" + Date.now(),
|
|
2069
|
+
});
|
|
2070
|
+
const repoCheck = checks.find((c) => c.label.includes("Base repo does not exist"));
|
|
2071
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
2072
|
+
expect(repoCheck?.fix).toContain("codexBaseRepo");
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
it("reports base repo exists but is not a git repo", async () => {
|
|
2076
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
2077
|
+
const checks = await checkFilesAndDirs({
|
|
2078
|
+
codexBaseRepo: tmpDir,
|
|
2079
|
+
});
|
|
2080
|
+
const repoCheck = checks.find((c) => c.label.includes("Base repo is not a git repo"));
|
|
2081
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
2082
|
+
expect(repoCheck?.fix).toContain("git init");
|
|
2083
|
+
});
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
// ---------------------------------------------------------------------------
|
|
2087
|
+
// checkFilesAndDirs — tilde path resolution branches
|
|
2088
|
+
// ---------------------------------------------------------------------------
|
|
2089
|
+
|
|
2090
|
+
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
2091
|
+
it("resolves ~/... dispatch state path", async () => {
|
|
2092
|
+
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
2093
|
+
|
|
2094
|
+
// Providing a path with ~/ triggers the tilde resolution branch
|
|
2095
|
+
const checks = await checkFilesAndDirs({
|
|
2096
|
+
dispatchStatePath: "~/nonexistent-state-file.json",
|
|
2097
|
+
});
|
|
2098
|
+
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
2099
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
2100
|
+
expect(stateCheck).toBeDefined();
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
it("resolves ~/... worktree base dir path", async () => {
|
|
2104
|
+
const checks = await checkFilesAndDirs({
|
|
2105
|
+
worktreeBaseDir: "~/nonexistent-worktree-base",
|
|
2106
|
+
});
|
|
2107
|
+
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
2108
|
+
expect(wtCheck).toBeDefined();
|
|
2109
|
+
});
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
// ---------------------------------------------------------------------------
|
|
2113
|
+
// checkDispatchHealth — orphaned worktree singular
|
|
2114
|
+
// ---------------------------------------------------------------------------
|
|
2115
|
+
|
|
2116
|
+
describe("checkDispatchHealth — edge cases", () => {
|
|
2117
|
+
it("reports single orphaned worktree (singular)", async () => {
|
|
2118
|
+
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
2119
|
+
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
2120
|
+
]);
|
|
2121
|
+
|
|
2122
|
+
const checks = await checkDispatchHealth();
|
|
2123
|
+
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
2124
|
+
expect(orphanCheck?.severity).toBe("warn");
|
|
2125
|
+
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
2126
|
+
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
2130
|
+
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
2131
|
+
dispatches: {
|
|
2132
|
+
active: {},
|
|
2133
|
+
completed: {
|
|
2134
|
+
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
2135
|
+
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
2136
|
+
},
|
|
2137
|
+
},
|
|
2138
|
+
sessionMap: {},
|
|
2139
|
+
processedEvents: [],
|
|
2140
|
+
});
|
|
2141
|
+
vi.mocked(pruneCompleted).mockResolvedValueOnce(2);
|
|
2142
|
+
|
|
2143
|
+
const checks = await checkDispatchHealth(undefined, true);
|
|
2144
|
+
const pruneCheck = checks.find((c) => c.label.includes("Pruned"));
|
|
2145
|
+
expect(pruneCheck?.severity).toBe("pass");
|
|
2146
|
+
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
it("reports single stale dispatch (singular)", async () => {
|
|
2150
|
+
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
2151
|
+
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
2152
|
+
]);
|
|
2153
|
+
|
|
2154
|
+
const checks = await checkDispatchHealth();
|
|
2155
|
+
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
2156
|
+
expect(staleCheck?.severity).toBe("warn");
|
|
2157
|
+
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
2158
|
+
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
2159
|
+
});
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
// ---------------------------------------------------------------------------
|
|
2163
|
+
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
2164
|
+
// ---------------------------------------------------------------------------
|
|
2165
|
+
|
|
2166
|
+
describe("checkConnectivity — webhook non-ok body", () => {
|
|
2167
|
+
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
2168
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
2169
|
+
if (url.includes("localhost")) {
|
|
2170
|
+
return { ok: true, text: async () => "pong" };
|
|
2171
|
+
}
|
|
2172
|
+
throw new Error("unexpected");
|
|
2173
|
+
}));
|
|
2174
|
+
|
|
2175
|
+
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
2176
|
+
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
2177
|
+
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
2178
|
+
// Since body !== "ok", it falls into the warn branch
|
|
2179
|
+
expect(webhookCheck?.severity).toBe("warn");
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
// ---------------------------------------------------------------------------
|
|
2184
|
+
// formatReport — icon function TTY branches
|
|
2185
|
+
// ---------------------------------------------------------------------------
|
|
2186
|
+
|
|
2187
|
+
describe("formatReport — TTY icon rendering", () => {
|
|
2188
|
+
it("renders colored icons when stdout.isTTY is true", () => {
|
|
2189
|
+
const origIsTTY = process.stdout.isTTY;
|
|
2190
|
+
try {
|
|
2191
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
2192
|
+
|
|
2193
|
+
const report = {
|
|
2194
|
+
sections: [{
|
|
2195
|
+
name: "Test",
|
|
2196
|
+
checks: [
|
|
2197
|
+
{ label: "pass check", severity: "pass" as const },
|
|
2198
|
+
{ label: "warn check", severity: "warn" as const },
|
|
2199
|
+
{ label: "fail check", severity: "fail" as const },
|
|
2200
|
+
],
|
|
2201
|
+
}],
|
|
2202
|
+
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
2203
|
+
};
|
|
2204
|
+
|
|
2205
|
+
const output = formatReport(report);
|
|
2206
|
+
// TTY output includes ANSI escape codes
|
|
2207
|
+
expect(output).toContain("\x1b[32m"); // green for pass
|
|
2208
|
+
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
2209
|
+
expect(output).toContain("\x1b[31m"); // red for fail
|
|
2210
|
+
} finally {
|
|
2211
|
+
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
// ---------------------------------------------------------------------------
|
|
2217
|
+
// checkCodingTools — codingTool fallback to "codex" in label
|
|
2218
|
+
// ---------------------------------------------------------------------------
|
|
2219
|
+
|
|
2220
|
+
describe("checkCodingTools — codingTool null fallback", () => {
|
|
2221
|
+
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
2222
|
+
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
2223
|
+
codingTool: undefined,
|
|
2224
|
+
backends: { codex: { aliases: ["codex"] } },
|
|
2225
|
+
} as any);
|
|
2226
|
+
|
|
2227
|
+
const checks = checkCodingTools();
|
|
2228
|
+
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
2229
|
+
expect(configCheck?.severity).toBe("pass");
|
|
2230
|
+
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
2231
|
+
});
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
// ---------------------------------------------------------------------------
|
|
2235
|
+
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
2236
|
+
// ---------------------------------------------------------------------------
|
|
2237
|
+
|
|
2238
|
+
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
2239
|
+
it("handles non-Error thrown during dispatch state read", async () => {
|
|
2240
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
2241
|
+
const statePath = join(tmpDir, "state.json");
|
|
2242
|
+
writeFileSync(statePath, '{}');
|
|
2243
|
+
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
2244
|
+
|
|
2245
|
+
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
2246
|
+
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
2247
|
+
expect(stateCheck?.severity).toBe("fail");
|
|
2248
|
+
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
2249
|
+
});
|
|
2250
|
+
});
|