@calltelemetry/openclaw-linear 0.9.3 → 0.9.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.
@@ -1290,7 +1290,7 @@ describe("buildSummary — additional branches", () => {
1290
1290
  // ---------------------------------------------------------------------------
1291
1291
 
1292
1292
  describe("runDoctor — additional branches", () => {
1293
- it("applies --fix to auth-profiles.json permissions when fixable check exists", async () => {
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,623 @@ 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
+ });