@hellcoder/companion 0.96.0
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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock git-utils module ─────────────────────────────────────────────────
|
|
4
|
+
// Mocked before imports so every `import` of git-utils gets the mock.
|
|
5
|
+
vi.mock("../git-utils.js", () => ({
|
|
6
|
+
getRepoInfo: vi.fn(() => null),
|
|
7
|
+
listBranches: vi.fn(() => []),
|
|
8
|
+
listWorktrees: vi.fn(() => []),
|
|
9
|
+
ensureWorktree: vi.fn(() => ({
|
|
10
|
+
worktreePath: "/worktrees/feat",
|
|
11
|
+
branch: "feat",
|
|
12
|
+
actualBranch: "feat",
|
|
13
|
+
isNew: true,
|
|
14
|
+
})),
|
|
15
|
+
gitFetch: vi.fn(() => ({ success: true, output: "" })),
|
|
16
|
+
gitPull: vi.fn(() => ({ success: true, output: "" })),
|
|
17
|
+
removeWorktree: vi.fn(() => ({ removed: true })),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// ─── Mock child_process for the git pull ahead/behind count ────────────────
|
|
21
|
+
vi.mock("node:child_process", () => ({
|
|
22
|
+
execSync: vi.fn(() => "0\t0"),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// ─── Mock github-pr module for the PR status route ─────────────────────────
|
|
26
|
+
vi.mock("../github-pr.js", () => ({
|
|
27
|
+
isGhAvailable: vi.fn(() => false),
|
|
28
|
+
fetchPRInfoAsync: vi.fn(async () => null),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { Hono } from "hono";
|
|
32
|
+
import * as gitUtils from "../git-utils.js";
|
|
33
|
+
import { execSync } from "node:child_process";
|
|
34
|
+
import { registerGitRoutes } from "./git-routes.js";
|
|
35
|
+
import * as githubPr from "../github-pr.js";
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Build a fresh Hono app with the git routes registered. */
|
|
40
|
+
function createApp(prPoller?: Parameters<typeof registerGitRoutes>[1]) {
|
|
41
|
+
const app = new Hono();
|
|
42
|
+
registerGitRoutes(app, prPoller);
|
|
43
|
+
return app;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shorthand for POST/DELETE requests with a JSON body. */
|
|
47
|
+
function jsonRequest(
|
|
48
|
+
path: string,
|
|
49
|
+
body: Record<string, unknown>,
|
|
50
|
+
method: "POST" | "DELETE" = "POST",
|
|
51
|
+
) {
|
|
52
|
+
return new Request(`http://localhost${path}`, {
|
|
53
|
+
method,
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Test Suite ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
let app: Hono;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
app = createApp();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
69
|
+
// GET /git/repo-info
|
|
70
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
71
|
+
|
|
72
|
+
describe("GET /git/repo-info", () => {
|
|
73
|
+
it("returns 400 when path query parameter is missing", async () => {
|
|
74
|
+
// The route requires a `path` query param; omitting it should yield 400
|
|
75
|
+
const res = await app.request("/git/repo-info");
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(400);
|
|
78
|
+
const body = await res.json();
|
|
79
|
+
expect(body.error).toBe("path required");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns 400 when the path is not a git repository", async () => {
|
|
83
|
+
// getRepoInfo returns null for non-git directories
|
|
84
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue(null);
|
|
85
|
+
|
|
86
|
+
const res = await app.request("/git/repo-info?path=/tmp/not-a-repo");
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(400);
|
|
89
|
+
const body = await res.json();
|
|
90
|
+
expect(body.error).toBe("Not a git repository");
|
|
91
|
+
expect(gitUtils.getRepoInfo).toHaveBeenCalledWith("/tmp/not-a-repo");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns repo info on success", async () => {
|
|
95
|
+
// When getRepoInfo finds a valid repo it returns a GitRepoInfo object
|
|
96
|
+
const mockInfo = {
|
|
97
|
+
repoRoot: "/home/user/project",
|
|
98
|
+
repoName: "project",
|
|
99
|
+
currentBranch: "main",
|
|
100
|
+
defaultBranch: "main",
|
|
101
|
+
isWorktree: false,
|
|
102
|
+
};
|
|
103
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue(mockInfo);
|
|
104
|
+
|
|
105
|
+
const res = await app.request(
|
|
106
|
+
`/git/repo-info?path=${encodeURIComponent("/home/user/project")}`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
const body = await res.json();
|
|
111
|
+
expect(body).toEqual(mockInfo);
|
|
112
|
+
expect(gitUtils.getRepoInfo).toHaveBeenCalledWith("/home/user/project");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("passes the raw path value to getRepoInfo", async () => {
|
|
116
|
+
// Ensure URL-decoded paths are forwarded correctly
|
|
117
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue(null);
|
|
118
|
+
|
|
119
|
+
await app.request(
|
|
120
|
+
`/git/repo-info?path=${encodeURIComponent("/path/with spaces/repo")}`,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(gitUtils.getRepoInfo).toHaveBeenCalledWith(
|
|
124
|
+
"/path/with spaces/repo",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
130
|
+
// GET /git/branches
|
|
131
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
describe("GET /git/branches", () => {
|
|
134
|
+
it("returns 400 when repoRoot query parameter is missing", async () => {
|
|
135
|
+
const res = await app.request("/git/branches");
|
|
136
|
+
|
|
137
|
+
expect(res.status).toBe(400);
|
|
138
|
+
const body = await res.json();
|
|
139
|
+
expect(body.error).toBe("repoRoot required");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns an empty list when there are no branches", async () => {
|
|
143
|
+
vi.mocked(gitUtils.listBranches).mockReturnValue([]);
|
|
144
|
+
|
|
145
|
+
const res = await app.request("/git/branches?repoRoot=/repo");
|
|
146
|
+
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
const body = await res.json();
|
|
149
|
+
expect(body).toEqual([]);
|
|
150
|
+
expect(gitUtils.listBranches).toHaveBeenCalledWith("/repo");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns branch info on success", async () => {
|
|
154
|
+
const branches = [
|
|
155
|
+
{
|
|
156
|
+
name: "main",
|
|
157
|
+
isCurrent: true,
|
|
158
|
+
isRemote: false,
|
|
159
|
+
worktreePath: null,
|
|
160
|
+
ahead: 0,
|
|
161
|
+
behind: 0,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "feature/login",
|
|
165
|
+
isCurrent: false,
|
|
166
|
+
isRemote: false,
|
|
167
|
+
worktreePath: null,
|
|
168
|
+
ahead: 2,
|
|
169
|
+
behind: 1,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
vi.mocked(gitUtils.listBranches).mockReturnValue(branches);
|
|
173
|
+
|
|
174
|
+
const res = await app.request("/git/branches?repoRoot=/repo");
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
const body = await res.json();
|
|
178
|
+
expect(body).toEqual(branches);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("returns 500 when listBranches throws an error", async () => {
|
|
182
|
+
// The route wraps listBranches in a try/catch and returns 500 on failure
|
|
183
|
+
vi.mocked(gitUtils.listBranches).mockImplementation(() => {
|
|
184
|
+
throw new Error("fatal: not a git repository");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const res = await app.request("/git/branches?repoRoot=/bad-path");
|
|
188
|
+
|
|
189
|
+
expect(res.status).toBe(500);
|
|
190
|
+
const body = await res.json();
|
|
191
|
+
expect(body.error).toBe("fatal: not a git repository");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("handles non-Error throws gracefully by stringifying", async () => {
|
|
195
|
+
// When a non-Error value is thrown, it should be stringified
|
|
196
|
+
vi.mocked(gitUtils.listBranches).mockImplementation(() => {
|
|
197
|
+
throw "unexpected string error"; // eslint-disable-line no-throw-literal
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const res = await app.request("/git/branches?repoRoot=/bad-path");
|
|
201
|
+
|
|
202
|
+
expect(res.status).toBe(500);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
expect(body.error).toBe("unexpected string error");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
209
|
+
// POST /git/fetch
|
|
210
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
211
|
+
|
|
212
|
+
describe("POST /git/fetch", () => {
|
|
213
|
+
it("returns 400 when repoRoot is missing from body", async () => {
|
|
214
|
+
const res = await app.request(jsonRequest("/git/fetch", {}));
|
|
215
|
+
|
|
216
|
+
expect(res.status).toBe(400);
|
|
217
|
+
const body = await res.json();
|
|
218
|
+
expect(body.error).toBe("repoRoot required");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns fetch result on success", async () => {
|
|
222
|
+
vi.mocked(gitUtils.gitFetch).mockReturnValue({
|
|
223
|
+
success: true,
|
|
224
|
+
output: "From origin\n * branch main -> FETCH_HEAD",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const res = await app.request(
|
|
228
|
+
jsonRequest("/git/fetch", { repoRoot: "/repo" }),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(res.status).toBe(200);
|
|
232
|
+
const body = await res.json();
|
|
233
|
+
expect(body.success).toBe(true);
|
|
234
|
+
expect(body.output).toContain("FETCH_HEAD");
|
|
235
|
+
expect(gitUtils.gitFetch).toHaveBeenCalledWith("/repo");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("returns failure result when gitFetch reports failure", async () => {
|
|
239
|
+
vi.mocked(gitUtils.gitFetch).mockReturnValue({
|
|
240
|
+
success: false,
|
|
241
|
+
output: "fatal: could not read from remote repository",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const res = await app.request(
|
|
245
|
+
jsonRequest("/git/fetch", { repoRoot: "/repo" }),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(200);
|
|
249
|
+
const body = await res.json();
|
|
250
|
+
expect(body.success).toBe(false);
|
|
251
|
+
expect(body.output).toContain("could not read from remote repository");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("handles malformed JSON body gracefully", async () => {
|
|
255
|
+
// When the body is not valid JSON, the route catches the parse error
|
|
256
|
+
// and falls through to the missing repoRoot check
|
|
257
|
+
const res = await app.request(
|
|
258
|
+
new Request("http://localhost/git/fetch", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/json" },
|
|
261
|
+
body: "not json",
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(res.status).toBe(400);
|
|
266
|
+
const body = await res.json();
|
|
267
|
+
expect(body.error).toBe("repoRoot required");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
272
|
+
// GET /git/worktrees
|
|
273
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
274
|
+
|
|
275
|
+
describe("GET /git/worktrees", () => {
|
|
276
|
+
it("returns 400 when repoRoot query parameter is missing", async () => {
|
|
277
|
+
const res = await app.request("/git/worktrees");
|
|
278
|
+
|
|
279
|
+
expect(res.status).toBe(400);
|
|
280
|
+
const body = await res.json();
|
|
281
|
+
expect(body.error).toBe("repoRoot required");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("returns an empty list when no worktrees exist", async () => {
|
|
285
|
+
vi.mocked(gitUtils.listWorktrees).mockReturnValue([]);
|
|
286
|
+
|
|
287
|
+
const res = await app.request("/git/worktrees?repoRoot=/repo");
|
|
288
|
+
|
|
289
|
+
expect(res.status).toBe(200);
|
|
290
|
+
const body = await res.json();
|
|
291
|
+
expect(body).toEqual([]);
|
|
292
|
+
expect(gitUtils.listWorktrees).toHaveBeenCalledWith("/repo");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("returns worktree info on success", async () => {
|
|
296
|
+
const worktrees = [
|
|
297
|
+
{
|
|
298
|
+
path: "/repo",
|
|
299
|
+
branch: "main",
|
|
300
|
+
head: "abc123",
|
|
301
|
+
isMainWorktree: true,
|
|
302
|
+
isDirty: false,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
path: "/worktrees/feat",
|
|
306
|
+
branch: "feature/login",
|
|
307
|
+
head: "def456",
|
|
308
|
+
isMainWorktree: false,
|
|
309
|
+
isDirty: true,
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
vi.mocked(gitUtils.listWorktrees).mockReturnValue(worktrees);
|
|
313
|
+
|
|
314
|
+
const res = await app.request("/git/worktrees?repoRoot=/repo");
|
|
315
|
+
|
|
316
|
+
expect(res.status).toBe(200);
|
|
317
|
+
const body = await res.json();
|
|
318
|
+
expect(body).toEqual(worktrees);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
323
|
+
// POST /git/worktree (create/ensure)
|
|
324
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
325
|
+
|
|
326
|
+
describe("POST /git/worktree", () => {
|
|
327
|
+
it("returns 400 when repoRoot is missing", async () => {
|
|
328
|
+
const res = await app.request(
|
|
329
|
+
jsonRequest("/git/worktree", { branch: "feat" }),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(res.status).toBe(400);
|
|
333
|
+
const body = await res.json();
|
|
334
|
+
expect(body.error).toBe("repoRoot and branch required");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("returns 400 when branch is missing", async () => {
|
|
338
|
+
const res = await app.request(
|
|
339
|
+
jsonRequest("/git/worktree", { repoRoot: "/repo" }),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
expect(res.status).toBe(400);
|
|
343
|
+
const body = await res.json();
|
|
344
|
+
expect(body.error).toBe("repoRoot and branch required");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("returns 400 when both repoRoot and branch are missing", async () => {
|
|
348
|
+
const res = await app.request(jsonRequest("/git/worktree", {}));
|
|
349
|
+
|
|
350
|
+
expect(res.status).toBe(400);
|
|
351
|
+
const body = await res.json();
|
|
352
|
+
expect(body.error).toBe("repoRoot and branch required");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("creates a worktree with minimal required params", async () => {
|
|
356
|
+
const result = {
|
|
357
|
+
worktreePath: "/worktrees/feat",
|
|
358
|
+
branch: "feat",
|
|
359
|
+
actualBranch: "feat",
|
|
360
|
+
isNew: true,
|
|
361
|
+
};
|
|
362
|
+
vi.mocked(gitUtils.ensureWorktree).mockReturnValue(result);
|
|
363
|
+
|
|
364
|
+
const res = await app.request(
|
|
365
|
+
jsonRequest("/git/worktree", { repoRoot: "/repo", branch: "feat" }),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect(res.status).toBe(200);
|
|
369
|
+
const body = await res.json();
|
|
370
|
+
expect(body).toEqual(result);
|
|
371
|
+
expect(gitUtils.ensureWorktree).toHaveBeenCalledWith("/repo", "feat", {
|
|
372
|
+
baseBranch: undefined,
|
|
373
|
+
createBranch: undefined,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("passes optional baseBranch and createBranch to ensureWorktree", async () => {
|
|
378
|
+
// The route should forward optional parameters from the request body
|
|
379
|
+
vi.mocked(gitUtils.ensureWorktree).mockReturnValue({
|
|
380
|
+
worktreePath: "/worktrees/new-feat",
|
|
381
|
+
branch: "new-feat",
|
|
382
|
+
actualBranch: "new-feat",
|
|
383
|
+
isNew: true,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const res = await app.request(
|
|
387
|
+
jsonRequest("/git/worktree", {
|
|
388
|
+
repoRoot: "/repo",
|
|
389
|
+
branch: "new-feat",
|
|
390
|
+
baseBranch: "develop",
|
|
391
|
+
createBranch: true,
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
expect(gitUtils.ensureWorktree).toHaveBeenCalledWith(
|
|
397
|
+
"/repo",
|
|
398
|
+
"new-feat",
|
|
399
|
+
{
|
|
400
|
+
baseBranch: "develop",
|
|
401
|
+
createBranch: true,
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("returns an existing worktree when isNew is false", async () => {
|
|
407
|
+
// ensureWorktree may return an existing worktree rather than creating a new one
|
|
408
|
+
vi.mocked(gitUtils.ensureWorktree).mockReturnValue({
|
|
409
|
+
worktreePath: "/worktrees/feat",
|
|
410
|
+
branch: "feat",
|
|
411
|
+
actualBranch: "feat",
|
|
412
|
+
isNew: false,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const res = await app.request(
|
|
416
|
+
jsonRequest("/git/worktree", { repoRoot: "/repo", branch: "feat" }),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(res.status).toBe(200);
|
|
420
|
+
const body = await res.json();
|
|
421
|
+
expect(body.isNew).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("handles malformed JSON body gracefully", async () => {
|
|
425
|
+
const res = await app.request(
|
|
426
|
+
new Request("http://localhost/git/worktree", {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { "Content-Type": "application/json" },
|
|
429
|
+
body: "{{bad json",
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
expect(res.status).toBe(400);
|
|
434
|
+
const body = await res.json();
|
|
435
|
+
expect(body.error).toBe("repoRoot and branch required");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
440
|
+
// DELETE /git/worktree
|
|
441
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
442
|
+
|
|
443
|
+
describe("DELETE /git/worktree", () => {
|
|
444
|
+
it("returns 400 when repoRoot is missing", async () => {
|
|
445
|
+
const res = await app.request(
|
|
446
|
+
jsonRequest("/git/worktree", { worktreePath: "/wt" }, "DELETE"),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
expect(res.status).toBe(400);
|
|
450
|
+
const body = await res.json();
|
|
451
|
+
expect(body.error).toBe("repoRoot and worktreePath required");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("returns 400 when worktreePath is missing", async () => {
|
|
455
|
+
const res = await app.request(
|
|
456
|
+
jsonRequest("/git/worktree", { repoRoot: "/repo" }, "DELETE"),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
expect(res.status).toBe(400);
|
|
460
|
+
const body = await res.json();
|
|
461
|
+
expect(body.error).toBe("repoRoot and worktreePath required");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("returns 400 when both repoRoot and worktreePath are missing", async () => {
|
|
465
|
+
const res = await app.request(
|
|
466
|
+
jsonRequest("/git/worktree", {}, "DELETE"),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
expect(res.status).toBe(400);
|
|
470
|
+
const body = await res.json();
|
|
471
|
+
expect(body.error).toBe("repoRoot and worktreePath required");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("removes a worktree without force", async () => {
|
|
475
|
+
vi.mocked(gitUtils.removeWorktree).mockReturnValue({ removed: true });
|
|
476
|
+
|
|
477
|
+
const res = await app.request(
|
|
478
|
+
jsonRequest(
|
|
479
|
+
"/git/worktree",
|
|
480
|
+
{ repoRoot: "/repo", worktreePath: "/worktrees/feat" },
|
|
481
|
+
"DELETE",
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(res.status).toBe(200);
|
|
486
|
+
const body = await res.json();
|
|
487
|
+
expect(body.removed).toBe(true);
|
|
488
|
+
expect(gitUtils.removeWorktree).toHaveBeenCalledWith(
|
|
489
|
+
"/repo",
|
|
490
|
+
"/worktrees/feat",
|
|
491
|
+
{ force: undefined },
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("passes force option when provided", async () => {
|
|
496
|
+
vi.mocked(gitUtils.removeWorktree).mockReturnValue({ removed: true });
|
|
497
|
+
|
|
498
|
+
const res = await app.request(
|
|
499
|
+
jsonRequest(
|
|
500
|
+
"/git/worktree",
|
|
501
|
+
{ repoRoot: "/repo", worktreePath: "/worktrees/feat", force: true },
|
|
502
|
+
"DELETE",
|
|
503
|
+
),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
expect(res.status).toBe(200);
|
|
507
|
+
expect(gitUtils.removeWorktree).toHaveBeenCalledWith(
|
|
508
|
+
"/repo",
|
|
509
|
+
"/worktrees/feat",
|
|
510
|
+
{ force: true },
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("returns failure reason when worktree removal fails", async () => {
|
|
515
|
+
// removeWorktree returns { removed: false, reason: "..." } on failure
|
|
516
|
+
vi.mocked(gitUtils.removeWorktree).mockReturnValue({
|
|
517
|
+
removed: false,
|
|
518
|
+
reason: "worktree is dirty",
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const res = await app.request(
|
|
522
|
+
jsonRequest(
|
|
523
|
+
"/git/worktree",
|
|
524
|
+
{ repoRoot: "/repo", worktreePath: "/worktrees/feat" },
|
|
525
|
+
"DELETE",
|
|
526
|
+
),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
expect(res.status).toBe(200);
|
|
530
|
+
const body = await res.json();
|
|
531
|
+
expect(body.removed).toBe(false);
|
|
532
|
+
expect(body.reason).toBe("worktree is dirty");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("handles malformed JSON body gracefully", async () => {
|
|
536
|
+
const res = await app.request(
|
|
537
|
+
new Request("http://localhost/git/worktree", {
|
|
538
|
+
method: "DELETE",
|
|
539
|
+
headers: { "Content-Type": "application/json" },
|
|
540
|
+
body: "invalid",
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
expect(res.status).toBe(400);
|
|
545
|
+
const body = await res.json();
|
|
546
|
+
expect(body.error).toBe("repoRoot and worktreePath required");
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
551
|
+
// POST /git/pull
|
|
552
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
553
|
+
|
|
554
|
+
describe("POST /git/pull", () => {
|
|
555
|
+
it("returns 400 when cwd is missing from body", async () => {
|
|
556
|
+
const res = await app.request(jsonRequest("/git/pull", {}));
|
|
557
|
+
|
|
558
|
+
expect(res.status).toBe(400);
|
|
559
|
+
const body = await res.json();
|
|
560
|
+
expect(body.error).toBe("cwd required");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("returns pull result with ahead/behind counts on success", async () => {
|
|
564
|
+
// After pulling, the route runs `git rev-list` to get ahead/behind counts
|
|
565
|
+
vi.mocked(gitUtils.gitPull).mockReturnValue({
|
|
566
|
+
success: true,
|
|
567
|
+
output: "Already up to date.",
|
|
568
|
+
});
|
|
569
|
+
vi.mocked(execSync).mockReturnValue("3\t5" as any);
|
|
570
|
+
|
|
571
|
+
const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
|
|
572
|
+
|
|
573
|
+
expect(res.status).toBe(200);
|
|
574
|
+
const body = await res.json();
|
|
575
|
+
expect(body.success).toBe(true);
|
|
576
|
+
expect(body.output).toBe("Already up to date.");
|
|
577
|
+
// execSync returns "behind\tahead" — the route parses [behind, ahead]
|
|
578
|
+
expect(body.git_behind).toBe(3);
|
|
579
|
+
expect(body.git_ahead).toBe(5);
|
|
580
|
+
expect(gitUtils.gitPull).toHaveBeenCalledWith("/repo");
|
|
581
|
+
expect(execSync).toHaveBeenCalledWith(
|
|
582
|
+
"git rev-list --left-right --count @{upstream}...HEAD",
|
|
583
|
+
{ cwd: "/repo", encoding: "utf-8", timeout: 3000 },
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("returns zeros for ahead/behind when execSync throws (no upstream)", async () => {
|
|
588
|
+
// When there's no upstream tracking branch, execSync throws and
|
|
589
|
+
// the route silently catches the error and defaults to 0/0
|
|
590
|
+
vi.mocked(gitUtils.gitPull).mockReturnValue({
|
|
591
|
+
success: true,
|
|
592
|
+
output: "Already up to date.",
|
|
593
|
+
});
|
|
594
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
595
|
+
throw new Error("fatal: no upstream configured");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
|
|
599
|
+
|
|
600
|
+
expect(res.status).toBe(200);
|
|
601
|
+
const body = await res.json();
|
|
602
|
+
expect(body.success).toBe(true);
|
|
603
|
+
expect(body.git_ahead).toBe(0);
|
|
604
|
+
expect(body.git_behind).toBe(0);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("returns pull failure result from gitPull", async () => {
|
|
608
|
+
// gitPull itself can report failure (e.g. merge conflicts)
|
|
609
|
+
vi.mocked(gitUtils.gitPull).mockReturnValue({
|
|
610
|
+
success: false,
|
|
611
|
+
output: "CONFLICT (content): Merge conflict in file.txt",
|
|
612
|
+
});
|
|
613
|
+
vi.mocked(execSync).mockReturnValue("0\t0" as any);
|
|
614
|
+
|
|
615
|
+
const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
|
|
616
|
+
|
|
617
|
+
expect(res.status).toBe(200);
|
|
618
|
+
const body = await res.json();
|
|
619
|
+
expect(body.success).toBe(false);
|
|
620
|
+
expect(body.output).toContain("CONFLICT");
|
|
621
|
+
// ahead/behind still get computed even when pull fails
|
|
622
|
+
expect(body.git_ahead).toBe(0);
|
|
623
|
+
expect(body.git_behind).toBe(0);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("handles tab-separated output with extra whitespace", async () => {
|
|
627
|
+
// Verify the route's split/parse logic handles various whitespace in execSync output
|
|
628
|
+
vi.mocked(gitUtils.gitPull).mockReturnValue({
|
|
629
|
+
success: true,
|
|
630
|
+
output: "",
|
|
631
|
+
});
|
|
632
|
+
vi.mocked(execSync).mockReturnValue("12\t7" as any);
|
|
633
|
+
|
|
634
|
+
const res = await app.request(jsonRequest("/git/pull", { cwd: "/repo" }));
|
|
635
|
+
|
|
636
|
+
const body = await res.json();
|
|
637
|
+
expect(body.git_behind).toBe(12);
|
|
638
|
+
expect(body.git_ahead).toBe(7);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("handles malformed JSON body gracefully", async () => {
|
|
642
|
+
const res = await app.request(
|
|
643
|
+
new Request("http://localhost/git/pull", {
|
|
644
|
+
method: "POST",
|
|
645
|
+
headers: { "Content-Type": "application/json" },
|
|
646
|
+
body: "nope",
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
expect(res.status).toBe(400);
|
|
651
|
+
const body = await res.json();
|
|
652
|
+
expect(body.error).toBe("cwd required");
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
657
|
+
// GET /git/pr-status
|
|
658
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
659
|
+
|
|
660
|
+
describe("GET /git/pr-status", () => {
|
|
661
|
+
it("returns 400 when cwd is missing", async () => {
|
|
662
|
+
const res = await app.request("/git/pr-status?branch=main");
|
|
663
|
+
|
|
664
|
+
expect(res.status).toBe(400);
|
|
665
|
+
const body = await res.json();
|
|
666
|
+
expect(body.error).toBe("cwd and branch required");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("returns 400 when branch is missing", async () => {
|
|
670
|
+
const res = await app.request("/git/pr-status?cwd=/repo");
|
|
671
|
+
|
|
672
|
+
expect(res.status).toBe(400);
|
|
673
|
+
const body = await res.json();
|
|
674
|
+
expect(body.error).toBe("cwd and branch required");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("returns 400 when both cwd and branch are missing", async () => {
|
|
678
|
+
const res = await app.request("/git/pr-status");
|
|
679
|
+
|
|
680
|
+
expect(res.status).toBe(400);
|
|
681
|
+
const body = await res.json();
|
|
682
|
+
expect(body.error).toBe("cwd and branch required");
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("returns cached data from prPoller when available", async () => {
|
|
686
|
+
// When a prPoller is provided and has cached data, the route returns
|
|
687
|
+
// it immediately without calling github-pr functions
|
|
688
|
+
const cachedData = {
|
|
689
|
+
available: true,
|
|
690
|
+
pr: {
|
|
691
|
+
number: 42,
|
|
692
|
+
title: "Add feature",
|
|
693
|
+
url: "https://github.com/org/repo/pull/42",
|
|
694
|
+
state: "OPEN" as const,
|
|
695
|
+
isDraft: false,
|
|
696
|
+
reviewDecision: null,
|
|
697
|
+
additions: 10,
|
|
698
|
+
deletions: 5,
|
|
699
|
+
changedFiles: 2,
|
|
700
|
+
checks: [],
|
|
701
|
+
checksSummary: { total: 0, success: 0, failure: 0, pending: 0 },
|
|
702
|
+
reviewThreads: { total: 0, resolved: 0, unresolved: 0 },
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
const mockPoller = {
|
|
706
|
+
getCached: vi.fn(() => cachedData),
|
|
707
|
+
};
|
|
708
|
+
const appWithPoller = createApp(mockPoller as any);
|
|
709
|
+
|
|
710
|
+
const res = await appWithPoller.request(
|
|
711
|
+
"/git/pr-status?cwd=/repo&branch=feat",
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
expect(res.status).toBe(200);
|
|
715
|
+
const body = await res.json();
|
|
716
|
+
expect(body).toEqual(cachedData);
|
|
717
|
+
expect(mockPoller.getCached).toHaveBeenCalledWith("/repo", "feat");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("falls through to github-pr when prPoller has no cache", async () => {
|
|
721
|
+
// prPoller.getCached returns null -> route falls through to dynamic import path
|
|
722
|
+
const mockPoller = {
|
|
723
|
+
getCached: vi.fn(() => null),
|
|
724
|
+
};
|
|
725
|
+
const appWithPoller = createApp(mockPoller as any);
|
|
726
|
+
vi.mocked(githubPr.isGhAvailable).mockReturnValue(false);
|
|
727
|
+
|
|
728
|
+
const res = await appWithPoller.request(
|
|
729
|
+
"/git/pr-status?cwd=/repo&branch=feat",
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
expect(res.status).toBe(200);
|
|
733
|
+
const body = await res.json();
|
|
734
|
+
expect(body).toEqual({ available: false, pr: null });
|
|
735
|
+
expect(mockPoller.getCached).toHaveBeenCalledWith("/repo", "feat");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("returns unavailable when gh CLI is not installed (no prPoller)", async () => {
|
|
739
|
+
// Without a prPoller, the route goes directly to the dynamic import path
|
|
740
|
+
// and isGhAvailable returns false
|
|
741
|
+
vi.mocked(githubPr.isGhAvailable).mockReturnValue(false);
|
|
742
|
+
|
|
743
|
+
const res = await app.request("/git/pr-status?cwd=/repo&branch=main");
|
|
744
|
+
|
|
745
|
+
expect(res.status).toBe(200);
|
|
746
|
+
const body = await res.json();
|
|
747
|
+
expect(body.available).toBe(false);
|
|
748
|
+
expect(body.pr).toBeNull();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("fetches PR info when gh is available and no poller cache", async () => {
|
|
752
|
+
// When gh is available, the route calls fetchPRInfoAsync and returns the result
|
|
753
|
+
const mockPr = {
|
|
754
|
+
number: 99,
|
|
755
|
+
title: "Fix bug",
|
|
756
|
+
url: "https://github.com/org/repo/pull/99",
|
|
757
|
+
state: "OPEN" as const,
|
|
758
|
+
isDraft: false,
|
|
759
|
+
reviewDecision: "APPROVED" as const,
|
|
760
|
+
additions: 20,
|
|
761
|
+
deletions: 3,
|
|
762
|
+
changedFiles: 1,
|
|
763
|
+
checks: [{ name: "ci", status: "completed", conclusion: "success" }],
|
|
764
|
+
checksSummary: { total: 1, success: 1, failure: 0, pending: 0 },
|
|
765
|
+
reviewThreads: { total: 2, resolved: 2, unresolved: 0 },
|
|
766
|
+
};
|
|
767
|
+
vi.mocked(githubPr.isGhAvailable).mockReturnValue(true);
|
|
768
|
+
vi.mocked(githubPr.fetchPRInfoAsync).mockResolvedValue(mockPr);
|
|
769
|
+
|
|
770
|
+
const res = await app.request("/git/pr-status?cwd=/repo&branch=fix-bug");
|
|
771
|
+
|
|
772
|
+
expect(res.status).toBe(200);
|
|
773
|
+
const body = await res.json();
|
|
774
|
+
expect(body.available).toBe(true);
|
|
775
|
+
expect(body.pr).toEqual(mockPr);
|
|
776
|
+
expect(githubPr.fetchPRInfoAsync).toHaveBeenCalledWith(
|
|
777
|
+
"/repo",
|
|
778
|
+
"fix-bug",
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("returns null PR when fetchPRInfoAsync finds no PR for the branch", async () => {
|
|
783
|
+
// gh is available but there's no PR for this branch
|
|
784
|
+
vi.mocked(githubPr.isGhAvailable).mockReturnValue(true);
|
|
785
|
+
vi.mocked(githubPr.fetchPRInfoAsync).mockResolvedValue(null);
|
|
786
|
+
|
|
787
|
+
const res = await app.request(
|
|
788
|
+
"/git/pr-status?cwd=/repo&branch=no-pr-here",
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
expect(res.status).toBe(200);
|
|
792
|
+
const body = await res.json();
|
|
793
|
+
expect(body.available).toBe(true);
|
|
794
|
+
expect(body.pr).toBeNull();
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("skips prPoller entirely when none is provided", async () => {
|
|
798
|
+
// When registerGitRoutes is called without a prPoller argument,
|
|
799
|
+
// the route goes straight to the github-pr import path
|
|
800
|
+
vi.mocked(githubPr.isGhAvailable).mockReturnValue(true);
|
|
801
|
+
vi.mocked(githubPr.fetchPRInfoAsync).mockResolvedValue(null);
|
|
802
|
+
|
|
803
|
+
const res = await app.request(
|
|
804
|
+
"/git/pr-status?cwd=/repo&branch=some-branch",
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
expect(res.status).toBe(200);
|
|
808
|
+
const body = await res.json();
|
|
809
|
+
expect(body.available).toBe(true);
|
|
810
|
+
// Verify github-pr was called (would not be if poller had returned cached)
|
|
811
|
+
expect(githubPr.isGhAvailable).toHaveBeenCalled();
|
|
812
|
+
});
|
|
813
|
+
});
|