project-mgr 0.1.6 → 0.2.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/test/core.test.ts CHANGED
@@ -9,13 +9,17 @@ import { run } from "../src/cli.js";
9
9
  import {
10
10
  addStatus,
11
11
  at,
12
+ countEntries,
12
13
  current,
13
14
  databasePath,
14
15
  history,
15
16
  importStatuses,
17
+ listProjects,
16
18
  openLedger,
17
19
  parseTime,
20
+ setProjectMetadata,
18
21
  toPortableStatusEntry,
22
+ validateProjectMetadataInput,
19
23
  validateStatusEntry,
20
24
  ValidationError,
21
25
  } from "../src/index.js";
@@ -55,6 +59,29 @@ test("domain normalizes valid values and rejects invalid input", () => {
55
59
  externalRef: "ref",
56
60
  },
57
61
  );
62
+ assert.equal(
63
+ validateStatusEntry({
64
+ project: "default-status",
65
+ summary: "sum",
66
+ }).status,
67
+ "completed",
68
+ );
69
+ assert.equal(
70
+ validateStatusEntry({
71
+ project: "done-status",
72
+ status: "done",
73
+ summary: "sum",
74
+ }).status,
75
+ "completed",
76
+ );
77
+ assert.ok(
78
+ validateStatusEntry({
79
+ project: "default-time",
80
+ status: "active",
81
+ summary: "sum",
82
+ }).occurredAt,
83
+ );
84
+
58
85
  for (const invalid of [
59
86
  null,
60
87
  [],
@@ -79,12 +106,59 @@ test("domain normalizes valid values and rejects invalid input", () => {
79
106
  });
80
107
  assert.equal("id" in portable, false);
81
108
  assert.equal("recordedAt" in portable, false);
109
+
110
+ assert.deepEqual(
111
+ validateProjectMetadataInput({
112
+ project: " demo ",
113
+ displayName: " Demo Project ",
114
+ description: " Desc ",
115
+ notes: " Notes ",
116
+ }),
117
+ {
118
+ project: "demo",
119
+ displayName: "Demo Project",
120
+ description: "Desc",
121
+ notes: "Notes",
122
+ },
123
+ );
124
+
125
+ assert.deepEqual(
126
+ validateProjectMetadataInput({
127
+ project: "demo",
128
+ description: null,
129
+ notes: "",
130
+ }),
131
+ {
132
+ project: "demo",
133
+ description: null,
134
+ notes: null,
135
+ },
136
+ );
137
+
138
+ for (const invalid of [
139
+ null,
140
+ [],
141
+ { unknownKey: true },
142
+ { project: "bad space" },
143
+ { project: "demo", description: 123 },
144
+ { project: "demo", notes: "x".repeat(2_001) },
145
+ ]) {
146
+ assert.throws(() => validateProjectMetadataInput(invalid), ValidationError);
147
+ }
82
148
  });
83
149
 
