@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.
- package/README.md +301 -255
- package/package.json +1 -1
- package/src/__test__/smoke-linear-api.test.ts +147 -0
- package/src/infra/doctor.test.ts +762 -2
- package/src/pipeline/dag-dispatch.test.ts +444 -0
- package/src/pipeline/pipeline.test.ts +1247 -1
- package/src/pipeline/planner.test.ts +457 -3
- package/src/pipeline/planning-state.test.ts +164 -3
- package/src/pipeline/webhook.test.ts +2438 -19
- package/src/tools/planner-tools.test.ts +722 -0
package/src/infra/doctor.test.ts
CHANGED
|
@@ -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
|
+
});
|