kitfly 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/VERSION +1 -0
  5. package/package.json +63 -0
  6. package/schemas/README.md +32 -0
  7. package/schemas/site.schema.json +5 -0
  8. package/schemas/theme.schema.json +5 -0
  9. package/schemas/v0/site.schema.json +172 -0
  10. package/schemas/v0/theme.schema.json +210 -0
  11. package/scripts/build-all.ts +121 -0
  12. package/scripts/build.ts +601 -0
  13. package/scripts/bundle.ts +781 -0
  14. package/scripts/dev.ts +777 -0
  15. package/scripts/generate-checksums.sh +78 -0
  16. package/scripts/release/export-release-key.sh +28 -0
  17. package/scripts/release/release-guard-tag-version.sh +79 -0
  18. package/scripts/release/sign-release-assets.sh +123 -0
  19. package/scripts/release/upload-release-assets.sh +76 -0
  20. package/scripts/release/upload-release-provenance.sh +52 -0
  21. package/scripts/release/verify-public-key.sh +48 -0
  22. package/scripts/release/verify-signatures.sh +117 -0
  23. package/scripts/version-sync.ts +82 -0
  24. package/src/__tests__/build.test.ts +240 -0
  25. package/src/__tests__/bundle.test.ts +786 -0
  26. package/src/__tests__/cli.test.ts +706 -0
  27. package/src/__tests__/crucible.test.ts +1043 -0
  28. package/src/__tests__/engine.test.ts +157 -0
  29. package/src/__tests__/init.test.ts +450 -0
  30. package/src/__tests__/pipeline.test.ts +1087 -0
  31. package/src/__tests__/productbook.test.ts +1206 -0
  32. package/src/__tests__/runbook.test.ts +974 -0
  33. package/src/__tests__/server-registry.test.ts +1251 -0
  34. package/src/__tests__/servicebook.test.ts +1248 -0
  35. package/src/__tests__/shared.test.ts +2005 -0
  36. package/src/__tests__/styles.test.ts +14 -0
  37. package/src/__tests__/theme-schema.test.ts +47 -0
  38. package/src/__tests__/theme.test.ts +554 -0
  39. package/src/cli.ts +582 -0
  40. package/src/commands/init.ts +92 -0
  41. package/src/commands/update.ts +444 -0
  42. package/src/engine.ts +20 -0
  43. package/src/logger.ts +15 -0
  44. package/src/migrations/0000_schema_versioning.ts +67 -0
  45. package/src/migrations/0001_server_port.ts +52 -0
  46. package/src/migrations/0002_brand_logo.ts +49 -0
  47. package/src/migrations/index.ts +26 -0
  48. package/src/migrations/schema.ts +24 -0
  49. package/src/server-registry.ts +405 -0
  50. package/src/shared.ts +1239 -0
  51. package/src/site/styles.css +931 -0
  52. package/src/site/template.html +193 -0
  53. package/src/templates/crucible.ts +1163 -0
  54. package/src/templates/driver.ts +876 -0
  55. package/src/templates/handbook.ts +339 -0
  56. package/src/templates/minimal.ts +139 -0
  57. package/src/templates/pipeline.ts +966 -0
  58. package/src/templates/productbook.ts +1032 -0
  59. package/src/templates/runbook.ts +829 -0
  60. package/src/templates/schema.ts +119 -0
  61. package/src/templates/servicebook.ts +1242 -0
  62. package/src/theme.ts +245 -0