84
150
  test("SQLite ledger migrates, appends, is idempotent, and queries by timeline", () => {
85
151
  const db = openLedger(":memory:");
86
- const first = addStatus(db, entry({ externalRef: "one", progress: "done" }));
152
+ const first = addStatus(
153
+ db,
154
+ entry({
155
+ externalRef: "one",
156
+ progress: "done",
157
+ }),
158
+ );
87
159
  assert.equal(first.inserted, true);
160
+ assert.equal(first.entry.progress, "done");
161
+
88
162
  assert.equal(
89
163
  addStatus(db, entry({ summary: "ignored", externalRef: "one" })).inserted,
90
164
  false,
@@ -137,8 +211,87 @@ test("SQLite ledger migrates, appends, is idempotent, and queries by timeline",
137
211
  count: number;
138
212
  }
139
213
  ).count,
140
- 1,
214
+ 2,
215
+ );
216
+
217
+ // Test setProjectMetadata & listProjects
218
+ const meta = setProjectMetadata(db, {
219
+ project: "project-mgr",
220
+ displayName: "Project Manager",
221
+ description: "Updated Description",
222
+ notes: "Updated Notes",
223
+ });
224
+ assert.equal(meta.displayName, "Project Manager");
225
+ assert.equal(meta.description, "Updated Description");
226
+ assert.equal(meta.notes, "Updated Notes");
227
+
228
+ // Create project with notes only (no description)
229
+
230
+ const notesOnlyMeta = setProjectMetadata(db, {
231
+ project: "notes-only-meta",
232
+ notes: "Only Note",
233
+ });
234
+ assert.equal(notesOnlyMeta.description, undefined);
235
+ assert.equal(notesOnlyMeta.notes, "Only Note");
236
+
237
+ // Create project without status entries
238
+ setProjectMetadata(db, {
239
+ project: "standalone",
240
+ description: "Standalone App",
241
+ });
242
+
243
+ // Create project with tag for search
244
+ addStatus(
245
+ db,
246
+ entry({ project: "tag-proj", tags: ["nomatch", "my-special-tag"] }),
247
+ );
248
+ // Add projects with identical timestamps to exercise fallback tie-breaker
249
+ addStatus(
250
+ db,
251
+ entry({ project: "twin-a", occurredAt: "2026-05-01T00:00:00.000Z" }),
252
+ );
253
+ addStatus(
254
+ db,
255
+ entry({ project: "twin-b", occurredAt: "2026-05-01T00:00:00.000Z" }),
141
256
  );
257
+ // Add a project with an even newer status entry timestamp to test descending sort
258
+ addStatus(
259
+ db,
260
+ entry({ project: "newest-proj", occurredAt: "2026-12-31T00:00:00.000Z" }),
261
+ );
262
+ // Add project with undefined progress for query filtering test
263
+ addStatus(db, {
264
+ project: "no-progress-proj",
265
+ summary: "no progress summary",
266
+ });
267
+ const matchedNoProg = listProjects(db, { query: "no progress summary" });
268
+ assert.equal(matchedNoProg.length, 1);
269
+ const noMatch = listProjects(db, { query: "non-existent-tag-query" });
270
+ assert.equal(noMatch.length, 0);
271
+
272
+ const matchedNotes = listProjects(db, { query: "Updated Notes" });
273
+ assert.equal(matchedNotes.length, 1);
274
+ assert.equal(matchedNotes[0].project, "project-mgr");
275
+
276
+ const matchedStandalone = listProjects(db, { query: "Standalone App" });
277
+ assert.equal(matchedStandalone.length, 1);
278
+ assert.equal(matchedStandalone[0].project, "standalone");
279
+
280
+ const matchedNone = listProjects(db, { query: "nonexistent" });
281
+ assert.equal(matchedNone.length, 0);
282
+
283
+ assert.equal(countEntries(db) > 0, true);
284
+ assert.equal(countEntries(db, "tag-proj"), 1);
285
+
286
+ // Test clearing description and notes with null or empty string
287
+ const clearedMeta = setProjectMetadata(db, {
288
+ project: "project-mgr",
289
+ description: null,
290
+ notes: "",
291
+ });
292
+ assert.equal(clearedMeta.description, undefined);
293
+ assert.equal(clearedMeta.notes, undefined);
294
+
142
295
  db.close();
143
296
  });
144
297
 
@@ -209,7 +362,7 @@ test("CLI runs through a package-style symlink", () => {
209
362
  rmSync(directory, { recursive: true, force: true });
210
363
  });
211
364
 
