git-daemon 0.1.8 → 0.1.10

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/tests/app.test.ts CHANGED
@@ -1,15 +1,47 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it } from "vitest";
2
2
  import request from "supertest";
3
3
  import path from "path";
4
4
  import os from "os";
5
5
  import { promises as fs } from "fs";
6
6
  import pino from "pino";
7
7
  import { execa } from "execa";
8
+ import type { Server } from "http";
8
9
  import { createApp, type DaemonContext } from "../src/app";
9
10
  import { TokenStore } from "../src/tokens";
10
11
  import { PairingManager } from "../src/pairing";
11
12
  import { JobManager } from "../src/jobs";
12
13
  import type { AppConfig } from "../src/types";
14
+ import { HealthcheckResultStore } from "../src/healthchecks";
15
+
16
+ const servers: Server[] = [];
17
+
18
+ const createHttpServer = (app: ReturnType<typeof createApp>) =>
19
+ new Promise<Server>((resolve, reject) => {
20
+ const server = app.listen(0, "127.0.0.1");
21
+ server.once("listening", () => {
22
+ servers.push(server);
23
+ resolve(server);
24
+ });
25
+ server.once("error", (err) => {
26
+ reject(err);
27
+ });
28
+ });
29
+
30
+ const closeServer = (server: Server) =>
31
+ new Promise<void>((resolve, reject) => {
32
+ server.close((err) => {
33
+ if (err) {
34
+ reject(err);
35
+ return;
36
+ }
37
+ resolve();
38
+ });
39
+ });
40
+
41
+ afterEach(async () => {
42
+ const closing = servers.splice(0, servers.length).map(closeServer);
43
+ await Promise.all(closing);
44
+ });
13
45
 
14
46
  const createTempDir = async () =>
15
47
  fs.mkdtemp(path.join(os.tmpdir(), "git-daemon-test-"));