@@ -0,0 +1,1251 @@
1
+ /**
2
+ * Tests for server registry - track running kitfly dev servers
3
+ *
4
+ * Testability notes:
5
+ * - Registry I/O functions (registerServer, listServers, findServerByPort,
6
+ * findServerByContentRoot, unregisterServer, cleanRegistry) all read/write
7
+ * to a hardcoded REGISTRY_PATH (~/.kitfly/servers.json) with no override
8
+ * mechanism, AND call isProcessAlive() which uses process.kill(pid, 0).
9
+ * Testing these would require either mocking fs + process, or writing to the
10
+ * real ~/.kitfly directory (risky - could affect running servers).
11
+ * - stopServer / stopAllServers send real signals (SIGTERM/SIGKILL) — excluded
12
+ * per constraint.
13
+ * - findPidOnPort, discoverOrphans, getProcessInfo depend on @3leaps/sysprims
14
+ * which queries real OS state — excluded per constraint.
15
+ * - cleanLogs reads from hardcoded LOGS_DIR and calls listServers — same
16
+ * issues as registry I/O.
17
+ *
18
+ * What IS tested below: pure path functions, type shape validation, the log
19
+ * filename regex pattern (extracted from cleanLogs logic), and structural
20
+ * contracts that do not require I/O.
21
+ */
22
+
23
+ import { homedir } from "node:os";
24
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
25
+ import { describe, expect, it } from "vitest";
26
+ import {
27
+ getKitflyHome,
28
+ getLogPath,
29
+ getLogsDir,
30
+ type PortConflict,
31
+ type ServerEntry,
32
+ } from "../server-registry.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Path utility tests (pure functions)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("getKitflyHome", () => {
39
+ it("returns ~/.kitfly directory path", () => {
40
+ const result = getKitflyHome();
41
+ const expected = join(homedir(), ".kitfly");
42
+ expect(result).toBe(expected);
43
+ });
44
+
45
+ it("returns consistent value across multiple calls", () => {
46
+ const first = getKitflyHome();
47
+ const second = getKitflyHome();
48
+ expect(first).toBe(second);
49
+ });
50
+
51
+ it("includes home directory as prefix", () => {
52
+ const result = getKitflyHome();
53
+ expect(result).toContain(homedir());
54
+ });
55
+
56
+ it("ends with .kitfly", () => {
57
+ const result = getKitflyHome();
58
+ expect(result.endsWith(".kitfly")).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("getLogsDir", () => {
63
+ it("returns ~/.kitfly/logs directory path", () => {
64
+ const result = getLogsDir();
65
+ const kitflyHome = getKitflyHome();
66
+ expect(result).toBe(join(kitflyHome, "logs"));
67
+ });
68
+
69
+ it("is subdirectory of kitfly home", () => {
70
+ const result = getLogsDir();
71
+ const kitflyHome = getKitflyHome();
72
+ expect(result).toContain(kitflyHome);
73
+ });
74
+
75
+ it("ends with logs", () => {
76
+ const result = getLogsDir();
77
+ expect(result.endsWith("logs")).toBe(true);
78
+ });
79
+
80
+ it("returns consistent value across multiple calls", () => {
81
+ const first = getLogsDir();
82
+ const second = getLogsDir();
83
+ expect(first).toBe(second);
84
+ });
85
+ });
86
+
87
+ describe("getLogPath", () => {
88
+ it("returns log file path for given port", () => {
89
+ const port = 3000;
90
+ const result = getLogPath(port);
91
+ const expected = join(getLogsDir(), `${port}.log`);
92
+ expect(result).toBe(expected);
93
+ });
94
+
95
+ it("includes port number in filename", () => {
96
+ const port = 8080;
97
+ const result = getLogPath(port);
98
+ expect(result).toContain("8080.log");
99
+ });
100
+
101
+ it("includes logs directory in path", () => {
102
+ const port = 5000;
103
+ const result = getLogPath(port);
104
+ expect(result).toContain(join(getLogsDir(), "5000.log"));
105
+ });
106
+
107
+ it("handles different port numbers", () => {
108
+ const ports = [3000, 8080, 5000, 3001, 9999];
109
+ for (const port of ports) {
110
+ const result = getLogPath(port);
111
+ expect(result).toContain(`${port}.log`);
112
+ expect(result).toContain(getLogsDir());
113
+ }
114
+ });
115
+
116
+ it("adds .log extension to port number", () => {
117
+ const result = getLogPath(3000);
118
+ expect(result).toMatch(/\d+\.log$/);
119
+ expect(result.endsWith(".log")).toBe(true);
120
+ });
121
+
122
+ it("handles edge case port numbers", () => {
123
+ // Minimum port
124
+ const minPort = getLogPath(1);
125
+ expect(minPort).toBe(join(getLogsDir(), "1.log"));
126
+
127
+ // Maximum valid port
128
+ const maxPort = getLogPath(65535);
129
+ expect(maxPort).toBe(join(getLogsDir(), "65535.log"));
130
+ });
131
+
132
+ it("produces unique paths for different ports", () => {
133
+ const paths = [3000, 3001, 3002].map((p) => getLogPath(p));
134
+ const uniquePaths = new Set(paths);
135
+ expect(uniquePaths.size).toBe(paths.length);
136
+ });
137
+
138
+ it("returns string path", () => {
139
+ const result = getLogPath(3000);
140
+ expect(typeof result).toBe("string");
141
+ });
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Type tests
146
+ // ---------------------------------------------------------------------------
147
+
148
+ describe("ServerEntry type", () => {
149
+ it("can create valid server entry", () => {
150
+ const entry: ServerEntry = {
151
+ pid: 12345,
152
+ port: 3000,
153
+ host: "localhost",
154
+ contentRoot: "/home/user/project",
155
+ startTime: Date.now(),
156
+ kitflyVersion: "0.1.0",
157
+ daemonized: true,
158
+ };
159
+
160
+ expect(entry.pid).toBe(12345);
161
+ expect(entry.port).toBe(3000);
162
+ expect(entry.host).toBe("localhost");
163
+ expect(entry.contentRoot).toBe("/home/user/project");
164
+ expect(typeof entry.startTime).toBe("number");
165
+ expect(entry.kitflyVersion).toBe("0.1.0");
166
+ expect(entry.daemonized).toBe(true);
167
+ });
168
+
169
+ it("server entry with different values", () => {
170
+ const entry: ServerEntry = {
171
+ pid: 99999,
172
+ port: 8080,
173
+ host: "0.0.0.0",
174
+ contentRoot: "/var/www/site",
175
+ startTime: 1000000000,
176
+ kitflyVersion: "0.2.0",
177
+ daemonized: false,
178
+ };
179
+
180
+ expect(entry.pid).toBe(99999);
181
+ expect(entry.port).toBe(8080);
182
+ expect(entry.daemonized).toBe(false);
183
+ });
184
+
185
+ it("server entry properties are required", () => {
186
+ // This is more of a type check - the following would not compile in TS
187
+ // but we test the runtime behavior is sensible
188
+ const entry: ServerEntry = {
189
+ pid: 123,
190
+ port: 3000,
191
+ host: "localhost",
192
+ contentRoot: "/path",
193
+ startTime: 0,
194
+ kitflyVersion: "0.1.0",
195
+ daemonized: false,
196
+ };
197
+
198
+ // All properties should be defined
199
+ expect(entry).toHaveProperty("pid");
200
+ expect(entry).toHaveProperty("port");
201
+ expect(entry).toHaveProperty("host");
202
+ expect(entry).toHaveProperty("contentRoot");
203
+ expect(entry).toHaveProperty("startTime");
204
+ expect(entry).toHaveProperty("kitflyVersion");
205
+ expect(entry).toHaveProperty("daemonized");
206
+ });
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Path composition tests
211
+ // ---------------------------------------------------------------------------
212
+
213
+ describe("Path relationships", () => {
214
+ it("logs directory is under kitfly home", () => {
215
+ const kitflyHome = getKitflyHome();
216
+ const logsDir = getLogsDir();
217
+ expect(logsDir.startsWith(kitflyHome)).toBe(true);
218
+ });
219
+
220
+ it("log path is under logs directory", () => {
221
+ const logsDir = getLogsDir();
222
+ const logPath = getLogPath(3000);
223
+ expect(logPath.startsWith(logsDir)).toBe(true);
224
+ });
225
+
226
+ it("all paths are absolute", () => {
227
+ const kitflyHome = getKitflyHome();
228
+ const logsDir = getLogsDir();
229
+ const logPath = getLogPath(3000);
230
+
231
+ expect(isAbsolute(kitflyHome)).toBe(true);
232
+ expect(isAbsolute(logsDir)).toBe(true);
233
+ expect(isAbsolute(logPath)).toBe(true);
234
+ });
235
+
236
+ it("nested hierarchy is correct", () => {
237
+ const kitflyHome = getKitflyHome();
238
+ const logsDir = getLogsDir();
239
+ const logPath = getLogPath(8080);
240
+
241
+ // logPath should contain logsDir should contain kitflyHome
242
+ expect(logPath).toContain(logsDir);
243
+ expect(logsDir).toContain(kitflyHome);
244
+ expect(kitflyHome).toContain(homedir());
245
+ });
246
+ });
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Edge case tests
250
+ // ---------------------------------------------------------------------------
251
+
252
+ describe("Path edge cases", () => {
253
+ it("handles zero port number", () => {
254
+ const result = getLogPath(0);
255
+ expect(result).toBe(join(getLogsDir(), "0.log"));
256
+ });
257
+
258
+ it("handles large port numbers", () => {
259
+ const result = getLogPath(999999);
260
+ expect(result).toContain("999999.log");
261
+ });
262
+
263
+ it("handles port at boundaries", () => {
264
+ expect(getLogPath(1)).toContain("1.log");
265
+ expect(getLogPath(65535)).toContain("65535.log");
266
+ });
267
+
268
+ it("log paths with same port are identical", () => {
269
+ const path1 = getLogPath(3000);
270
+ const path2 = getLogPath(3000);
271
+ expect(path1).toBe(path2);
272
+ });
273
+
274
+ it("different ports produce different paths", () => {
275
+ const path1 = getLogPath(3000);
276
+ const path2 = getLogPath(3001);
277
+ expect(path1).not.toBe(path2);
278
+ });
279
+ });
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // String format tests
283
+ // ---------------------------------------------------------------------------
284
+
285
+ describe("Path string formats", () => {
286
+ it("kitfly home path format is valid", () => {
287
+ const result = getKitflyHome();
288
+ // Should be a valid path string (not empty, is string)
289
+ expect(result.length).toBeGreaterThan(0);
290
+ expect(typeof result).toBe("string");
291
+ // Should not have trailing slash
292
+ expect(result.endsWith("/")).toBe(false);
293
+ });
294
+
295
+ it("logs dir path format is valid", () => {
296
+ const result = getLogsDir();
297
+ expect(result.length).toBeGreaterThan(0);
298
+ expect(typeof result).toBe("string");
299
+ expect(result.endsWith("/")).toBe(false);
300
+ });
301
+
302
+ it("log path format is valid", () => {
303
+ const result = getLogPath(3000);
304
+ expect(result.length).toBeGreaterThan(0);
305
+ expect(typeof result).toBe("string");
306
+ expect(result).toMatch(/\d+\.log$/);
307
+ // Should not have trailing slash
308
+ expect(result.endsWith("/")).toBe(false);
309
+ });
310
+
311
+ it("paths use OS-native separators consistently", () => {
312
+ // path.join uses OS-native separators (/ on Unix, \ on Windows)
313
+ const kitflyHome = getKitflyHome();
314
+ const logsDir = getLogsDir();
315
+ const logPath = getLogPath(3000);
316
+
317
+ // All paths should be well-formed and non-empty
318
+ expect(kitflyHome.length).toBeGreaterThan(0);
319
+ expect(logsDir.length).toBeGreaterThan(0);
320
+ expect(logPath.length).toBeGreaterThan(0);
321
+ });
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // PortConflict type shape
326
+ // ---------------------------------------------------------------------------
327
+
328
+ describe("PortConflict type", () => {
329
+ it("can represent a kitfly conflict", () => {
330
+ const conflict: PortConflict = {
331
+ type: "kitfly",
332
+ port: 3000,
333
+ pid: 12345,
334
+ contentRoot: "/home/user/project",
335
+ };
336
+
337
+ expect(conflict.type).toBe("kitfly");
338
+ expect(conflict.port).toBe(3000);
339
+ expect(conflict.pid).toBe(12345);
340
+ expect(conflict.contentRoot).toBe("/home/user/project");
341
+ expect(conflict.processName).toBeUndefined();
342
+ });
343
+
344
+ it("can represent an external process conflict", () => {
345
+ const conflict: PortConflict = {
346
+ type: "other",
347
+ port: 8080,
348
+ pid: 54321,
349
+ processName: "nginx",
350
+ };
351
+
352
+ expect(conflict.type).toBe("other");
353
+ expect(conflict.port).toBe(8080);
354
+ expect(conflict.pid).toBe(54321);
355
+ expect(conflict.processName).toBe("nginx");
356
+ expect(conflict.contentRoot).toBeUndefined();
357
+ });
358
+
359
+ it("type field only allows kitfly or other", () => {
360
+ const kitflyConflict: PortConflict = {
361
+ type: "kitfly",
362
+ port: 3000,
363
+ pid: 1,
364
+ };
365
+ const otherConflict: PortConflict = {
366
+ type: "other",
367
+ port: 3000,
368
+ pid: 1,
369
+ };
370
+ expect(["kitfly", "other"]).toContain(kitflyConflict.type);
371
+ expect(["kitfly", "other"]).toContain(otherConflict.type);
372
+ });
373
+
374
+ it("optional fields can be omitted", () => {
375
+ const conflict: PortConflict = {
376
+ type: "kitfly",
377
+ port: 3000,
378
+ pid: 100,
379
+ };
380
+
381
+ // processName and contentRoot are optional
382
+ expect(conflict).not.toHaveProperty("processName");
383
+ expect(conflict).not.toHaveProperty("contentRoot");
384
+ });
385
+
386
+ it("can have both optional fields set", () => {
387
+ const conflict: PortConflict = {
388
+ type: "kitfly",
389
+ port: 3000,
390
+ pid: 100,
391
+ processName: "bun",
392
+ contentRoot: "/tmp/site",
393
+ };
394
+
395
+ expect(conflict.processName).toBe("bun");
396
+ expect(conflict.contentRoot).toBe("/tmp/site");
397
+ });
398
+ });
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Log filename regex (same pattern used by cleanLogs)
402
+ // ---------------------------------------------------------------------------
403
+
404
+ describe("Log filename pattern", () => {
405
+ // cleanLogs uses /^(\d+)\.log$/ to match log files — test this regex
406
+ // to ensure the logic matches expected filenames
407
+ const LOG_FILE_PATTERN = /^(\d+)\.log$/;
408
+
409
+ it("matches standard port log files", () => {
410
+ expect(LOG_FILE_PATTERN.test("3000.log")).toBe(true);
411
+ expect(LOG_FILE_PATTERN.test("8080.log")).toBe(true);
412
+ expect(LOG_FILE_PATTERN.test("1.log")).toBe(true);
413
+ expect(LOG_FILE_PATTERN.test("65535.log")).toBe(true);
414
+ });
415
+
416
+ it("extracts port number from filename", () => {
417
+ const match = "3000.log".match(LOG_FILE_PATTERN);
418
+ expect(match).not.toBeNull();
419
+ expect(match?.[1]).toBe("3000");
420
+ expect(parseInt(match?.[1] ?? "", 10)).toBe(3000);
421
+ });
422
+
423
+ it("extracts port from high-numbered filename", () => {
424
+ const match = "65535.log".match(LOG_FILE_PATTERN);
425
+ expect(match).not.toBeNull();
426
+ expect(parseInt(match?.[1] ?? "", 10)).toBe(65535);
427
+ });
428
+
429
+ it("does not match non-numeric filenames", () => {
430
+ expect(LOG_FILE_PATTERN.test("server.log")).toBe(false);
431
+ expect(LOG_FILE_PATTERN.test("access.log")).toBe(false);
432
+ expect(LOG_FILE_PATTERN.test("error.log")).toBe(false);
433
+ });
434
+
435
+ it("does not match filenames with non-digit characters", () => {
436
+ expect(LOG_FILE_PATTERN.test("3000a.log")).toBe(false);
437
+ expect(LOG_FILE_PATTERN.test("a3000.log")).toBe(false);
438
+ expect(LOG_FILE_PATTERN.test("30-00.log")).toBe(false);
439
+ expect(LOG_FILE_PATTERN.test("30.00.log")).toBe(false);
440
+ });
441
+
442
+ it("does not match wrong extensions", () => {
443
+ expect(LOG_FILE_PATTERN.test("3000.txt")).toBe(false);
444
+ expect(LOG_FILE_PATTERN.test("3000.json")).toBe(false);
445
+ expect(LOG_FILE_PATTERN.test("3000.log.bak")).toBe(false);
446
+ expect(LOG_FILE_PATTERN.test("3000")).toBe(false);
447
+ });
448
+
449
+ it("does not match hidden files or directories", () => {
450
+ expect(LOG_FILE_PATTERN.test(".3000.log")).toBe(false);
451
+ expect(LOG_FILE_PATTERN.test(".log")).toBe(false);
452
+ });
453
+
454
+ it("does not match partial matches", () => {
455
+ // The ^ and $ anchors prevent substring matches
456
+ expect(LOG_FILE_PATTERN.test("prefix3000.log")).toBe(false);
457
+ expect(LOG_FILE_PATTERN.test("3000.logsuffix")).toBe(false);
458
+ });
459
+
460
+ it("matches zero port", () => {
461
+ expect(LOG_FILE_PATTERN.test("0.log")).toBe(true);
462
+ const match = "0.log".match(LOG_FILE_PATTERN);
463
+ expect(parseInt(match?.[1] ?? "", 10)).toBe(0);
464
+ });
465
+
466
+ it("matches large numbers beyond valid port range", () => {
467
+ // The regex does not enforce port range — it matches any digit sequence
468
+ expect(LOG_FILE_PATTERN.test("999999.log")).toBe(true);
469
+ expect(LOG_FILE_PATTERN.test("100000.log")).toBe(true);
470
+ });
471
+
472
+ it("does not match empty port", () => {
473
+ expect(LOG_FILE_PATTERN.test(".log")).toBe(false);
474
+ });
475
+ });
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // ServerEntry edge cases and serialization
479
+ // ---------------------------------------------------------------------------
480
+
481
+ describe("ServerEntry edge cases", () => {
482
+ it("handles minimum valid values", () => {
483
+ const entry: ServerEntry = {
484
+ pid: 1,
485
+ port: 1,
486
+ host: "",
487
+ contentRoot: "/",
488
+ startTime: 0,
489
+ kitflyVersion: "",
490
+ daemonized: false,
491
+ };
492
+ expect(entry.pid).toBe(1);
493
+ expect(entry.port).toBe(1);
494
+ expect(entry.startTime).toBe(0);
495
+ });
496
+
497
+ it("handles large PID values", () => {
498
+ // Linux max PID is typically 4194304, macOS can go higher
499
+ const entry: ServerEntry = {
500
+ pid: 4194304,
501
+ port: 3000,
502
+ host: "localhost",
503
+ contentRoot: "/home/user/site",
504
+ startTime: Date.now(),
505
+ kitflyVersion: "1.0.0",
506
+ daemonized: true,
507
+ };
508
+ expect(entry.pid).toBe(4194304);
509
+ });
510
+
511
+ it("roundtrips through JSON serialization", () => {
512
+ const entry: ServerEntry = {
513
+ pid: 12345,
514
+ port: 3000,
515
+ host: "localhost",
516
+ contentRoot: "/home/user/project",
517
+ startTime: 1700000000000,
518
+ kitflyVersion: "0.1.0",
519
+ daemonized: true,
520
+ };
521
+
522
+ const json = JSON.stringify(entry);
523
+ const parsed = JSON.parse(json) as ServerEntry;
524
+
525
+ expect(parsed.pid).toBe(entry.pid);
526
+ expect(parsed.port).toBe(entry.port);
527
+ expect(parsed.host).toBe(entry.host);
528
+ expect(parsed.contentRoot).toBe(entry.contentRoot);
529
+ expect(parsed.startTime).toBe(entry.startTime);
530
+ expect(parsed.kitflyVersion).toBe(entry.kitflyVersion);
531
+ expect(parsed.daemonized).toBe(entry.daemonized);
532
+ });
533
+
534
+ it("preserves exact property count (7 fields)", () => {
535
+ const entry: ServerEntry = {
536
+ pid: 1,
537
+ port: 1,
538
+ host: "h",
539
+ contentRoot: "/",
540
+ startTime: 0,
541
+ kitflyVersion: "0",
542
+ daemonized: false,
543
+ };
544
+ expect(Object.keys(entry)).toHaveLength(7);
545
+ });
546
+
547
+ it("host can be any string (localhost, 0.0.0.0, IP, hostname)", () => {
548
+ const hosts = ["localhost", "0.0.0.0", "127.0.0.1", "::1", "my-host.local"];
549
+ for (const host of hosts) {
550
+ const entry: ServerEntry = {
551
+ pid: 1,
552
+ port: 3000,
553
+ host,
554
+ contentRoot: "/tmp",
555
+ startTime: 0,
556
+ kitflyVersion: "0.1.0",
557
+ daemonized: false,
558
+ };
559
+ expect(entry.host).toBe(host);
560
+ }
561
+ });
562
+
563
+ it("contentRoot accepts various path formats", () => {
564
+ const paths = ["/", "/home/user", "/tmp/my-site", "/var/www/html"];
565
+ for (const p of paths) {
566
+ const entry: ServerEntry = {
567
+ pid: 1,
568
+ port: 3000,
569
+ host: "localhost",
570
+ contentRoot: p,
571
+ startTime: 0,
572
+ kitflyVersion: "0.1.0",
573
+ daemonized: false,
574
+ };
575
+ expect(entry.contentRoot).toBe(p);
576
+ }
577
+ });
578
+
579
+ it("startTime is a unix epoch timestamp in milliseconds", () => {
580
+ const now = Date.now();
581
+ const entry: ServerEntry = {
582
+ pid: 1,
583
+ port: 3000,
584
+ host: "localhost",
585
+ contentRoot: "/tmp",
586
+ startTime: now,
587
+ kitflyVersion: "0.1.0",
588
+ daemonized: false,
589
+ };
590
+ // Verify it's a reasonable ms timestamp (after year 2020, before year 2100)
591
+ expect(entry.startTime).toBeGreaterThan(1577836800000);
592
+ expect(entry.startTime).toBeLessThan(4102444800000);
593
+ });
594
+ });
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // ServerRegistry JSON structure
598
+ // ---------------------------------------------------------------------------
599
+
600
+ describe("ServerRegistry JSON structure", () => {
601
+ it("empty registry has version 1 and empty servers array", () => {
602
+ // This matches what readRegistry() returns on parse failure
603
+ const emptyRegistry = { version: 1 as const, servers: [] as ServerEntry[] };
604
+ expect(emptyRegistry.version).toBe(1);
605
+ expect(emptyRegistry.servers).toEqual([]);
606
+ });
607
+
608
+ it("registry with servers preserves all entries through serialization", () => {
609
+ const entries: ServerEntry[] = [
610
+ {
611
+ pid: 100,
612
+ port: 3000,
613
+ host: "localhost",
614
+ contentRoot: "/project-a",
615
+ startTime: 1700000000000,
616
+ kitflyVersion: "0.1.0",
617
+ daemonized: true,
618
+ },
619
+ {
620
+ pid: 200,
621
+ port: 3001,
622
+ host: "localhost",
623
+ contentRoot: "/project-b",
624
+ startTime: 1700000001000,
625
+ kitflyVersion: "0.1.0",
626
+ daemonized: false,
627
+ },
628
+ ];
629
+ const registry = { version: 1 as const, servers: entries };
630
+
631
+ const json = JSON.stringify(registry, null, 2);
632
+ const parsed = JSON.parse(json);
633
+
634
+ expect(parsed.version).toBe(1);
635
+ expect(parsed.servers).toHaveLength(2);
636
+ expect(parsed.servers[0].port).toBe(3000);
637
+ expect(parsed.servers[1].port).toBe(3001);
638
+ });
639
+
640
+ it("registry JSON uses pretty-print format (2-space indent)", () => {
641
+ // writeRegistry uses JSON.stringify(registry, null, 2)
642
+ const registry = { version: 1 as const, servers: [] as ServerEntry[] };
643
+ const json = JSON.stringify(registry, null, 2);
644
+
645
+ expect(json).toContain("\n");
646
+ expect(json).toContain(" "); // 2-space indent
647
+ expect(json).not.toContain("\t"); // no tabs
648
+ });
649
+
650
+ it("servers can be filtered by port (registerServer dedup logic)", () => {
651
+ const servers: ServerEntry[] = [
652
+ {
653
+ pid: 100,
654
+ port: 3000,
655
+ host: "localhost",
656
+ contentRoot: "/project-a",
657
+ startTime: 1700000000000,
658
+ kitflyVersion: "0.1.0",
659
+ daemonized: true,
660
+ },
661
+ {
662
+ pid: 200,
663
+ port: 3001,
664
+ host: "localhost",
665
+ contentRoot: "/project-b",
666
+ startTime: 1700000001000,
667
+ kitflyVersion: "0.1.0",
668
+ daemonized: false,
669
+ },
670
+ {
671
+ pid: 300,
672
+ port: 3000,
673
+ host: "localhost",
674
+ contentRoot: "/project-c",
675
+ startTime: 1700000002000,
676
+ kitflyVersion: "0.1.0",
677
+ daemonized: true,
678
+ },
679
+ ];
680
+
681
+ // registerServer filters out existing entries for the same port
682
+ const filtered = servers.filter((s) => s.port !== 3000);
683
+ expect(filtered).toHaveLength(1);
684
+ expect(filtered[0].port).toBe(3001);
685
+ });
686
+
687
+ it("servers can be looked up by port (findServerByPort logic)", () => {
688
+ const servers: ServerEntry[] = [
689
+ {
690
+ pid: 100,
691
+ port: 3000,
692
+ host: "localhost",
693
+ contentRoot: "/project-a",
694
+ startTime: 1700000000000,
695
+ kitflyVersion: "0.1.0",
696
+ daemonized: true,
697
+ },
698
+ {
699
+ pid: 200,
700
+ port: 8080,
701
+ host: "localhost",
702
+ contentRoot: "/project-b",
703
+ startTime: 1700000001000,
704
+ kitflyVersion: "0.1.0",
705
+ daemonized: false,
706
+ },
707
+ ];
708
+
709
+ const found = servers.find((s) => s.port === 8080) ?? null;
710
+ expect(found).not.toBeNull();
711
+ expect(found?.pid).toBe(200);
712
+ expect(found?.contentRoot).toBe("/project-b");
713
+
714
+ const notFound = servers.find((s) => s.port === 9999) ?? null;
715
+ expect(notFound).toBeNull();
716
+ });
717
+
718
+ it("servers can be looked up by contentRoot (findServerByContentRoot logic)", () => {
719
+ const servers: ServerEntry[] = [
720
+ {
721
+ pid: 100,
722
+ port: 3000,
723
+ host: "localhost",
724
+ contentRoot: "/project-a",
725
+ startTime: 1700000000000,
726
+ kitflyVersion: "0.1.0",
727
+ daemonized: true,
728
+ },
729
+ {
730
+ pid: 200,
731
+ port: 3001,
732
+ host: "localhost",
733
+ contentRoot: "/project-b",
734
+ startTime: 1700000001000,
735
+ kitflyVersion: "0.1.0",
736
+ daemonized: false,
737
+ },
738
+ ];
739
+
740
+ const found = servers.find((s) => s.contentRoot === "/project-a") ?? null;
741
+ expect(found).not.toBeNull();
742
+ expect(found?.port).toBe(3000);
743
+
744
+ // contentRoot match is exact (no normalization)
745
+ const notFound = servers.find((s) => s.contentRoot === "/project-a/") ?? null;
746
+ expect(notFound).toBeNull();
747
+ });
748
+
749
+ it("unregister filters by port and returns length change", () => {
750
+ const servers: ServerEntry[] = [
751
+ {
752
+ pid: 100,
753
+ port: 3000,
754
+ host: "localhost",
755
+ contentRoot: "/a",
756
+ startTime: 0,
757
+ kitflyVersion: "0.1.0",
758
+ daemonized: false,
759
+ },
760
+ {
761
+ pid: 200,
762
+ port: 3001,
763
+ host: "localhost",
764
+ contentRoot: "/b",
765
+ startTime: 0,
766
+ kitflyVersion: "0.1.0",
767
+ daemonized: false,
768
+ },
769
+ ];
770
+
771
+ const before = servers.length;
772
+ const after = servers.filter((s) => s.port !== 3000);
773
+ const removed = after.length < before;
774
+
775
+ expect(removed).toBe(true);
776
+ expect(after).toHaveLength(1);
777
+ expect(after[0].port).toBe(3001);
778
+ });
779
+
780
+ it("unregister returns false when port not found", () => {
781
+ const servers: ServerEntry[] = [
782
+ {
783
+ pid: 100,
784
+ port: 3000,
785
+ host: "localhost",
786
+ contentRoot: "/a",
787
+ startTime: 0,
788
+ kitflyVersion: "0.1.0",
789
+ daemonized: false,
790
+ },
791
+ ];
792
+
793
+ const before = servers.length;
794
+ const after = servers.filter((s) => s.port !== 9999);
795
+ const removed = after.length < before;
796
+
797
+ expect(removed).toBe(false);
798
+ expect(after).toHaveLength(1);
799
+ });
800
+ });
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // Port conflict detection logic
804
+ // ---------------------------------------------------------------------------
805
+
806
+ describe("Port conflict detection logic", () => {
807
+ it("same contentRoot means no conflict (reuse scenario)", () => {
808
+ const existingServer: ServerEntry = {
809
+ pid: 100,
810
+ port: 3000,
811
+ host: "localhost",
812
+ contentRoot: "/project",
813
+ startTime: 0,
814
+ kitflyVersion: "0.1.0",
815
+ daemonized: true,
816
+ };
817
+
818
+ // checkPortConflict returns null when contentRoot matches
819
+ const requestedContentRoot = "/project";
820
+ const isConflict = existingServer.contentRoot !== requestedContentRoot;
821
+ expect(isConflict).toBe(false);
822
+ });
823
+
824
+ it("different contentRoot on same port is a kitfly conflict", () => {
825
+ const existingServer: ServerEntry = {
826
+ pid: 100,
827
+ port: 3000,
828
+ host: "localhost",
829
+ contentRoot: "/project-a",
830
+ startTime: 0,
831
+ kitflyVersion: "0.1.0",
832
+ daemonized: true,
833
+ };
834
+
835
+ const requestedContentRoot = "/project-b";
836
+ const isConflict = existingServer.contentRoot !== requestedContentRoot;
837
+ expect(isConflict).toBe(true);
838
+
839
+ // The conflict would be typed as "kitfly" with the existing server's details
840
+ const conflict: PortConflict = {
841
+ type: "kitfly",
842
+ port: existingServer.port,
843
+ pid: existingServer.pid,
844
+ contentRoot: existingServer.contentRoot,
845
+ };
846
+ expect(conflict.type).toBe("kitfly");
847
+ expect(conflict.contentRoot).toBe("/project-a");
848
+ });
849
+
850
+ it("contentRoot comparison is exact string equality", () => {
851
+ // No path normalization is done — trailing slash matters
852
+ const root1: string = "/home/user/project";
853
+ const root2: string = "/home/user/project/";
854
+ expect(root1 === root2).toBe(false);
855
+
856
+ // Case sensitivity matters
857
+ const root3: string = "/Home/User/Project";
858
+ expect(root1 === root3).toBe(false);
859
+ });
860
+
861
+ it("external process conflict has type other", () => {
862
+ const conflict: PortConflict = {
863
+ type: "other",
864
+ port: 80,
865
+ pid: 1,
866
+ processName: "httpd",
867
+ };
868
+ expect(conflict.type).toBe("other");
869
+ expect(conflict.processName).toBe("httpd");
870
+ expect(conflict.contentRoot).toBeUndefined();
871
+ });
872
+ });
873
+
874
+ // ---------------------------------------------------------------------------
875
+ // stopServer return type shapes
876
+ // ---------------------------------------------------------------------------
877
+
878
+ describe("stopServer return type shapes", () => {
879
+ it("success result has expected shape", () => {
880
+ const result = { success: true, message: "Stopped server on port 3000" };
881
+ expect(result).toHaveProperty("success");
882
+ expect(result).toHaveProperty("message");
883
+ expect(result.success).toBe(true);
884
+ expect(typeof result.message).toBe("string");
885
+ });
886
+
887
+ it("no-server-found result", () => {
888
+ const port = 3000;
889
+ const result = { success: false, message: `No server running on port ${port}` };
890
+ expect(result.success).toBe(false);
891
+ expect(result.message).toContain("3000");
892
+ });
893
+
894
+ it("invalid-pid result includes pid in message", () => {
895
+ const port = 3000;
896
+ const pid = -1;
897
+ const result = {
898
+ success: false,
899
+ message: `Removed invalid registry entry for port ${port} (pid: ${pid})`,
900
+ };
901
+ expect(result.success).toBe(false);
902
+ expect(result.message).toContain("invalid");
903
+ expect(result.message).toContain(String(pid));
904
+ expect(result.message).toContain(String(port));
905
+ });
906
+
907
+ it("failure result includes error details", () => {
908
+ const pid = 12345;
909
+ const err = new Error("EPERM");
910
+ const result = {
911
+ success: false,
912
+ message: `Failed to stop server (PID ${pid}): ${err}`,
913
+ };
914
+ expect(result.success).toBe(false);
915
+ expect(result.message).toContain("Failed");
916
+ expect(result.message).toContain(String(pid));
917
+ });
918
+ });
919
+
920
+ // ---------------------------------------------------------------------------
921
+ // stopAllServers return type shapes
922
+ // ---------------------------------------------------------------------------
923
+
924
+ describe("stopAllServers return type shape", () => {
925
+ it("has stopped, failed, and orphans counts", () => {
926
+ const result = { stopped: 2, failed: 1, orphans: 0 };
927
+ expect(result).toHaveProperty("stopped");
928
+ expect(result).toHaveProperty("failed");
929
+ expect(result).toHaveProperty("orphans");
930
+ expect(typeof result.stopped).toBe("number");
931
+ expect(typeof result.failed).toBe("number");
932
+ expect(typeof result.orphans).toBe("number");
933
+ });
934
+
935
+ it("all-success scenario", () => {
936
+ const result = { stopped: 3, failed: 0, orphans: 0 };
937
+ expect(result.stopped).toBeGreaterThan(0);
938
+ expect(result.failed).toBe(0);
939
+ });
940
+
941
+ it("empty scenario (no servers running)", () => {
942
+ const result = { stopped: 0, failed: 0, orphans: 0 };
943
+ expect(result.stopped + result.failed + result.orphans).toBe(0);
944
+ });
945
+
946
+ it("orphans-only scenario", () => {
947
+ const result = { stopped: 0, failed: 0, orphans: 2 };
948
+ expect(result.orphans).toBe(2);
949
+ expect(result.stopped).toBe(0);
950
+ });
951
+ });
952
+
953
+ // ---------------------------------------------------------------------------
954
+ // Path component extraction
955
+ // ---------------------------------------------------------------------------
956
+
957
+ describe("Path component extraction", () => {
958
+ it("getLogPath filename is <port>.log", () => {
959
+ const logPath = getLogPath(3000);
960
+ const filename = basename(logPath);
961
+ expect(filename).toBe("3000.log");
962
+ });
963
+
964
+ it("getLogPath parent directory is the logs dir", () => {
965
+ const logPath = getLogPath(3000);
966
+ const logsDir = getLogsDir();
967
+ expect(dirname(logPath)).toBe(logsDir);
968
+ });
969
+
970
+ it("getKitflyHome basename is .kitfly", () => {
971
+ const home = getKitflyHome();
972
+ const name = basename(home);
973
+ expect(name).toBe(".kitfly");
974
+ });
975
+
976
+ it("getLogsDir basename is logs", () => {
977
+ const dir = getLogsDir();
978
+ const name = basename(dir);
979
+ expect(name).toBe("logs");
980
+ });
981
+
982
+ it("directory depth from homedir is exactly 1 for kitfly home", () => {
983
+ const home = getKitflyHome();
984
+ const relative = home.slice(homedir().length);
985
+ const segments = relative.split(sep).filter(Boolean);
986
+ expect(segments).toEqual([".kitfly"]);
987
+ });
988
+
989
+ it("directory depth from homedir is exactly 2 for logs dir", () => {
990
+ const dir = getLogsDir();
991
+ const relative = dir.slice(homedir().length);
992
+ const segments = relative.split(sep).filter(Boolean);
993
+ expect(segments).toEqual([".kitfly", "logs"]);
994
+ });
995
+
996
+ it("directory depth from homedir is exactly 2 for log file (plus filename)", () => {
997
+ const logPath = getLogPath(3000);
998
+ const relative = logPath.slice(homedir().length);
999
+ const segments = relative.split(sep).filter(Boolean);
1000
+ expect(segments).toEqual([".kitfly", "logs", "3000.log"]);
1001
+ });
1002
+ });
1003
+
1004
+ // ---------------------------------------------------------------------------
1005
+ // cleanLogs filtering logic (offline / data-only)
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ describe("cleanLogs filtering logic", () => {
1009
+ // Tests the data-filtering logic that cleanLogs applies, without real I/O.
1010
+ // cleanLogs: for each <port>.log file, remove it if port is NOT in activePorts.
1011
+
1012
+ function simulateCleanLogs(dirEntries: string[], activePorts: Set<number>): string[] {
1013
+ const removed: string[] = [];
1014
+ for (const entry of dirEntries) {
1015
+ const match = entry.match(/^(\d+)\.log$/);
1016
+ if (!match) continue;
1017
+ const port = parseInt(match[1], 10);
1018
+ if (!activePorts.has(port)) {
1019
+ removed.push(entry);
1020
+ }
1021
+ }
1022
+ return removed;
1023
+ }
1024
+
1025
+ it("removes logs for ports not in active set", () => {
1026
+ const dirEntries = ["3000.log", "3001.log", "3002.log"];
1027
+ const activePorts = new Set([3000]);
1028
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1029
+ expect(removed).toEqual(["3001.log", "3002.log"]);
1030
+ });
1031
+
1032
+ it("keeps logs for all active ports", () => {
1033
+ const dirEntries = ["3000.log", "3001.log"];
1034
+ const activePorts = new Set([3000, 3001]);
1035
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1036
+ expect(removed).toEqual([]);
1037
+ });
1038
+
1039
+ it("removes all logs when no servers are active", () => {
1040
+ const dirEntries = ["3000.log", "8080.log"];
1041
+ const activePorts = new Set<number>();
1042
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1043
+ expect(removed).toEqual(["3000.log", "8080.log"]);
1044
+ });
1045
+
1046
+ it("ignores non-log files", () => {
1047
+ const dirEntries = ["3000.log", "readme.txt", ".gitkeep", "backup.tar.gz"];
1048
+ const activePorts = new Set<number>();
1049
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1050
+ // Only 3000.log matches the pattern
1051
+ expect(removed).toEqual(["3000.log"]);
1052
+ });
1053
+
1054
+ it("ignores files with non-numeric names", () => {
1055
+ const dirEntries = ["server.log", "access.log", "error.log"];
1056
+ const activePorts = new Set<number>();
1057
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1058
+ expect(removed).toEqual([]);
1059
+ });
1060
+
1061
+ it("handles empty directory", () => {
1062
+ const dirEntries: string[] = [];
1063
+ const activePorts = new Set([3000]);
1064
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1065
+ expect(removed).toEqual([]);
1066
+ });
1067
+
1068
+ it("handles mixed valid and invalid log filenames", () => {
1069
+ const dirEntries = ["3000.log", "abc.log", "3001.log", ".3002.log", "3003.log.bak", "3004.log"];
1070
+ const activePorts = new Set([3001]);
1071
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1072
+ // Only 3000.log and 3004.log match the pattern and are not active
1073
+ expect(removed).toEqual(["3000.log", "3004.log"]);
1074
+ });
1075
+
1076
+ it("port 0 is a valid log filename", () => {
1077
+ const dirEntries = ["0.log"];
1078
+ const activePorts = new Set<number>();
1079
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1080
+ expect(removed).toEqual(["0.log"]);
1081
+ });
1082
+
1083
+ it("large port numbers are matched correctly", () => {
1084
+ const dirEntries = ["99999.log"];
1085
+ const activePorts = new Set([99999]);
1086
+ const removed = simulateCleanLogs(dirEntries, activePorts);
1087
+ expect(removed).toEqual([]);
1088
+ });
1089
+ });
1090
+
1091
+ // ---------------------------------------------------------------------------
1092
+ // cleanRegistry filtering logic (offline / data-only)
1093
+ // ---------------------------------------------------------------------------
1094
+
1095
+ describe("cleanRegistry filtering logic", () => {
1096
+ // Tests the alive/dead partitioning logic used by cleanRegistry,
1097
+ // without real process checks.
1098
+
1099
+ function simulateCleanRegistry(
1100
+ servers: ServerEntry[],
1101
+ isAlive: (entry: ServerEntry) => boolean,
1102
+ ): { alive: ServerEntry[]; removed: ServerEntry[] } {
1103
+ const alive: ServerEntry[] = [];
1104
+ const removed: ServerEntry[] = [];
1105
+ for (const entry of servers) {
1106
+ if (isAlive(entry)) {
1107
+ alive.push(entry);
1108
+ } else {
1109
+ removed.push(entry);
1110
+ }
1111
+ }
1112
+ return { alive, removed };
1113
+ }
1114
+
1115
+ function makeEntry(overrides: Partial<ServerEntry> = {}): ServerEntry {
1116
+ return {
1117
+ pid: 100,
1118
+ port: 3000,
1119
+ host: "localhost",
1120
+ contentRoot: "/tmp",
1121
+ startTime: 0,
1122
+ kitflyVersion: "0.1.0",
1123
+ daemonized: false,
1124
+ ...overrides,
1125
+ };
1126
+ }
1127
+
1128
+ it("keeps alive servers, removes dead ones", () => {
1129
+ const servers = [
1130
+ makeEntry({ pid: 100, port: 3000 }),
1131
+ makeEntry({ pid: 200, port: 3001 }),
1132
+ makeEntry({ pid: 300, port: 3002 }),
1133
+ ];
1134
+ const alivePids = new Set([100, 300]);
1135
+ const { alive, removed } = simulateCleanRegistry(servers, (e) => alivePids.has(e.pid));
1136
+
1137
+ expect(alive).toHaveLength(2);
1138
+ expect(alive.map((s) => s.pid)).toEqual([100, 300]);
1139
+ expect(removed).toHaveLength(1);
1140
+ expect(removed[0].pid).toBe(200);
1141
+ });
1142
+
1143
+ it("returns all servers when all are alive", () => {
1144
+ const servers = [makeEntry({ pid: 100 }), makeEntry({ pid: 200 })];
1145
+ const { alive, removed } = simulateCleanRegistry(servers, () => true);
1146
+ expect(alive).toHaveLength(2);
1147
+ expect(removed).toHaveLength(0);
1148
+ });
1149
+
1150
+ it("removes all servers when none are alive", () => {
1151
+ const servers = [makeEntry({ pid: 100 }), makeEntry({ pid: 200 })];
1152
+ const { alive, removed } = simulateCleanRegistry(servers, () => false);
1153
+ expect(alive).toHaveLength(0);
1154
+ expect(removed).toHaveLength(2);
1155
+ });
1156
+
1157
+ it("handles empty server list", () => {
1158
+ const { alive, removed } = simulateCleanRegistry([], () => true);
1159
+ expect(alive).toHaveLength(0);
1160
+ expect(removed).toHaveLength(0);
1161
+ });
1162
+
1163
+ it("pid <= 0 is always considered dead (safety rule)", () => {
1164
+ // isProcessAlive returns false for pid <= 0 without calling process.kill
1165
+ const servers = [
1166
+ makeEntry({ pid: 0, port: 3000 }),
1167
+ makeEntry({ pid: -1, port: 3001 }),
1168
+ makeEntry({ pid: 100, port: 3002 }),
1169
+ ];
1170
+ const { alive, removed } = simulateCleanRegistry(servers, (e) => e.pid > 0);
1171
+ expect(alive).toHaveLength(1);
1172
+ expect(alive[0].pid).toBe(100);
1173
+ expect(removed).toHaveLength(2);
1174
+ });
1175
+ });
1176
+
1177
+ // ---------------------------------------------------------------------------
1178
+ // registerServer deduplication logic
1179
+ // ---------------------------------------------------------------------------
1180
+
1181
+ describe("registerServer deduplication logic", () => {
1182
+ function makeEntry(overrides: Partial<ServerEntry> = {}): ServerEntry {
1183
+ return {
1184
+ pid: 100,
1185
+ port: 3000,
1186
+ host: "localhost",
1187
+ contentRoot: "/tmp",
1188
+ startTime: 0,
1189
+ kitflyVersion: "0.1.0",
1190
+ daemonized: false,
1191
+ ...overrides,
1192
+ };
1193
+ }
1194
+
1195
+ it("adding to empty list produces single-entry list", () => {
1196
+ const existing: ServerEntry[] = [];
1197
+ const newEntry = makeEntry({ pid: 500, port: 4000 });
1198
+ const filtered = existing.filter((s) => s.port !== newEntry.port);
1199
+ filtered.push(newEntry);
1200
+ expect(filtered).toHaveLength(1);
1201
+ expect(filtered[0].pid).toBe(500);
1202
+ });
1203
+
1204
+ it("replaces existing entry for same port", () => {
1205
+ const existing = [makeEntry({ pid: 100, port: 3000, contentRoot: "/old" })];
1206
+ const newEntry = makeEntry({ pid: 200, port: 3000, contentRoot: "/new" });
1207
+ const filtered = existing.filter((s) => s.port !== newEntry.port);
1208
+ filtered.push(newEntry);
1209
+
1210
+ expect(filtered).toHaveLength(1);
1211
+ expect(filtered[0].pid).toBe(200);
1212
+ expect(filtered[0].contentRoot).toBe("/new");
1213
+ });
1214
+
1215
+ it("preserves entries on other ports", () => {
1216
+ const existing = [makeEntry({ pid: 100, port: 3000 }), makeEntry({ pid: 200, port: 3001 })];
1217
+ const newEntry = makeEntry({ pid: 300, port: 3000, contentRoot: "/new" });
1218
+ const filtered = existing.filter((s) => s.port !== newEntry.port);
1219
+ filtered.push(newEntry);
1220
+
1221
+ expect(filtered).toHaveLength(2);
1222
+ expect(filtered[0].port).toBe(3001);
1223
+ expect(filtered[1].port).toBe(3000);
1224
+ expect(filtered[1].pid).toBe(300);
1225
+ });
1226
+
1227
+ it("adding to list with no port conflict appends", () => {
1228
+ const existing = [makeEntry({ pid: 100, port: 3000 }), makeEntry({ pid: 200, port: 3001 })];
1229
+ const newEntry = makeEntry({ pid: 300, port: 4000 });
1230
+ const filtered = existing.filter((s) => s.port !== newEntry.port);
1231
+ filtered.push(newEntry);
1232
+
1233
+ expect(filtered).toHaveLength(3);
1234
+ expect(filtered[2].port).toBe(4000);
1235
+ });
1236
+
1237
+ it("multiple registrations on same port keep only the latest", () => {
1238
+ let servers: ServerEntry[] = [];
1239
+
1240
+ // Register three times on port 3000
1241
+ for (let i = 1; i <= 3; i++) {
1242
+ const entry = makeEntry({ pid: i * 100, port: 3000, contentRoot: `/v${i}` });
1243
+ servers = servers.filter((s) => s.port !== entry.port);
1244
+ servers.push(entry);
1245
+ }
1246
+
1247
+ expect(servers).toHaveLength(1);
1248
+ expect(servers[0].pid).toBe(300);
1249
+ expect(servers[0].contentRoot).toBe("/v3");
1250
+ });
1251
+ });