@blackbelt-technology/pi-agent-dashboard 0.4.4 → 0.4.5
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/AGENTS.md +38 -33
- package/README.md +1 -0
- package/docs/architecture.md +162 -4
- package/package.json +4 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
- package/packages/extension/src/bridge-context.ts +10 -0
- package/packages/extension/src/bridge.ts +22 -0
- package/packages/extension/src/connection.ts +29 -0
- package/packages/extension/src/server-auto-start.ts +16 -0
- package/packages/extension/src/session-sync.ts +14 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
- package/packages/server/src/__tests__/config-api.test.ts +9 -0
- package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
- package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
- package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
- package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
- package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
- package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
- package/packages/server/src/browser-gateway.ts +36 -0
- package/packages/server/src/cli.ts +70 -2
- package/packages/server/src/event-status-extraction.ts +98 -1
- package/packages/server/src/event-wiring.ts +70 -1
- package/packages/server/src/memory-session-manager.ts +34 -3
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/reattach-placement.ts +98 -0
- package/packages/server/src/restart-helper.ts +41 -2
- package/packages/server/src/routes/system-routes.ts +25 -1
- package/packages/server/src/server.ts +55 -3
- package/packages/server/src/session-scanner.ts +19 -0
- package/packages/server/src/viewed-session-tracker.ts +78 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +59 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
- package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
- package/packages/shared/src/__tests__/protocol.test.ts +11 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
- package/packages/shared/src/browser-protocol.ts +25 -0
- package/packages/shared/src/config.ts +41 -0
- package/packages/shared/src/mdns-discovery.ts +32 -1
- package/packages/shared/src/platform/node-spawn.ts +30 -0
- package/packages/shared/src/protocol.ts +30 -1
- package/packages/shared/src/session-meta.ts +6 -0
- package/packages/shared/src/types.ts +19 -0
|
@@ -6,6 +6,7 @@ import type { SessionManager } from "../memory-session-manager.js";
|
|
|
6
6
|
import type { PreferencesStore } from "../preferences-store.js";
|
|
7
7
|
import type { MetaPersistence } from "../meta-persistence.js";
|
|
8
8
|
import type { DirectoryService } from "../directory-service.js";
|
|
9
|
+
import type { PiGateway } from "../pi-gateway.js";
|
|
9
10
|
import type { ServerConfig } from "../server.js";
|
|
10
11
|
import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
11
12
|
import type { NetworkGuard } from "./route-deps.js";
|
|
@@ -31,9 +32,23 @@ export function registerSystemRoutes(
|
|
|
31
32
|
networkGuard: NetworkGuard;
|
|
32
33
|
version?: string;
|
|
33
34
|
directoryService?: DirectoryService;
|
|
35
|
+
piGateway?: PiGateway;
|
|
34
36
|
},
|
|
35
37
|
) {
|
|
36
|
-
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService } = deps;
|
|
38
|
+
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway } = deps;
|
|
39
|
+
|
|
40
|
+
// Quiesce windows for the bridge `server_restarting` broadcast. See change
|
|
41
|
+
// `fix-restart-bridge-auto-start-race`. Bridges that receive this message
|
|
42
|
+
// suppress only the spawn step in `server-auto-start.ts` for `quiesceMs`;
|
|
43
|
+
// discovery + reconnection still run.
|
|
44
|
+
const RESTART_QUIESCE_MS = 5000;
|
|
45
|
+
const SHUTDOWN_QUIESCE_MS = 60000;
|
|
46
|
+
const announceRestart = (reason: "restart" | "shutdown", quiesceMs: number) => {
|
|
47
|
+
if (!piGateway) return;
|
|
48
|
+
try {
|
|
49
|
+
piGateway.broadcast({ type: "server_restarting", reason, quiesceMs });
|
|
50
|
+
} catch { /* best-effort — never block exit on a flaky bridge socket */ }
|
|
51
|
+
};
|
|
37
52
|
const serverStartTime = Date.now();
|
|
38
53
|
|
|
39
54
|
// Editor detection endpoint
|
|
@@ -196,6 +211,10 @@ export function registerSystemRoutes(
|
|
|
196
211
|
async () => {
|
|
197
212
|
metaPersistence.flushAll();
|
|
198
213
|
preferencesStore.flush();
|
|
214
|
+
// Tell every connected bridge that the server is going away deliberately
|
|
215
|
+
// BEFORE we start tearing down state, so bridges suppress auto-start.
|
|
216
|
+
// See change: fix-restart-bridge-auto-start-race.
|
|
217
|
+
announceRestart("shutdown", SHUTDOWN_QUIESCE_MS);
|
|
199
218
|
// Tear down the zrok tunnel (and sweep orphans on our port) so restarts
|
|
200
219
|
// don't leak reservations that leave stale URLs backed by nothing.
|
|
201
220
|
try { await deleteTunnel(config.port); } catch { /* best-effort */ }
|
|
@@ -212,6 +231,11 @@ export function registerSystemRoutes(
|
|
|
212
231
|
metaPersistence.flushAll();
|
|
213
232
|
preferencesStore.flush();
|
|
214
233
|
|
|
234
|
+
// Announce restart to every bridge BEFORE spawning the replacement so
|
|
235
|
+
// bridges suppress their auto-start spawn step and don't race the
|
|
236
|
+
// orchestrator. See change: fix-restart-bridge-auto-start-race.
|
|
237
|
+
announceRestart("restart", RESTART_QUIESCE_MS);
|
|
238
|
+
|
|
215
239
|
// Tear down tunnel before spawning the replacement process so the new
|
|
216
240
|
// server doesn't race an orphan zrok agent on the same port.
|
|
217
241
|
try { await deleteTunnel(config.port); } catch { /* best-effort */ }
|
|
@@ -20,6 +20,7 @@ import { createSessionOrderManager, type SessionOrderManager } from "./session-o
|
|
|
20
20
|
import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js";
|
|
21
21
|
import { createPendingAttachRegistry } from "./pending-attach-registry.js";
|
|
22
22
|
import { createPendingResumeIntentRegistry } from "./pending-resume-intent-registry.js";
|
|
23
|
+
import { applyReattachPolicy } from "./reattach-placement.js";
|
|
23
24
|
|
|
24
25
|
// pending-load-manager removed — server loads sessions directly via DirectoryService
|
|
25
26
|
import { createDirectoryService, type DirectoryService } from "./directory-service.js";
|
|
@@ -282,7 +283,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
// Save per-session .meta.json on any change
|
|
285
|
-
sessionManager.onChange = (sessionId: string) => {
|
|
286
|
+
sessionManager.onChange = (sessionId: string, ctx) => {
|
|
286
287
|
const session = sessionManager.get(sessionId);
|
|
287
288
|
if (!session?.sessionFile) return;
|
|
288
289
|
metaPersistence.save(session.sessionFile, {
|
|
@@ -304,6 +305,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
304
305
|
contextTokens: session.contextTokens ?? undefined,
|
|
305
306
|
contextWindow: session.contextWindow,
|
|
306
307
|
firstMessage: session.firstMessage,
|
|
308
|
+
// Persist unread bit so it survives server restart.
|
|
309
|
+
// See change: session-card-unread-stripes.
|
|
310
|
+
unread: session.unread,
|
|
307
311
|
cachedAt: Date.now(),
|
|
308
312
|
});
|
|
309
313
|
// When a session ends, drop its id from the persisted drag-reorder list
|
|
@@ -352,17 +356,33 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
352
356
|
endedSessionIds.delete(sessionId);
|
|
353
357
|
const intent = pendingResumeIntents.consume(sessionId);
|
|
354
358
|
if (intent === null) {
|
|
355
|
-
//
|
|
359
|
+
// No user-driven resume intent. If this register carried
|
|
360
|
+
// `registerReason: "reattach"`, apply the configured
|
|
361
|
+
// `reattachPlacement` policy. Otherwise (legacy bridge or
|
|
362
|
+
// genuine null reattach with `"preserve"` semantics) leave
|
|
363
|
+
// order alone.
|
|
364
|
+
// See change: reattach-move-to-front.
|
|
365
|
+
if (ctx?.registerReason === "reattach") {
|
|
366
|
+
applyReattachPolicy(
|
|
367
|
+
sessionId,
|
|
368
|
+
session.cwd,
|
|
369
|
+
config.reattachPlacement,
|
|
370
|
+
{ sessionManager, sessionOrderManager, browserGateway },
|
|
371
|
+
ctx.priorStatus,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
356
374
|
return;
|
|
357
375
|
}
|
|
358
376
|
if (intent === "keep") {
|
|
359
377
|
// Drag-to-resume — dropped slot wins; the earlier reorder_sessions
|
|
360
378
|
// already broadcast. Do NOT mutate sessionOrder, do NOT broadcast.
|
|
379
|
+
// Registry intent overrides any `registerReason: "reattach"`.
|
|
361
380
|
return;
|
|
362
381
|
}
|
|
363
382
|
// intent === "front": move-to-front so the just-resumed card
|
|
364
383
|
// surfaces at the top of the alive tier, even on repeated end →
|
|
365
384
|
// resume cycles where the id might still be in the order.
|
|
385
|
+
// Registry intent overrides any `registerReason: "reattach"`.
|
|
366
386
|
sessionOrderManager.moveToFront(session.cwd, sessionId);
|
|
367
387
|
const next = sessionOrderManager.getOrder(session.cwd) ?? [];
|
|
368
388
|
browserGateway.broadcastToAll({
|
|
@@ -370,6 +390,37 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
370
390
|
cwd: session.cwd,
|
|
371
391
|
sessionIds: next,
|
|
372
392
|
});
|
|
393
|
+
} else if (!isEnded && !wasEnded && ctx?.registerReason === "reattach") {
|
|
394
|
+
// Reattach of a session that was persisted as alive (the common
|
|
395
|
+
// case after `pi-dashboard restart` while pi processes stay
|
|
396
|
+
// alive). Neither alive→ended nor ended→alive transition fires;
|
|
397
|
+
// we apply the reattach policy directly here.
|
|
398
|
+
//
|
|
399
|
+
// Defensive: a registry intent for an alive session should not
|
|
400
|
+
// happen in practice (handleResumeSession only tags intents for
|
|
401
|
+
// ended sessions), but per spec scenario "Registry intent wins
|
|
402
|
+
// over reattach" we honor it if present and skip the policy.
|
|
403
|
+
// See change: reattach-move-to-front.
|
|
404
|
+
const intent = pendingResumeIntents.consume(sessionId);
|
|
405
|
+
if (intent === "front") {
|
|
406
|
+
sessionOrderManager.moveToFront(session.cwd, sessionId);
|
|
407
|
+
const next = sessionOrderManager.getOrder(session.cwd) ?? [];
|
|
408
|
+
browserGateway.broadcastToAll({
|
|
409
|
+
type: "sessions_reordered",
|
|
410
|
+
cwd: session.cwd,
|
|
411
|
+
sessionIds: next,
|
|
412
|
+
});
|
|
413
|
+
} else if (intent === "keep") {
|
|
414
|
+
// Honor dropped slot; do nothing.
|
|
415
|
+
} else {
|
|
416
|
+
applyReattachPolicy(
|
|
417
|
+
sessionId,
|
|
418
|
+
session.cwd,
|
|
419
|
+
config.reattachPlacement,
|
|
420
|
+
{ sessionManager, sessionOrderManager, browserGateway },
|
|
421
|
+
ctx.priorStatus,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
373
424
|
}
|
|
374
425
|
};
|
|
375
426
|
// Track which session ids we've seen as ended at least once, so the
|
|
@@ -503,6 +554,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
503
554
|
knownSessionIds,
|
|
504
555
|
pendingDashboardSpawns,
|
|
505
556
|
pendingAttachRegistry,
|
|
557
|
+
viewedSessionTracker: browserGateway.viewedSessionTracker,
|
|
506
558
|
});
|
|
507
559
|
|
|
508
560
|
// Auto-shutdown idle timer
|
|
@@ -652,7 +704,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
652
704
|
if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
653
705
|
},
|
|
654
706
|
});
|
|
655
|
-
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService });
|
|
707
|
+
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
|
|
656
708
|
registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
|
|
657
709
|
|
|
658
710
|
// ── Bootstrap REST routes ────────────────────────────────────────
|
|
@@ -38,6 +38,19 @@ function extractTimestamp(filename: string): number {
|
|
|
38
38
|
return isNaN(ts) ? Date.now() : ts;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Read the events.jsonl mtime as a cold-start seed for `lastActivityAt`.
|
|
43
|
+
* Returns `undefined` on stat failure — callers fall back to `startedAt` at
|
|
44
|
+
* render time. See change: session-card-last-activity-badge.
|
|
45
|
+
*/
|
|
46
|
+
function readJsonlMtime(sessionFile: string): number | undefined {
|
|
47
|
+
try {
|
|
48
|
+
return statSync(sessionFile).mtimeMs;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/** Build a DashboardSession from cached `.meta.json` data */
|
|
42
55
|
function sessionFromMeta(
|
|
43
56
|
sessionId: string,
|
|
@@ -56,6 +69,9 @@ function sessionFromMeta(
|
|
|
56
69
|
thinkingLevel: meta.thinkingLevel,
|
|
57
70
|
startedAt: meta.startedAt ?? startedAt,
|
|
58
71
|
endedAt: meta.endedAt,
|
|
72
|
+
// Seed last-activity from events.jsonl mtime so the session-card relative-time
|
|
73
|
+
// badge survives server restarts. See change: session-card-last-activity-badge.
|
|
74
|
+
lastActivityAt: readJsonlMtime(sessionFile),
|
|
59
75
|
tokensIn: meta.tokensIn ?? 0,
|
|
60
76
|
tokensOut: meta.tokensOut ?? 0,
|
|
61
77
|
cacheRead: meta.cacheRead,
|
|
@@ -68,6 +84,9 @@ function sessionFromMeta(
|
|
|
68
84
|
hidden: meta.hidden ?? false,
|
|
69
85
|
firstMessage: meta.firstMessage,
|
|
70
86
|
attachedProposal: meta.attachedProposal,
|
|
87
|
+
// Restore unread bit from .meta.json so it survives server restart.
|
|
88
|
+
// See change: session-card-unread-stripes.
|
|
89
|
+
unread: meta.unread,
|
|
71
90
|
dataUnavailable: true,
|
|
72
91
|
};
|
|
73
92
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewed-session tracker.
|
|
3
|
+
*
|
|
4
|
+
* Per-session set of WebSocket connections that are currently displaying
|
|
5
|
+
* the session's chat panel (typically the browser is on `/session/:id`).
|
|
6
|
+
* Used by:
|
|
7
|
+
* - `event-wiring.ts` to gate `isUnreadTrigger`-driven `unread = true`
|
|
8
|
+
* stamps so a session being actively watched never becomes unread.
|
|
9
|
+
* - the `session_view` handler in `browser-gateway.ts` to clear unread
|
|
10
|
+
* when any browser opens an unread session.
|
|
11
|
+
*
|
|
12
|
+
* Read state is GLOBAL across browsers: a session is "viewed" iff at
|
|
13
|
+
* least one connected client has it open. This mirrors mail/Slack:
|
|
14
|
+
* opening on phone clears unread for the laptop too.
|
|
15
|
+
*
|
|
16
|
+
* In-memory only — view state is intrinsically per-connection and has
|
|
17
|
+
* no value to persist.
|
|
18
|
+
*
|
|
19
|
+
* See change: session-card-unread-stripes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { WebSocket } from "ws";
|
|
23
|
+
|
|
24
|
+
export interface ViewedSessionTracker {
|
|
25
|
+
/** Mark `ws` as viewing `sessionId`. Idempotent. */
|
|
26
|
+
view(sessionId: string, ws: WebSocket): void;
|
|
27
|
+
/** Mark `ws` as no longer viewing `sessionId`. Idempotent. */
|
|
28
|
+
unview(sessionId: string, ws: WebSocket): void;
|
|
29
|
+
/**
|
|
30
|
+
* Remove `ws` from every viewed session. Called from the WebSocket
|
|
31
|
+
* `close` handler so a disconnected browser cannot hold sessions in
|
|
32
|
+
* the viewed state forever.
|
|
33
|
+
*/
|
|
34
|
+
unviewAll(ws: WebSocket): void;
|
|
35
|
+
/** Returns true if at least one connected browser views the session. */
|
|
36
|
+
isViewedByAnyone(sessionId: string): boolean;
|
|
37
|
+
/** Test/diagnostic accessor — number of viewers for a session. */
|
|
38
|
+
viewerCount(sessionId: string): number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createViewedSessionTracker(): ViewedSessionTracker {
|
|
42
|
+
const viewers = new Map<string, Set<WebSocket>>();
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
view(sessionId: string, ws: WebSocket): void {
|
|
46
|
+
let set = viewers.get(sessionId);
|
|
47
|
+
if (!set) {
|
|
48
|
+
set = new Set<WebSocket>();
|
|
49
|
+
viewers.set(sessionId, set);
|
|
50
|
+
}
|
|
51
|
+
set.add(ws);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
unview(sessionId: string, ws: WebSocket): void {
|
|
55
|
+
const set = viewers.get(sessionId);
|
|
56
|
+
if (!set) return;
|
|
57
|
+
set.delete(ws);
|
|
58
|
+
if (set.size === 0) viewers.delete(sessionId);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
unviewAll(ws: WebSocket): void {
|
|
62
|
+
for (const [sessionId, set] of viewers) {
|
|
63
|
+
if (set.delete(ws) && set.size === 0) {
|
|
64
|
+
viewers.delete(sessionId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
isViewedByAnyone(sessionId: string): boolean {
|
|
70
|
+
const set = viewers.get(sessionId);
|
|
71
|
+
return !!set && set.size > 0;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
viewerCount(sessionId: string): number {
|
|
75
|
+
return viewers.get(sessionId)?.size ?? 0;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -412,3 +412,62 @@ describe("ensureConfig", () => {
|
|
|
412
412
|
expect(content.piPort).toBeUndefined();
|
|
413
413
|
});
|
|
414
414
|
});
|
|
415
|
+
|
|
416
|
+
describe("loadConfig reattachPlacement", () => {
|
|
417
|
+
let testDir: string;
|
|
418
|
+
let configFile: string;
|
|
419
|
+
let origHome: string;
|
|
420
|
+
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
testDir = path.join(os.tmpdir(), `test-reattach-${Date.now()}-${Math.random()}`);
|
|
423
|
+
fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
|
|
424
|
+
configFile = path.join(testDir, ".pi", "dashboard", "config.json");
|
|
425
|
+
origHome = process.env.HOME!;
|
|
426
|
+
process.env.HOME = testDir;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
afterEach(() => {
|
|
430
|
+
process.env.HOME = origHome;
|
|
431
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("defaults to 'always' when missing", () => {
|
|
435
|
+
fs.writeFileSync(configFile, JSON.stringify({}));
|
|
436
|
+
expect(loadConfig().reattachPlacement).toBe("always");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("defaults to 'always' when config file does not exist", () => {
|
|
440
|
+
expect(loadConfig().reattachPlacement).toBe("always");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("accepts 'preserve'", () => {
|
|
444
|
+
fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "preserve" }));
|
|
445
|
+
expect(loadConfig().reattachPlacement).toBe("preserve");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("accepts 'streaming-only'", () => {
|
|
449
|
+
fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "streaming-only" }));
|
|
450
|
+
expect(loadConfig().reattachPlacement).toBe("streaming-only");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("accepts 'always' explicitly", () => {
|
|
454
|
+
fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "always" }));
|
|
455
|
+
expect(loadConfig().reattachPlacement).toBe("always");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("falls back to 'always' on invalid string", () => {
|
|
459
|
+
fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "wibble" }));
|
|
460
|
+
expect(loadConfig().reattachPlacement).toBe("always");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("falls back to 'always' on non-string", () => {
|
|
464
|
+
fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: 42 }));
|
|
465
|
+
expect(loadConfig().reattachPlacement).toBe("always");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("ensureConfig does NOT write reattachPlacement to defaults", () => {
|
|
469
|
+
ensureConfig();
|
|
470
|
+
const content = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
471
|
+
expect(content.reattachPlacement).toBeUndefined();
|
|
472
|
+
});
|
|
473
|
+
});
|
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import os from "node:os";
|
|
3
|
-
import { isLocalService, type DiscoveredServer } from "../mdns-discovery.js";
|
|
3
|
+
import { isLocalService, pickBestHost, type DiscoveredServer } from "../mdns-discovery.js";
|
|
4
|
+
|
|
5
|
+
describe("pickBestHost", () => {
|
|
6
|
+
it("keeps a DNS-safe hostname unchanged", () => {
|
|
7
|
+
const service = { host: "my-mac.local", addresses: ["192.168.1.5"] } as any;
|
|
8
|
+
expect(pickBestHost(service)).toBe("my-mac.local");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("keeps a bare DNS-safe hostname unchanged", () => {
|
|
12
|
+
const service = { host: "macbook", addresses: ["192.168.1.5"] } as any;
|
|
13
|
+
expect(pickBestHost(service)).toBe("macbook");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("falls back to IPv4 address when host has a space", () => {
|
|
17
|
+
const service = { host: "MacBook 242", addresses: ["192.168.16.242", "fe80::1"] } as any;
|
|
18
|
+
expect(pickBestHost(service)).toBe("192.168.16.242");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to IPv4 when host has invalid characters", () => {
|
|
22
|
+
const service = { host: "my_mac@home", addresses: ["10.0.0.5"] } as any;
|
|
23
|
+
expect(pickBestHost(service)).toBe("10.0.0.5");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("prefers IPv4 over IPv6 when both are present", () => {
|
|
27
|
+
const service = { host: "bad host", addresses: ["fe80::1", "192.168.1.7"] } as any;
|
|
28
|
+
expect(pickBestHost(service)).toBe("192.168.1.7");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to first address when only IPv6 is available", () => {
|
|
32
|
+
const service = { host: "bad host", addresses: ["fe80::1"] } as any;
|
|
33
|
+
expect(pickBestHost(service)).toBe("fe80::1");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns the original host when no addresses available (best-effort)", () => {
|
|
37
|
+
const service = { host: "bad host", addresses: [] } as any;
|
|
38
|
+
expect(pickBestHost(service)).toBe("bad host");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns 'unknown' when host is missing and no addresses", () => {
|
|
42
|
+
const service = { host: undefined, addresses: [] } as any;
|
|
43
|
+
expect(pickBestHost(service)).toBe("unknown");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects host starting with hyphen", () => {
|
|
47
|
+
const service = { host: "-bad", addresses: ["10.0.0.1"] } as any;
|
|
48
|
+
expect(pickBestHost(service)).toBe("10.0.0.1");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
4
51
|
|
|
5
52
|
// Test isLocalService with mock Service objects
|
|
6
53
|
describe("isLocalService", () => {
|