@@ -43,6 +75,7 @@ const setupRepoWithRemote = async (workspaceRoot: string) => {
43
75
  const createConfig = (
44
76
  workspaceRoot: string | null,
45
77
  origin: string,
78
+ healthchecks?: AppConfig["healthchecks"],
46
79
  ): AppConfig => ({
47
80
  configVersion: 1,
48
81
  server: { host: "127.0.0.1", port: 0 },
@@ -51,13 +84,18 @@ const createConfig = (
51
84
  pairing: { tokenTtlDays: 30 },
52
85
  jobs: { maxConcurrent: 1, timeoutSeconds: 60 },
53
86
  deps: { defaultSafer: true },
87
+ healthchecks,
54
88
  logging: { directory: "logs", maxFiles: 1, maxBytes: 1024 },
55
89
  approvals: { entries: [] },
56
90
  });
57
91
 
58
- const createContext = async (workspaceRoot: string | null, origin: string) => {
92
+ const createContext = async (
93
+ workspaceRoot: string | null,
94
+ origin: string,
95
+ healthchecks?: AppConfig["healthchecks"],
96
+ ) => {
59
97
  const configDir = await createTempDir();
60
- const config = createConfig(workspaceRoot, origin);
98
+ const config = createConfig(workspaceRoot, origin, healthchecks);
61
99
  const tokenStore = new TokenStore(configDir);
62
100
  await tokenStore.load();
63
101
  const pairingManager = new PairingManager(
@@ -68,6 +106,7 @@ const createContext = async (workspaceRoot: string | null, origin: string) => {
68
106
  config.jobs.maxConcurrent,
69
107
  config.jobs.timeoutSeconds,
70
108
  );
109
+ const healthcheckStore = new HealthcheckResultStore();
71
110
  const logger = pino({ enabled: false });
72
111
  const capabilities = { tools: {} };
73
112
  const ctx: DaemonContext = {
@@ -76,27 +115,66 @@ const createContext = async (workspaceRoot: string | null, origin: string) => {
76
115
  tokenStore,
77
116
  pairingManager,
78
117
  jobManager,
118
+ healthcheckStore,
79
119
  capabilities,
80
120
  logger,
81
121
  version: "0.1.0",
82
122
  build: undefined,
83
123
  };
84
- return { ctx, app: createApp(ctx) };
124
+ const app = createApp(ctx);
125
+ const server = await createHttpServer(app);
126
+ const client = request(server);
127
+ return { ctx, app, server, client };
128
+ };
129
+
130
+ const waitForJob = async (
131
+ ctx: DaemonContext,
132
+ jobId: string,
133
+ timeoutMs = 5000,
134
+ ) => {
135
+ const start = Date.now();
136
+ while (Date.now() - start < timeoutMs) {
137
+ const job = ctx.jobManager.get(jobId);
138
+ if (job && job.state !== "queued" && job.state !== "running") {
139
+ return job.state;
140
+ }
141
+ await new Promise((resolve) => setTimeout(resolve, 50));
142
+ }
143
+ throw new Error(`Job ${jobId} did not finish in time.`);
144
+ };
145
+
146
+ const setupHealthcheckRepo = async (workspaceRoot: string) => {
147
+ const repoDir = path.join(workspaceRoot, "repo");
148
+ await fs.mkdir(repoDir, { recursive: true });
149
+ await runGit(repoDir, ["init", "-b", "main"]);
150
+ await runGit(repoDir, ["config", "user.email", "test@example.com"]);
151
+ await runGit(repoDir, ["config", "user.name", "Test User"]);
152
+ await fs.writeFile(
153
+ path.join(repoDir, "README.md"),
154
+ "# Title\n\nHello world.",
155
+ );
156
+ await fs.writeFile(
157
+ path.join(repoDir, "package.json"),
158
+ JSON.stringify({ name: "repo", packageManager: "pnpm@8.0.0" }, null, 2),
159
+ );
160
+ await runGit(repoDir, ["add", "README.md", "package.json"]);
161
+ await runGit(repoDir, ["commit", "-m", "init"]);
162
+ return { repoPath: "repo" };
85
163
  };
86
164
 
87
165
  describe("Git Daemon API", () => {
88
166
  const origin = "http://localhost:5173";
89
167
 
90
168
  it("rejects missing Origin header", async () => {
91
- const { app } = await createContext(null, origin);
92
- const res = await request(app).get("/v1/meta").set("Host", "127.0.0.1");
169
+ const { client } = await createContext(null, origin);
170
+ const res = await client.get("/v1/meta").set("Host", "127.0.0.1");
93
171
  expect(res.status).toBe(403);
94
172
  expect(res.body.errorCode).toBe("origin_not_allowed");
95
173
  });
96
174
 
97
175
  it("returns meta for allowed origin", async () => {
98
- const { app } = await createContext(null, origin);
99
- const res = await request(app)
176
+ const { client } = await createContext(null, origin);
177
+ const res = await client
100
178
  .get("/v1/meta")
101
179
  .set("Origin", origin)
102
180
  .set("Host", "127.0.0.1");
@@ -106,8 +184,8 @@ describe("Git Daemon API", () => {
106
184
  });
107
185
 
108
186
  it("requires auth for protected routes", async () => {
109
- const { app } = await createContext(null, origin);
110
- const res = await request(app)
187
+ const { client } = await createContext(null, origin);
188
+ const res = await client
111
189
  .get("/v1/git/status")
112
190
  .query({ repoPath: "repo" })
113
191
  .set("Origin", origin)
@@ -117,10 +195,10 @@ describe("Git Daemon API", () => {
117
195
  });
118
196
 
119
197
  it("returns workspace_required when not configured", async () => {
120
- const { app, ctx } = await createContext(null, origin);
198
+ const { client, ctx } = await createContext(null, origin);
121
199
  const { token } = await ctx.tokenStore.issueToken(origin, 30);
122
200
 
123
- const res = await request(app)
201
+ const res = await client
124
202
  .get("/v1/git/status")
125
203
  .query({ repoPath: "repo" })
126
204
  .set("Origin", origin)
@@ -133,10 +211,10 @@ describe("Git Daemon API", () => {
133
211
 
134
212
  it("validates repoUrl on clone", async () => {
135
213
  const workspaceRoot = await createTempDir();
136
- const { app, ctx } = await createContext(workspaceRoot, origin);
214
+ const { client, ctx } = await createContext(workspaceRoot, origin);
137
215
  const { token } = await ctx.tokenStore.issueToken(origin, 30);
138
216
 
139
- const res = await request(app)
217
+ const res = await client
140
218
  .post("/v1/git/clone")
141
219
  .set("Origin", origin)
142
220
  .set("Host", "127.0.0.1")
@@ -150,10 +228,10 @@ describe("Git Daemon API", () => {
150
228
  it("lists branches including remotes by default", async () => {
151
229
  const workspaceRoot = await createTempDir();
152
230
  const { repoPath } = await setupRepoWithRemote(workspaceRoot);
153
- const { app, ctx } = await createContext(workspaceRoot, origin);
231
+ const { client, ctx } = await createContext(workspaceRoot, origin);
154
232
  const { token } = await ctx.tokenStore.issueToken(origin, 30);
155
233
 
156
- const res = await request(app)
234
+ const res = await client
157
235
  .get("/v1/git/branches")
158
236
  .query({ repoPath })
159
237
  .set("Origin", origin)
@@ -180,4 +258,133 @@ describe("Git Daemon API", () => {
180
258
  const originMain = branches.find((branch) => branch.name === "origin/main");
181
259
  expect(originMain?.type).toBe("remote");
182
260
  });
261
+
262
+ it("returns a UI-friendly status summary", async () => {
263
+ const workspaceRoot = await createTempDir();
264
+ const { repoPath } = await setupRepoWithRemote(workspaceRoot);
265
+ const repoDir = path.join(workspaceRoot, repoPath);
266
+ await fs.writeFile(path.join(repoDir, "scratch.txt"), "dirty");
267
+ const { client, ctx } = await createContext(workspaceRoot, origin);
268
+ const { token } = await ctx.tokenStore.issueToken(origin, 30);
269
+
270
+ const res = await client
271
+ .get("/v1/git/summary")
272
+ .query({ repoPath })
273
+ .set("Origin", origin)
274
+ .set("Host", "127.0.0.1")
275
+ .set("Authorization", `Bearer ${token}`);
276
+
277
+ expect(res.status).toBe(200);
278
+ expect(res.body.repoPath).toBe(repoPath);
279
+ expect(res.body.exists).toBe(true);
280
+ expect(res.body.branch).toBe("feature");
281
+ expect(res.body.upstream).toBe("origin/feature");
282
+ expect(res.body.ahead).toBe(0);
283
+ expect(res.body.behind).toBe(0);
284
+ expect(res.body.dirty).toBe(true);
285
+ expect(res.body.untracked).toBe(1);
286
+ expect(res.body.staged).toBe(0);
287
+ expect(res.body.unstaged).toBe(0);
288
+ expect(res.body.conflicts).toBe(0);
289
+ expect(res.body.detached).toBe(false);
290
+ });
291
+
292
+ it("returns exists false when summary repo is missing", async () => {
293
+ const workspaceRoot = await createTempDir();
294
+ const { client, ctx } = await createContext(workspaceRoot, origin);
295
+ const { token } = await ctx.tokenStore.issueToken(origin, 30);
296
+
297
+ const res = await client
298
+ .get("/v1/git/summary")
299
+ .query({ repoPath: "missing" })
300
+ .set("Origin", origin)
301
+ .set("Host", "127.0.0.1")
302
+ .set("Authorization", `Bearer ${token}`);
303
+
304
+ expect(res.status).toBe(200);
305
+ expect(res.body.exists).toBe(false);
306
+ expect(res.body.branch).toBe("");
307
+ expect(res.body.dirty).toBe(false);
308
+ expect(res.body.ahead).toBe(0);
309
+ expect(res.body.behind).toBe(0);
310
+ });
311
+
312
+ it("lists available healthchecks", async () => {
313
+ const workspaceRoot = await createTempDir();
314
+ const suitePath = path.resolve(
315
+ process.cwd(),
316
+ "healthchecks",
317
+ "example-suite",
318
+ );
319
+ const { client, ctx } = await createContext(workspaceRoot, origin, {
320
+ suites: [suitePath],
321
+ defaultSuiteId: "example-suite",
322
+ });
323
+ const { token } = await ctx.tokenStore.issueToken(origin, 30);
324
+
325
+ const res = await client
326
+ .get("/v1/healthchecks")
327
+ .set("Origin", origin)
328
+ .set("Host", "127.0.0.1")
329
+ .set("Authorization", `Bearer ${token}`);
330
+
331
+ expect(res.status).toBe(200);
332
+ const suiteIds = (res.body.suites as Array<{ id: string }>).map(
333
+ (suite) => suite.id,
334
+ );
335
+ expect(suiteIds).toContain("example-suite");
336
+ const suite = (
337
+ res.body.suites as Array<{
338
+ id: string;
339
+ checks: Array<{ id: string }>;
340
+ }>
341
+ ).find((item) => item.id === "example-suite");
342
+ expect(suite?.checks.map((check) => check.id)).toEqual(
343
+ expect.arrayContaining(["about-description", "package-manager"]),
344
+ );
345
+ });
346
+
347
+ it("runs healthchecks and returns results", async () => {
348
+ const workspaceRoot = await createTempDir();
349
+ const { repoPath } = await setupHealthcheckRepo(workspaceRoot);
350
+ const suitePath = path.resolve(
351
+ process.cwd(),
352
+ "healthchecks",
353
+ "example-suite",
354
+ );
355
+ const { client, ctx } = await createContext(workspaceRoot, origin, {
356
+ suites: [suitePath],
357
+ defaultSuiteId: "example-suite",
358
+ });
359
+ const { token } = await ctx.tokenStore.issueToken(origin, 30);
360
+
361
+ const runRes = await client
362
+ .post("/v1/healthchecks/run")
363
+ .set("Origin", origin)
364
+ .set("Host", "127.0.0.1")
365
+ .set("Authorization", `Bearer ${token}`)
366
+ .send({
367
+ repoPath,
368
+ repoInfo: { description: "Hello world." },
369
+ checks: [
370
+ { checkId: "about-description" },
371
+ { checkId: "package-manager" },
372
+ ],
373
+ });
374
+
375
+ expect(runRes.status).toBe(202);
376
+ const jobId = runRes.body.jobId as string;
377
+ await waitForJob(ctx, jobId);
378
+
379
+ const resultRes = await client
380
+ .get(`/v1/healthchecks/jobs/${jobId}/result`)
381
+ .set("Origin", origin)
382
+ .set("Host", "127.0.0.1")
383
+ .set("Authorization", `Bearer ${token}`);
384
+
385
+ expect(resultRes.status).toBe(200);
386
+ expect(resultRes.body.repoPath).toBe(repoPath);
387
+ expect(resultRes.body.status).toBe("pass-full");
388
+ expect(resultRes.body.checks).toHaveLength(2);
389
+ });
183
390
  });