212
- test("CLI supports add, import, reads, errors, and export formats", () => {
365
+ test("CLI supports add, import, reads, errors, project metadata, and export formats", () => {
213
366
  const directory = mkdtempSync(join(tmpdir(), "pmgr-cli-"));
214
367
  const file = join(directory, "ledger.sqlite");
215
368
  const importFile = join(directory, "entries.jsonl");
@@ -253,6 +406,10 @@ test("CLI supports add, import, reads, errors, and export formats", () => {
253
406
  "active",
254
407
  "--summary",
255
408
  "added",
409
+ "--description",
410
+ "CLI project description",
411
+ "--notes",
412
+ "CLI project notes",
256
413
  "--progress",
257
414
  "covered",
258
415
  "--next",
@@ -263,10 +420,118 @@ test("CLI supports add, import, reads, errors, and export formats", () => {
263
420
  "cli",
264
421
  "--external-ref",
265
422
  "cli-add",
423
+ "--at",
424
+ timestamp,
266
425
  "--json",
267
426
  ]),
268
427
  );
269
428
  assert.equal(added.project, "cli");
429
+
430
+ // CLI set-project, projects, and list commands
431
+ assert.match(
432
+ execute([
433
+ "set-project",
434
+ "--project",
435
+ "custom-proj",
436
+ "--display-name",
437
+ "Custom Proj",
438
+ "--description",
439
+ "Custom description",
440
+ "--notes",
441
+ "Custom note",
442
+ "--json",
443
+ ]),
444
+ /Custom Proj/,
445
+ );
446
+
447
+ // Test set-project updates existing project with partial fields
448
+ assert.match(
449
+ execute([
450
+ "set-project",
451
+ "--project",
452
+ "custom-proj",
453
+ "--display-name",
454
+ "Custom Proj Renamed",
455
+ ]),
456
+ /Custom Proj Renamed/,
457
+ );
458
+ execute([
459
+ "set-project",
460
+ "--project",
461
+ "custom-proj",
462
+ "--description",
463
+ "Updated desc only",
464
+ ]);
465
+ execute([
466
+ "set-project",
467
+ "--project",
468
+ "custom-proj",
469
+ "--notes",
470
+ "Updated note only",
471
+ ]);
472
+ execute(["set-project", "--project", "custom-proj"]);
473
+
474
+ // Test list / projects fuzzy search across all branches and human formatting
475
+ assert.match(execute(["projects"]), /cli\tcli/);
476
+ assert.match(execute(["projects"]), /Description: CLI project description/);
477
+ assert.match(execute(["projects"]), /Notes: CLI project notes/);
478
+ assert.match(execute(["projects"]), /Latest Status: \(none\)/);
479
+
480
+ assert.match(
481
+ execute(["list", "--query", "Custom Proj Renamed"]),
482
+ /Custom Proj Renamed/,
483
+ );
484
+ assert.match(
485
+ execute(["list", "--query", "Updated desc only"]),
486
+ /Updated desc only/,
487
+ );
488
+ assert.match(
489
+ execute(["list", "--query", "Updated note only"]),
490
+ /Updated note only/,
491
+ );
492
+ assert.match(execute(["projects", "--query", "active"]), /cli/);
493
+ assert.match(execute(["projects", "--query", "added"]), /cli/);
494
+ assert.match(execute(["projects", "--query", "covered"]), /cli/);
495
+ assert.match(execute(["projects", "--query", "cli"]), /cli/);
496
+ assert.equal(
497
+ execute(["projects", "--query", "no-match"]),
498
+ "No projects found.\n",
499
+ );
500
+ assert.equal(execute(["projects", "--query", "no-match", "--json"]), "[]\n");
501
+
502
+ // Test clearing description and notes via CLI flags
503
+ assert.throws(
504
+ () =>
505
+ execute([
506
+ "set-project",
507
+ "--project",
508
+ "custom-proj",
509
+ "--description",
510
+ "desc",
511
+ "--clear-description",
512
+ ]),
513
+ /cannot specify both --description and --clear-description/,
514
+ );
515
+ assert.throws(
516
+ () =>
517
+ execute([
518
+ "set-project",
519
+ "--project",
520
+ "custom-proj",
521
+ "--notes",
522
+ "note",
523
+ "--clear-notes",
524
+ ]),
525
+ /cannot specify both --notes and --clear-notes/,
526
+ );
527
+ execute([
528
+ "set-project",
529
+ "--project",
530
+ "custom-proj",
531
+ "--clear-description",
532
+ "--clear-notes",
533
+ ]);
534
+
270
535
  assert.match(
271
536
  execute([
272
537
  "status",
@@ -277,6 +542,8 @@ test("CLI supports add, import, reads, errors, and export formats", () => {
277
542
  "active",
278
543
  "--summary",
279
544
  "human output",
545
+ "--at",
546
+ timestamp,
280
547
  ]),
281
548
  /human output/,
282
549
  );
@@ -318,8 +585,9 @@ test("CLI supports add, import, reads, errors, and export formats", () => {
318
585
  JSON.parse(execute(["history", "--limit", "2", "--json"])).map(
319
586
  (item: { project: string }) => item.project,
320
587
  ),
321
- ["human", "cli"],
588
+ ["array", "json"],
322
589
  );
590
+
323
591
  assert.equal(
324
592
  JSON.parse(
325
593
  execute([
@@ -372,7 +640,9 @@ test("CLI supports add, import, reads, errors, and export formats", () => {
372
640
  () => execute(["status", "history", "--project", "cli", "--limit", "1.5"]),
373
641
  ValidationError,
374
642
  );
643
+ assert.throws(() => execute(["set-project"]), ValidationError);
375
644
  assert.throws(() => execute(["status", "add", "--project"]), ValidationError);
645
+
376
646
  assert.throws(
377
647
  () =>
378
648
  execute(["status", "current", "--project", "cli", "--project", "again"]),
@@ -0,0 +1,354 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { type IncomingMessage, type ServerResponse } from "node:http";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import test from "node:test";
8
+ import {
9
+ addStatus,
10
+ getWebUIHTML,
11
+ handleRequest,
12
+ openBrowser,
13
+ openLedger,
14
+ run,
15
+ setProjectMetadata,
16
+ startServer,
17
+ } from "../src/index.js";
18
+
19
+ type MockResponse = {
20
+ statusCode: number;
21
+ headers: Record<string, string>;
22
+ body: string;
23
+ json: () => unknown;
24
+ };
25
+
26
+ function simulateRequest(
27
+ db: ReturnType<typeof openLedger>,
28
+ method: string,
29
+ url?: string,
30
+ hostHeader?: string,
31
+ ): MockResponse {
32
+ const req = new EventEmitter() as unknown as IncomingMessage;
33
+ req.method = method;
34
+ req.url = url as string;
35
+ req.headers = hostHeader ? { host: hostHeader } : {};
36
+
37
+ let statusCode = 200;
38
+ let headers: Record<string, string> = {};
39
+ let body = "";
40
+
41
+ const res = {
42
+ writeHead(code: number, hdrs: Record<string, string>) {
43
+ statusCode = code;
44
+ headers = hdrs;
45
+ },
46
+ end(data?: string) {
47
+ if (data) body += data;
48
+ },
49
+ } as unknown as ServerResponse;
50
+
51
+ handleRequest(req, res, db);
52
+
53
+ return {
54
+ statusCode,
55
+ headers,
56
+ body,
57
+ json: () => (body ? JSON.parse(body) : null),
58
+ };
59
+ }
60
+
61
+ test("server handles Web UI and REST API request routing", () => {
62
+ const db = openLedger(":memory:");
63
+ setProjectMetadata(db, {
64
+ project: "alpha",
65
+ displayName: "Alpha Project",
66
+ description: "Alpha description",
67
+ notes: "Alpha note",
68
+ });
69
+ addStatus(db, {
70
+ project: "alpha",
71
+ status: "active",
72
+ summary: "Alpha started",
73
+ progress: "50%",
74
+ blockers: ["B1"],
75
+ nextSteps: ["N1"],
76
+ decisions: ["D1"],
77
+ tags: ["t1"],
78
+ });
79
+
80
+ setProjectMetadata(db, {
81
+ project: "beta",
82
+ displayName: "Beta Project",
83
+ });
84
+
85
+ // Test GET / with empty/undefined url and host header
86
+ const rootRes = simulateRequest(db, "GET", "", "customhost:8080");
87
+ assert.equal(rootRes.statusCode, 200);
88
+ assert.equal(rootRes.headers["Content-Type"], "text/html; charset=utf-8");
89
+ assert.ok(rootRes.body.includes("Project Manager"));
90
+
91
+ // Test GET /index.html
92
+ const indexRes = simulateRequest(db, "GET", "/index.html");
93
+ assert.equal(indexRes.statusCode, 200);
94
+
95
+ // Test GET /api/projects
96
+ const projectsRes = simulateRequest(db, "GET", "/api/projects");
97
+ assert.equal(projectsRes.statusCode, 200);
98
+ assert.equal((projectsRes.json() as unknown[]).length, 2);
99
+
100
+ // Test GET /api/projects?query=alpha
101
+ const queryRes = simulateRequest(db, "GET", "/api/projects?query=alpha");
102
+ assert.equal(queryRes.statusCode, 200);
103
+ assert.equal((queryRes.json() as unknown[]).length, 1);
104
+
105
+ // Test GET /api/projects/:slug (found)
106
+ const projAlphaRes = simulateRequest(db, "GET", "/api/projects/alpha");
107
+ assert.equal(projAlphaRes.statusCode, 200);
108
+ assert.equal((projAlphaRes.json() as { project: string }).project, "alpha");
109
+
110
+ // Test GET /api/projects/:slug (not found)
111
+ const proj404Res = simulateRequest(db, "GET", "/api/projects/nonexistent");
112
+ assert.equal(proj404Res.statusCode, 404);
113
+ assert.equal(
114
+ (proj404Res.json() as { error: string }).error,
115
+ "Project not found",
116
+ );
117
+
118
+ // Test GET /api/history without query params
119
+ const defaultHistoryRes = simulateRequest(db, "GET", "/api/history");
120
+ assert.equal(defaultHistoryRes.statusCode, 200);
121
+
122
+ // Test GET /api/history with valid numeric limit and offset
123
+ const historyRes = simulateRequest(
124
+ db,
125
+ "GET",
126
+ "/api/history?project=alpha&limit=10&offset=5",
127
+ );
128
+ assert.equal(historyRes.statusCode, 200);
129
+
130
+ // Test GET /api/history with fallback limit/offset NaN
131
+ const historyFallbackRes = simulateRequest(
132
+ db,
133
+ "GET",
134
+ "/api/history?limit=invalid&offset=invalid",
135
+ );
136
+ assert.equal(historyFallbackRes.statusCode, 200);
137
+
138
+ // Test GET /api/stats
139
+ const statsRes = simulateRequest(db, "GET", "/api/stats");
140
+ assert.equal(statsRes.statusCode, 200);
141
+ const stats = statsRes.json() as {
142
+ totalProjects: number;
143
+ totalEntries: number;
144
+ statusCounts: Record<string, number>;
145
+ };
146
+ assert.equal(stats.totalProjects, 2);
147
+ assert.equal(stats.totalEntries, 1);
148
+ assert.equal(stats.statusCounts.active, 1);
149
+ assert.equal(stats.statusCounts.none, 1);
150
+
151
+ // Test 404 for unknown route
152
+ const notFoundRes = simulateRequest(db, "GET", "/api/unknown");
153
+ assert.equal(notFoundRes.statusCode, 404);
154
+
155
+ // Test 405 Method Not Allowed
156
+ const postRes = simulateRequest(db, "POST", "/api/projects");
157
+ assert.equal(postRes.statusCode, 405);
158
+
159
+ db.close();
160
+ });
161
+
162
+ test("startServer initializes HTTP server, closes clean, and handles 500 error boundary", async () => {
163
+ const directory = mkdtempSync(join(tmpdir(), "pmgr-server-"));
164
+ const dbFile = join(directory, "test-server.sqlite");
165
+ const previousDb = process.env.PMGR_DB_PATH;
166
+ const previousPort = process.env.PORT;
167
+ process.env.PMGR_DB_PATH = dbFile;
168
+ process.env.PORT = "0";
169
+
170
+ // Test startServer with custom host and PORT env
171
+ const serverWithHost = await startServer({ host: "localhost", open: false });
172
+ assert.equal(serverWithHost.host, "localhost");
173
+ await serverWithHost.close();
174
+
175
+ delete process.env.PORT;
176
+ const userDb = openLedger(":memory:");
177
+ const serverWithDb = await startServer({ port: 0, open: false, db: userDb });
178
+ assert.ok(serverWithDb.url.length > 0);
179
+ await serverWithDb.close();
180
+ userDb.close();
181
+
182
+ // Test startServer with default options (options = undefined / empty)
183
+ const defaultServerInst = await startServer({ port: 0 });
184
+ assert.ok(defaultServerInst.url.length > 0);
185
+ await defaultServerInst.close();
186
+
187
+ const serverInst = await startServer({ port: 0, open: false });
188
+ assert.ok(serverInst.url.length > 0);
189
+ assert.equal(serverInst.host, "127.0.0.1");
190
+
191
+ // Test createServer callback event firing
192
+ const req = new EventEmitter() as unknown as IncomingMessage;
193
+ req.method = "GET";
194
+ req.url = "/api/projects";
195
+ req.headers = { host: "localhost" };
196
+
197
+ let statusCode = 0;
198
+ let responseData = "";
199
+ const res = {
200
+ writeHead(code: number) {
201
+ statusCode = code;
202
+ },
203
+ end(data?: string) {
204
+ if (data) responseData += data;
205
+ },
206
+ } as unknown as ServerResponse;
207
+
208
+ serverInst.server.emit("request", req, res);
209
+ assert.equal(statusCode, 200);
210
+ assert.ok(responseData.includes("[]"));
211
+
212
+ // Test 500 error catch block in createServer handler for Error and non-Error
213
+ const brokenDbError = {
214
+ prepare() {
215
+ throw new Error("Simulated DB Failure");
216
+ },
217
+ } as unknown as ReturnType<typeof openLedger>;
218
+
219
+ const brokenDbString = {
220
+ prepare() {
221
+ throw "String Exception";
222
+ },
223
+ } as unknown as ReturnType<typeof openLedger>;
224
+
225
+ let errCode = 0;
226
+ let errData = "";
227
+ const errRes = {
228
+ writeHead(code: number) {
229
+ errCode = code;
230
+ },
231
+ end(data?: string) {
232
+ if (data) errData += data;
233
+ },
234
+ } as unknown as ServerResponse;
235
+
236
+ const brokenServerInst = await startServer({
237
+ port: 0,
238
+ open: false,
239
+ db: brokenDbError,
240
+ });
241
+ brokenServerInst.server.emit("request", req, errRes);
242
+ assert.equal(errCode, 500);
243
+ assert.ok(errData.includes("Simulated DB Failure"));
244
+ await brokenServerInst.close();
245
+
246
+ let stringErrCode = 0;
247
+ let stringErrData = "";
248
+ const stringErrRes = {
249
+ writeHead(code: number) {
250
+ stringErrCode = code;
251
+ },
252
+ end(data?: string) {
253
+ if (data) stringErrData += data;
254
+ },
255
+ } as unknown as ServerResponse;
256
+
257
+ const stringBrokenServerInst = await startServer({
258
+ port: 0,
259
+ open: false,
260
+ db: brokenDbString,
261
+ });
262
+ stringBrokenServerInst.server.emit("request", req, stringErrRes);
263
+ assert.equal(stringErrCode, 500);
264
+ assert.ok(stringErrData.includes("String Exception"));
265
+ await stringBrokenServerInst.close();
266
+
267
+ // Test address non-object null and string fallback in server listen callback
268
+ const mockServerInst = await startServer({ port: 0, open: false });
269
+ mockServerInst.server.address = () => null;
270
+ await mockServerInst.close();
271
+
272
+ const mockServerInst2 = await startServer({ port: 0, open: false });
273
+ mockServerInst2.server.address = () => "pipe-address";
274
+ await mockServerInst2.close();
275
+
276
+ // Test close error callback rejection (double close)
277
+ await serverInst.close();
278
+ await assert.rejects(async () => {
279
+ await serverInst.close();
280
+ });
281
+
282
+ if (previousDb) process.env.PMGR_DB_PATH = previousDb;
283
+ else delete process.env.PMGR_DB_PATH;
284
+ if (previousPort) process.env.PORT = previousPort;
285
+ else delete process.env.PORT;
286
+
287
+ rmSync(directory, { recursive: true, force: true });
288
+ });
289
+
290
+ test("openBrowser executes without throwing on all platforms", () => {
291
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
292
+ try {
293
+ Object.defineProperty(process, "platform", { value: "darwin" });
294
+ openBrowser("http://localhost:3000");
295
+
296
+ Object.defineProperty(process, "platform", { value: "win32" });
297
+ openBrowser("http://localhost:3000");
298
+
299
+ Object.defineProperty(process, "platform", { value: "linux" });
300
+ openBrowser("http://localhost:3000");
301
+ } finally {
302
+ if (originalPlatform) {
303
+ Object.defineProperty(process, "platform", originalPlatform);
304
+ }
305
+ }
306
+ });
307
+
308
+ test("CLI serve command supports port, host, no-open, and json output", async () => {
309
+ const directory = mkdtempSync(join(tmpdir(), "pmgr-cli-serve-"));
310
+ const dbFile = join(directory, "serve.sqlite");
311
+ const previousDb = process.env.PMGR_DB_PATH;
312
+ process.env.PMGR_DB_PATH = dbFile;
313
+
314
+ let outputText = "";
315
+ const write = (str: string) => {
316
+ outputText += str;
317
+ };
318
+
319
+ const instance = (await run(
320
+ ["serve", "--port", "0", "--host", "127.0.0.1", "--no-open", "--json"],
321
+ write,
322
+ )) as Awaited<ReturnType<typeof startServer>>;
323
+
324
+ assert.ok(outputText.includes('"url"'));
325
+ assert.ok(instance && typeof instance.close === "function");
326
+ await instance.close();
327
+
328
+ outputText = "";
329
+ const instance2 = (await run(
330
+ ["status", "serve", "--port", "0", "--open"],
331
+ write,
332
+ )) as Awaited<ReturnType<typeof startServer>>;
333
+ assert.ok(outputText.includes("Server running at"));
334
+ await instance2.close();
335
+
336
+ outputText = "";
337
+ process.env.PORT = "0";
338
+ const instance3 = (await run(["serve"], write)) as Awaited<
339
+ ReturnType<typeof startServer>
340
+ >;
341
+ assert.ok(outputText.includes("Server running at"));
342
+ await instance3.close();
343
+ delete process.env.PORT;
344
+
345
+ if (previousDb) process.env.PMGR_DB_PATH = previousDb;
346
+ else delete process.env.PMGR_DB_PATH;
347
+ rmSync(directory, { recursive: true, force: true });
348
+ });
349
+
350
+ test("getWebUIHTML returns HTML string", () => {
351
+ const html = getWebUIHTML();
352
+ assert.ok(typeof html === "string");
353
+ assert.ok(html.includes("<!DOCTYPE html>"));
354
+ });