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/.claude/settings.local.json +5 -0
- package/CHANGELOG.md +19 -0
- package/README.md +64 -9
- package/README_CN.md +67 -12
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +109 -8
- package/dist/db.d.ts +6 -1
- package/dist/db.js +122 -4
- package/dist/domain.d.ts +17 -0
- package/dist/domain.js +56 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +147 -0
- package/dist/web/ui.d.ts +1 -0
- package/dist/web/ui.js +977 -0
- package/docs/architecture.md +3 -3
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +14 -4
- package/src/cli.ts +135 -8
- package/src/db.ts +158 -6
- package/src/domain.ts +80 -7
- package/src/index.ts +3 -0
- package/src/server.ts +202 -0
- package/src/web/ui.ts +977 -0
- package/test/core.test.ts +274 -4
- package/test/server.test.ts +354 -0
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(
|
|
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
|
-
|
|
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
|
-
["
|
|
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
|
+
});
|