@calltelemetry/openclaw-linear 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
- import { mkdtempSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
 
@@ -90,9 +90,11 @@ import {
90
90
  } from "./doctor.js";
91
91
 
92
92
  import { resolveLinearToken } from "../api/linear-api.js";
93
- import { readDispatchState, listStaleDispatches, pruneCompleted } from "../pipeline/dispatch-state.js";
93
+ import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted } from "../pipeline/dispatch-state.js";
94
94
  import { loadPrompts } from "../pipeline/pipeline.js";
95
95
  import { listWorktrees } from "./codex-worktree.js";
96
+ import { loadCodingConfig } from "../tools/code-tool.js";
97
+ import { getWebhookStatus, provisionWebhook } from "./webhook-provision.js";
96
98
 
97
99
  afterEach(() => {
98
100
  vi.restoreAllMocks();
@@ -558,3 +560,761 @@ describe.skipIf(process.env.CI)("checkCodeRunDeep", () => {
558
560
  }
559
561
  }, 120_000);
560
562
  });
563
+
564
+ // ===========================================================================
565
+ // Additional branch coverage tests
566
+ // ===========================================================================
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // checkAuth — additional branches
570
+ // ---------------------------------------------------------------------------
571
+
572
+ describe("checkAuth — additional branches", () => {
573
+ it("warns when token expires soon (< 1 hour remaining)", async () => {
574
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
575
+ accessToken: "tok",
576
+ expiresAt: Date.now() + 30 * 60_000, // 30 minutes from now
577
+ refreshToken: "refresh",
578
+ source: "profile",
579
+ });
580
+ vi.stubGlobal("fetch", vi.fn(async () => ({
581
+ ok: true,
582
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
583
+ })));
584
+
585
+ const { checks } = await checkAuth();
586
+ const expiryCheck = checks.find((c) => c.label.includes("expires soon"));
587
+ expect(expiryCheck?.severity).toBe("warn");
588
+ expect(expiryCheck?.label).toContain("m remaining");
589
+ expect(expiryCheck?.fix).toContain("auto-refresh");
590
+ });
591
+
592
+ it("reports fail when API returns non-ok status", async () => {
593
+ vi.stubGlobal("fetch", vi.fn(async () => ({
594
+ ok: false,
595
+ status: 401,
596
+ statusText: "Unauthorized",
597
+ })));
598
+
599
+ const { checks } = await checkAuth();
600
+ const apiCheck = checks.find((c) => c.label.includes("API returned"));
601
+ expect(apiCheck?.severity).toBe("fail");
602
+ expect(apiCheck?.label).toContain("401");
603
+ expect(apiCheck?.label).toContain("Unauthorized");
604
+ });
605
+
606
+ it("reports fail when API returns GraphQL errors", async () => {
607
+ vi.stubGlobal("fetch", vi.fn(async () => ({
608
+ ok: true,
609
+ json: async () => ({ errors: [{ message: "Invalid scope" }] }),
610
+ })));
611
+
612
+ const { checks } = await checkAuth();
613
+ const apiCheck = checks.find((c) => c.label.includes("API error"));
614
+ expect(apiCheck?.severity).toBe("fail");
615
+ expect(apiCheck?.label).toContain("Invalid scope");
616
+ });
617
+
618
+ it("uses token directly (no Bearer prefix) when no refreshToken", async () => {
619
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
620
+ accessToken: "lin_api_direct",
621
+ source: "config",
622
+ // No refreshToken, no expiresAt
623
+ });
624
+ const fetchMock = vi.fn(async () => ({
625
+ ok: true,
626
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
627
+ }));
628
+ vi.stubGlobal("fetch", fetchMock);
629
+
630
+ await checkAuth();
631
+ // The Authorization header should be the token directly, not "Bearer ..."
632
+ const callArgs = fetchMock.mock.calls[0];
633
+ const headers = (callArgs[1] as any).headers;
634
+ expect(headers.Authorization).toBe("lin_api_direct");
635
+ });
636
+
637
+ it("reports OAuth credentials configured from pluginConfig", async () => {
638
+ vi.stubGlobal("fetch", vi.fn(async () => ({
639
+ ok: true,
640
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
641
+ })));
642
+
643
+ const { checks } = await checkAuth({
644
+ clientId: "my-client-id",
645
+ clientSecret: "my-secret",
646
+ });
647
+ const oauthCheck = checks.find((c) => c.label.includes("OAuth credentials configured"));
648
+ expect(oauthCheck?.severity).toBe("pass");
649
+ });
650
+
651
+ it("reports OAuth credentials configured from env vars", async () => {
652
+ const origId = process.env.LINEAR_CLIENT_ID;
653
+ const origSecret = process.env.LINEAR_CLIENT_SECRET;
654
+ process.env.LINEAR_CLIENT_ID = "env-id";
655
+ process.env.LINEAR_CLIENT_SECRET = "env-secret";
656
+
657
+ vi.stubGlobal("fetch", vi.fn(async () => ({
658
+ ok: true,
659
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
660
+ })));
661
+
662
+ try {
663
+ const { checks } = await checkAuth();
664
+ const oauthCheck = checks.find((c) => c.label.includes("OAuth credentials configured"));
665
+ expect(oauthCheck?.severity).toBe("pass");
666
+ } finally {
667
+ if (origId) process.env.LINEAR_CLIENT_ID = origId;
668
+ else delete process.env.LINEAR_CLIENT_ID;
669
+ if (origSecret) process.env.LINEAR_CLIENT_SECRET = origSecret;
670
+ else delete process.env.LINEAR_CLIENT_SECRET;
671
+ }
672
+ });
673
+
674
+ it("skips no-expiresAt branch (no expiry check when token has no expiresAt)", async () => {
675
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
676
+ accessToken: "tok",
677
+ source: "config",
678
+ // No expiresAt
679
+ });
680
+ vi.stubGlobal("fetch", vi.fn(async () => ({
681
+ ok: true,
682
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
683
+ })));
684
+
685
+ const { checks } = await checkAuth();
686
+ // Should NOT have any expiry-related check
687
+ const expiryCheck = checks.find((c) => c.label.includes("expired") || c.label.includes("expires") || c.label.includes("not expired"));
688
+ expect(expiryCheck).toBeUndefined();
689
+ // Should still have the token pass check
690
+ const tokenCheck = checks.find((c) => c.label.includes("Access token"));
691
+ expect(tokenCheck?.severity).toBe("pass");
692
+ });
693
+
694
+ it("handles non-Error thrown in API catch", async () => {
695
+ vi.stubGlobal("fetch", vi.fn(async () => { throw "string error"; }));
696
+
697
+ const { checks } = await checkAuth();
698
+ const apiCheck = checks.find((c) => c.label.includes("unreachable"));
699
+ expect(apiCheck?.severity).toBe("fail");
700
+ expect(apiCheck?.label).toContain("string error");
701
+ });
702
+
703
+ it("warns about auth-profiles.json not found when source is profile", async () => {
704
+ // Ensure the mocked auth-profiles path does not exist
705
+ const testAuthPath = "/tmp/test-auth-profiles.json";
706
+ if (existsSync(testAuthPath)) unlinkSync(testAuthPath);
707
+
708
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
709
+ accessToken: "tok",
710
+ source: "profile",
711
+ refreshToken: "refresh",
712
+ expiresAt: Date.now() + 24 * 3_600_000,
713
+ });
714
+ vi.stubGlobal("fetch", vi.fn(async () => ({
715
+ ok: true,
716
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
717
+ })));
718
+
719
+ // AUTH_PROFILES_PATH is mocked to /tmp/test-auth-profiles.json which doesn't exist
720
+ // so statSync will throw, and since source is "profile", we get the warning
721
+ const { checks } = await checkAuth();
722
+ const permCheck = checks.find((c) => c.label.includes("auth-profiles.json not found"));
723
+ expect(permCheck?.severity).toBe("warn");
724
+ });
725
+
726
+ it("silently ignores auth-profiles.json not found when source is not profile", async () => {
727
+ // Ensure the mocked auth-profiles path does not exist
728
+ const testAuthPath = "/tmp/test-auth-profiles.json";
729
+ if (existsSync(testAuthPath)) unlinkSync(testAuthPath);
730
+
731
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
732
+ accessToken: "tok",
733
+ source: "config",
734
+ });
735
+ vi.stubGlobal("fetch", vi.fn(async () => ({
736
+ ok: true,
737
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
738
+ })));
739
+
740
+ const { checks } = await checkAuth();
741
+ // Should NOT have auth-profiles warning since source is not "profile"
742
+ const permCheck = checks.find((c) => c.label.includes("auth-profiles.json not found"));
743
+ expect(permCheck).toBeUndefined();
744
+ });
745
+ });
746
+
747
+ // ---------------------------------------------------------------------------
748
+ // checkCodingTools — additional branches
749
+ // ---------------------------------------------------------------------------
750
+
751
+ describe("checkCodingTools — additional branches", () => {
752
+ it("reports warn when config is empty (no codingTool, no backends)", () => {
753
+ vi.mocked(loadCodingConfig).mockReturnValueOnce({} as any);
754
+
755
+ const checks = checkCodingTools();
756
+ const configCheck = checks.find((c) => c.label.includes("coding-tools.json not found or empty"));
757
+ expect(configCheck?.severity).toBe("warn");
758
+ expect(configCheck?.fix).toContain("Create coding-tools.json");
759
+ });
760
+
761
+ it("reports fail for unknown default backend", () => {
762
+ vi.mocked(loadCodingConfig).mockReturnValueOnce({
763
+ codingTool: "unknown-backend",
764
+ backends: { "unknown-backend": {} },
765
+ } as any);
766
+
767
+ const checks = checkCodingTools();
768
+ const backendCheck = checks.find((c) => c.label.includes("Unknown default backend"));
769
+ expect(backendCheck?.severity).toBe("fail");
770
+ expect(backendCheck?.label).toContain("unknown-backend");
771
+ });
772
+
773
+ it("reports warn for invalid per-agent override", () => {
774
+ vi.mocked(loadCodingConfig).mockReturnValueOnce({
775
+ codingTool: "codex",
776
+ agentCodingTools: { "testAgent": "invalid-backend" },
777
+ backends: {},
778
+ } as any);
779
+
780
+ const checks = checkCodingTools();
781
+ const overrideCheck = checks.find((c) => c.label.includes("testAgent"));
782
+ expect(overrideCheck?.severity).toBe("warn");
783
+ expect(overrideCheck?.label).toContain("invalid-backend");
784
+ expect(overrideCheck?.label).toContain("not a valid backend");
785
+ });
786
+ });
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // checkFilesAndDirs — additional branches
790
+ // ---------------------------------------------------------------------------
791
+
792
+ describe("checkFilesAndDirs — additional branches", () => {
793
+ it("reports fail when dispatch state is corrupt", async () => {
794
+ // Create the file so existsSync returns true, then readDispatchState throws
795
+ const tmpState = join(mkdtempSync(join(tmpdir(), "doctor-state-")), "state.json");
796
+ writeFileSync(tmpState, "invalid json");
797
+ vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("JSON parse error"));
798
+
799
+ const checks = await checkFilesAndDirs({ dispatchStatePath: tmpState });
800
+ const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
801
+ expect(stateCheck?.severity).toBe("fail");
802
+ expect(stateCheck?.detail).toContain("JSON parse error");
803
+ });
804
+
805
+ it("reports fail when loadPrompts throws", async () => {
806
+ vi.mocked(loadPrompts).mockImplementationOnce(() => { throw new Error("template file missing"); });
807
+
808
+ const checks = await checkFilesAndDirs();
809
+ const promptCheck = checks.find((c) => c.label.includes("Failed to load prompts"));
810
+ expect(promptCheck?.severity).toBe("fail");
811
+ expect(promptCheck?.detail).toContain("template file missing");
812
+ });
813
+
814
+ it("reports missing rework.addendum as prompt issue", async () => {
815
+ vi.mocked(loadPrompts).mockReturnValueOnce({
816
+ worker: {
817
+ system: "You are a worker",
818
+ task: "Fix {{identifier}} {{title}} {{description}} in {{worktreePath}}",
819
+ },
820
+ audit: {
821
+ system: "You are an auditor",
822
+ task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}",
823
+ },
824
+ rework: { addendum: "" }, // falsy — should count as missing
825
+ } as any);
826
+
827
+ const checks = await checkFilesAndDirs();
828
+ const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
829
+ expect(promptCheck?.severity).toBe("fail");
830
+ expect(promptCheck?.label).toContain("Missing rework.addendum");
831
+ });
832
+
833
+ it("reports when variable missing from audit.task but present in worker.task", async () => {
834
+ vi.mocked(loadPrompts).mockReturnValueOnce({
835
+ worker: {
836
+ system: "ok",
837
+ task: "Fix {{identifier}} {{title}} {{description}} in {{worktreePath}}",
838
+ },
839
+ audit: {
840
+ system: "ok",
841
+ task: "Audit the issue please", // missing all vars
842
+ },
843
+ rework: { addendum: "Fix these gaps: {{gaps}}" },
844
+ } as any);
845
+
846
+ const checks = await checkFilesAndDirs();
847
+ const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
848
+ expect(promptCheck?.severity).toBe("fail");
849
+ expect(promptCheck?.label).toContain("audit.task missing");
850
+ });
851
+ });
852
+
853
+ // ---------------------------------------------------------------------------
854
+ // checkConnectivity — additional branches
855
+ // ---------------------------------------------------------------------------
856
+
857
+ describe("checkConnectivity — additional branches", () => {
858
+ it("re-checks Linear API when no authCtx, token available, and API returns ok", async () => {
859
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
860
+ if (url.includes("linear.app")) {
861
+ return { ok: true, json: async () => ({ data: { viewer: { id: "1" } } }) };
862
+ }
863
+ // webhook self-test
864
+ throw new Error("ECONNREFUSED");
865
+ }));
866
+
867
+ const checks = await checkConnectivity();
868
+ const apiCheck = checks.find((c) => c.label === "Linear API: connected");
869
+ expect(apiCheck?.severity).toBe("pass");
870
+ });
871
+
872
+ it("reports fail when API returns non-ok without authCtx", async () => {
873
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
874
+ if (url.includes("linear.app")) {
875
+ return { ok: false, status: 403, statusText: "Forbidden" };
876
+ }
877
+ throw new Error("ECONNREFUSED");
878
+ }));
879
+
880
+ const checks = await checkConnectivity();
881
+ const apiCheck = checks.find((c) => c.label.includes("Linear API: 403"));
882
+ expect(apiCheck?.severity).toBe("fail");
883
+ });
884
+
885
+ it("reports fail when API throws without authCtx", async () => {
886
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
887
+ if (url.includes("linear.app")) {
888
+ throw new Error("DNS resolution failed");
889
+ }
890
+ throw new Error("ECONNREFUSED");
891
+ }));
892
+
893
+ const checks = await checkConnectivity();
894
+ const apiCheck = checks.find((c) => c.label.includes("Linear API: unreachable"));
895
+ expect(apiCheck?.severity).toBe("fail");
896
+ expect(apiCheck?.label).toContain("DNS resolution failed");
897
+ });
898
+
899
+ it("reports fail when no token available and no authCtx", async () => {
900
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
901
+ accessToken: null,
902
+ source: "none",
903
+ });
904
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNREFUSED"); }));
905
+
906
+ const checks = await checkConnectivity();
907
+ const apiCheck = checks.find((c) => c.label.includes("Linear API: no token"));
908
+ expect(apiCheck?.severity).toBe("fail");
909
+ });
910
+
911
+ it("reports notification targets when configured", async () => {
912
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNREFUSED"); }));
913
+
914
+ const checks = await checkConnectivity(
915
+ {
916
+ notifications: {
917
+ targets: [
918
+ { channel: "discord", target: "#ct-ai" },
919
+ { channel: "telegram", target: "-1003884997363" },
920
+ ],
921
+ },
922
+ },
923
+ { viewer: { name: "T" } },
924
+ );
925
+ const notifChecks = checks.filter((c) => c.label.includes("Notifications:"));
926
+ expect(notifChecks).toHaveLength(2);
927
+ expect(notifChecks[0].label).toContain("discord");
928
+ expect(notifChecks[1].label).toContain("telegram");
929
+ });
930
+
931
+ it("reports pass when webhook self-test responds OK", async () => {
932
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
933
+ if (url.includes("localhost")) {
934
+ return { ok: true, text: async () => "ok" };
935
+ }
936
+ throw new Error("unexpected");
937
+ }));
938
+
939
+ const checks = await checkConnectivity({}, { viewer: { name: "T" } });
940
+ const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test: responds OK"));
941
+ expect(webhookCheck?.severity).toBe("pass");
942
+ });
943
+
944
+ it("reports warn when webhook self-test responds non-ok", async () => {
945
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
946
+ if (url.includes("localhost")) {
947
+ return { ok: false, status: 404, text: async () => "not found" };
948
+ }
949
+ throw new Error("unexpected");
950
+ }));
951
+
952
+ const checks = await checkConnectivity({}, { viewer: { name: "T" } });
953
+ const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
954
+ expect(webhookCheck?.severity).toBe("warn");
955
+ expect(webhookCheck?.label).toContain("404");
956
+ });
957
+
958
+ it("uses token directly without Bearer when no refreshToken (connectivity re-check)", async () => {
959
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
960
+ accessToken: "lin_api_direct",
961
+ source: "config",
962
+ // No refreshToken
963
+ });
964
+ const fetchMock = vi.fn(async (url: string) => {
965
+ if (url.includes("linear.app")) {
966
+ return { ok: true, json: async () => ({ data: { viewer: { id: "1" } } }) };
967
+ }
968
+ throw new Error("ECONNREFUSED");
969
+ });
970
+ vi.stubGlobal("fetch", fetchMock);
971
+
972
+ await checkConnectivity(); // No authCtx, triggers re-check
973
+ const linearCall = fetchMock.mock.calls.find((c) => (c[0] as string).includes("linear.app"));
974
+ const headers = (linearCall![1] as any).headers;
975
+ expect(headers.Authorization).toBe("lin_api_direct");
976
+ });
977
+ });
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // checkDispatchHealth — additional branches
981
+ // ---------------------------------------------------------------------------
982
+
983
+ describe("checkDispatchHealth — additional branches", () => {
984
+ it("reports pass when readDispatchState throws (no state file)", async () => {
985
+ vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
986
+
987
+ const checks = await checkDispatchHealth();
988
+ const healthCheck = checks.find((c) => c.label.includes("Dispatch health: no state file"));
989
+ expect(healthCheck?.severity).toBe("pass");
990
+ });
991
+
992
+ it("warns about active dispatches with stuck status", async () => {
993
+ vi.mocked(listActiveDispatches).mockReturnValueOnce([
994
+ { issueIdentifier: "API-1", status: "stuck" } as any,
995
+ { issueIdentifier: "API-2", status: "working" } as any,
996
+ ]);
997
+
998
+ const checks = await checkDispatchHealth();
999
+ const activeCheck = checks.find((c) => c.label.includes("Active dispatches:"));
1000
+ expect(activeCheck?.severity).toBe("warn");
1001
+ expect(activeCheck?.label).toContain("stuck");
1002
+ });
1003
+
1004
+ it("passes for active dispatches without stuck status", async () => {
1005
+ vi.mocked(listActiveDispatches).mockReturnValueOnce([
1006
+ { issueIdentifier: "API-1", status: "working" } as any,
1007
+ { issueIdentifier: "API-2", status: "auditing" } as any,
1008
+ ]);
1009
+
1010
+ const checks = await checkDispatchHealth();
1011
+ const activeCheck = checks.find((c) => c.label.includes("Active dispatches:"));
1012
+ expect(activeCheck?.severity).toBe("pass");
1013
+ expect(activeCheck?.label).toContain("working");
1014
+ expect(activeCheck?.label).toContain("auditing");
1015
+ });
1016
+
1017
+ it("reports orphaned worktrees", async () => {
1018
+ vi.mocked(listWorktrees).mockReturnValueOnce([
1019
+ { issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
1020
+ { issueIdentifier: "ORPHAN-2", path: "/tmp/wt2" } as any,
1021
+ ]);
1022
+
1023
+ const checks = await checkDispatchHealth();
1024
+ const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
1025
+ expect(orphanCheck?.severity).toBe("warn");
1026
+ expect(orphanCheck?.label).toContain("2 orphaned worktrees");
1027
+ expect(orphanCheck?.detail).toContain("/tmp/wt1");
1028
+ });
1029
+
1030
+ it("warns about old completed without fix (plural)", async () => {
1031
+ vi.mocked(readDispatchState).mockResolvedValueOnce({
1032
+ dispatches: {
1033
+ active: {},
1034
+ completed: {
1035
+ "API-OLD-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString(), status: "done" } as any,
1036
+ "API-OLD-2": { completedAt: new Date(Date.now() - 8 * 24 * 3_600_000).toISOString(), status: "done" } as any,
1037
+ },
1038
+ },
1039
+ sessionMap: {},
1040
+ processedEvents: [],
1041
+ });
1042
+
1043
+ const checks = await checkDispatchHealth(undefined, false);
1044
+ const oldCheck = checks.find((c) => c.label.includes("completed dispatch"));
1045
+ expect(oldCheck?.severity).toBe("warn");
1046
+ expect(oldCheck?.label).toContain("2 completed dispatches");
1047
+ expect(oldCheck?.fixable).toBe(true);
1048
+ });
1049
+
1050
+ it("reports multiple stale dispatches with plural", async () => {
1051
+ vi.mocked(listStaleDispatches).mockReturnValueOnce([
1052
+ { issueIdentifier: "API-1", status: "working" } as any,
1053
+ { issueIdentifier: "API-2", status: "auditing" } as any,
1054
+ ]);
1055
+
1056
+ const checks = await checkDispatchHealth();
1057
+ const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
1058
+ expect(staleCheck?.severity).toBe("warn");
1059
+ expect(staleCheck?.label).toContain("2 stale dispatches");
1060
+ });
1061
+ });
1062
+
1063
+ // ---------------------------------------------------------------------------
1064
+ // checkWebhooks — additional branches
1065
+ // ---------------------------------------------------------------------------
1066
+
1067
+ describe("checkWebhooks — additional branches", () => {
1068
+ it("warns and skips when no Linear token", async () => {
1069
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
1070
+ accessToken: null,
1071
+ source: "none",
1072
+ });
1073
+
1074
+ const checks = await checkWebhooks();
1075
+ expect(checks).toHaveLength(1);
1076
+ expect(checks[0].severity).toBe("warn");
1077
+ expect(checks[0].label).toContain("Webhook check skipped");
1078
+ });
1079
+
1080
+ it("reports pass when webhook status has no issues", async () => {
1081
+ vi.mocked(getWebhookStatus).mockResolvedValueOnce({
1082
+ webhookId: "wh-1",
1083
+ url: "https://example.com/webhook",
1084
+ enabled: true,
1085
+ resourceTypes: ["Comment", "Issue"],
1086
+ issues: [],
1087
+ } as any);
1088
+
1089
+ const checks = await checkWebhooks();
1090
+ const whCheck = checks.find((c) => c.label.includes("Workspace webhook OK"));
1091
+ expect(whCheck?.severity).toBe("pass");
1092
+ expect(whCheck?.label).toContain("Comment");
1093
+ expect(whCheck?.label).toContain("Issue");
1094
+ });
1095
+
1096
+ it("reports warnings for webhook issues without fix", async () => {
1097
+ vi.mocked(getWebhookStatus).mockResolvedValueOnce({
1098
+ webhookId: "wh-1",
1099
+ url: "https://example.com/webhook",
1100
+ enabled: true,
1101
+ resourceTypes: ["Comment"],
1102
+ issues: ["Missing resource type: Issue", "Webhook disabled"],
1103
+ } as any);
1104
+
1105
+ const checks = await checkWebhooks(undefined, false);
1106
+ const issueChecks = checks.filter((c) => c.label.includes("Webhook issue:"));
1107
+ expect(issueChecks).toHaveLength(2);
1108
+ expect(issueChecks[0].severity).toBe("warn");
1109
+ expect(issueChecks[0].label).toContain("Missing resource type");
1110
+ expect(issueChecks[1].label).toContain("Webhook disabled");
1111
+ expect(issueChecks[0].fixable).toBe(true);
1112
+ });
1113
+
1114
+ it("fixes webhook issues with --fix", async () => {
1115
+ vi.mocked(getWebhookStatus).mockResolvedValueOnce({
1116
+ webhookId: "wh-1",
1117
+ url: "https://example.com/webhook",
1118
+ enabled: true,
1119
+ resourceTypes: ["Comment"],
1120
+ issues: ["Missing resource type: Issue"],
1121
+ } as any);
1122
+ vi.mocked(provisionWebhook).mockResolvedValueOnce({
1123
+ action: "updated",
1124
+ webhookId: "wh-1",
1125
+ changes: ["added Issue resource type"],
1126
+ } as any);
1127
+
1128
+ const checks = await checkWebhooks(undefined, true);
1129
+ const fixCheck = checks.find((c) => c.label.includes("Workspace webhook fixed"));
1130
+ expect(fixCheck?.severity).toBe("pass");
1131
+ expect(fixCheck?.label).toContain("added Issue resource type");
1132
+ });
1133
+
1134
+ it("creates webhook with --fix when none found", async () => {
1135
+ // getWebhookStatus already mocked to return null by default
1136
+ vi.mocked(provisionWebhook).mockResolvedValueOnce({
1137
+ action: "created",
1138
+ webhookId: "wh-new",
1139
+ changes: ["created"],
1140
+ } as any);
1141
+
1142
+ const checks = await checkWebhooks(undefined, true);
1143
+ const createCheck = checks.find((c) => c.label.includes("Workspace webhook created"));
1144
+ expect(createCheck?.severity).toBe("pass");
1145
+ expect(createCheck?.label).toContain("wh-new");
1146
+ });
1147
+
1148
+ it("reports fail when no webhook found and no --fix", async () => {
1149
+ // getWebhookStatus already returns null by default
1150
+ const checks = await checkWebhooks(undefined, false);
1151
+ const failCheck = checks.find((c) => c.label.includes("No workspace webhook found"));
1152
+ expect(failCheck?.severity).toBe("fail");
1153
+ expect(failCheck?.fix).toContain("webhooks setup");
1154
+ });
1155
+
1156
+ it("handles webhook check failure gracefully", async () => {
1157
+ vi.mocked(getWebhookStatus).mockRejectedValueOnce(new Error("network timeout"));
1158
+
1159
+ const checks = await checkWebhooks();
1160
+ const failCheck = checks.find((c) => c.label.includes("Webhook check failed"));
1161
+ expect(failCheck?.severity).toBe("warn");
1162
+ expect(failCheck?.label).toContain("network timeout");
1163
+ });
1164
+
1165
+ it("uses custom webhookUrl from pluginConfig", async () => {
1166
+ vi.mocked(getWebhookStatus).mockResolvedValueOnce({
1167
+ webhookId: "wh-custom",
1168
+ url: "https://custom.example.com/webhook",
1169
+ enabled: true,
1170
+ resourceTypes: ["Comment", "Issue"],
1171
+ issues: [],
1172
+ } as any);
1173
+
1174
+ const checks = await checkWebhooks({ webhookUrl: "https://custom.example.com/webhook" });
1175
+ expect(getWebhookStatus).toHaveBeenCalled();
1176
+ const whCheck = checks.find((c) => c.label.includes("Workspace webhook OK"));
1177
+ expect(whCheck?.severity).toBe("pass");
1178
+ });
1179
+
1180
+ it("handles provisionWebhook with no changes array", async () => {
1181
+ vi.mocked(getWebhookStatus).mockResolvedValueOnce({
1182
+ webhookId: "wh-1",
1183
+ url: "https://example.com/webhook",
1184
+ enabled: true,
1185
+ resourceTypes: ["Comment"],
1186
+ issues: ["Missing Issue"],
1187
+ } as any);
1188
+ vi.mocked(provisionWebhook).mockResolvedValueOnce({
1189
+ action: "updated",
1190
+ webhookId: "wh-1",
1191
+ // No changes array
1192
+ } as any);
1193
+
1194
+ const checks = await checkWebhooks(undefined, true);
1195
+ const fixCheck = checks.find((c) => c.label.includes("Workspace webhook fixed"));
1196
+ expect(fixCheck?.severity).toBe("pass");
1197
+ expect(fixCheck?.label).toContain("fixed"); // falls back to "fixed"
1198
+ });
1199
+ });
1200
+
1201
+ // ---------------------------------------------------------------------------
1202
+ // formatReport — additional branches
1203
+ // ---------------------------------------------------------------------------
1204
+
1205
+ describe("formatReport — additional branches", () => {
1206
+ it("shows fix guidance for fail severity", () => {
1207
+ const report = {
1208
+ sections: [{
1209
+ name: "Test",
1210
+ checks: [
1211
+ { label: "Config missing", severity: "fail" as const, fix: "Add config file" },
1212
+ ],
1213
+ }],
1214
+ summary: { passed: 0, warnings: 0, errors: 1 },
1215
+ };
1216
+
1217
+ const output = formatReport(report);
1218
+ expect(output).toContain("Add config file");
1219
+ expect(output).toContain("1 error");
1220
+ });
1221
+
1222
+ it("shows plural errors and warnings in summary", () => {
1223
+ const report = {
1224
+ sections: [{
1225
+ name: "Test",
1226
+ checks: [
1227
+ { label: "w1", severity: "warn" as const },
1228
+ { label: "w2", severity: "warn" as const },
1229
+ { label: "e1", severity: "fail" as const },
1230
+ { label: "e2", severity: "fail" as const },
1231
+ ],
1232
+ }],
1233
+ summary: { passed: 0, warnings: 2, errors: 2 },
1234
+ };
1235
+
1236
+ const output = formatReport(report);
1237
+ expect(output).toContain("2 warnings");
1238
+ expect(output).toContain("2 errors");
1239
+ });
1240
+
1241
+ it("omits warnings and errors from summary when zero", () => {
1242
+ const report = {
1243
+ sections: [{
1244
+ name: "Test",
1245
+ checks: [{ label: "ok", severity: "pass" as const }],
1246
+ }],
1247
+ summary: { passed: 1, warnings: 0, errors: 0 },
1248
+ };
1249
+
1250
+ const output = formatReport(report);
1251
+ expect(output).toContain("1 passed");
1252
+ expect(output).not.toContain("warning");
1253
+ expect(output).not.toContain("error");
1254
+ });
1255
+
1256
+ it("does not show fix when check is passing even with fix set", () => {
1257
+ const report = {
1258
+ sections: [{
1259
+ name: "Test",
1260
+ checks: [
1261
+ { label: "Good check", severity: "pass" as const, fix: "This should not show" },
1262
+ ],
1263
+ }],
1264
+ summary: { passed: 1, warnings: 0, errors: 0 },
1265
+ };
1266
+
1267
+ const output = formatReport(report);
1268
+ expect(output).not.toContain("This should not show");
1269
+ });
1270
+ });
1271
+
1272
+ // ---------------------------------------------------------------------------
1273
+ // buildSummary — additional branches
1274
+ // ---------------------------------------------------------------------------
1275
+
1276
+ describe("buildSummary — additional branches", () => {
1277
+ it("returns zeros for empty sections", () => {
1278
+ const summary = buildSummary([]);
1279
+ expect(summary).toEqual({ passed: 0, warnings: 0, errors: 0 });
1280
+ });
1281
+
1282
+ it("returns zeros for sections with no checks", () => {
1283
+ const summary = buildSummary([{ name: "Empty", checks: [] }]);
1284
+ expect(summary).toEqual({ passed: 0, warnings: 0, errors: 0 });
1285
+ });
1286
+ });
1287
+
1288
+ // ---------------------------------------------------------------------------
1289
+ // runDoctor — additional branches
1290
+ // ---------------------------------------------------------------------------
1291
+
1292
+ describe("runDoctor — additional branches", () => {
1293
+ it("applies --fix to auth-profiles.json permissions when fixable check exists", async () => {
1294
+ // We need a scenario where checkAuth produces a fixable permissions check
1295
+ // The AUTH_PROFILES_PATH is mocked to /tmp/test-auth-profiles.json
1296
+ // Write a file there with wrong permissions so statSync succeeds
1297
+ const testAuthPath = "/tmp/test-auth-profiles.json";
1298
+ writeFileSync(testAuthPath, '{"profiles": {}}');
1299
+ chmodSync(testAuthPath, 0o644); // Wrong permissions
1300
+
1301
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
1302
+ if (url.includes("linear.app")) {
1303
+ return {
1304
+ ok: true,
1305
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
1306
+ };
1307
+ }
1308
+ throw new Error("ECONNREFUSED");
1309
+ }));
1310
+
1311
+ const report = await runDoctor({ fix: true, json: false });
1312
+ // The permissions check should have been attempted to fix
1313
+ const authSection = report.sections.find((s) => s.name === "Authentication & Tokens");
1314
+ const permCheck = authSection?.checks.find((c) => c.label.includes("permissions"));
1315
+ // If chmodSync succeeded (which it should for /tmp), severity should be "pass"
1316
+ if (permCheck && permCheck.label.includes("fixed")) {
1317
+ expect(permCheck.severity).toBe("pass");
1318
+ }
1319
+ });
1320
+ });