@danypops/pi-packed 0.21.3 → 0.21.4

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.
@@ -68,6 +68,7 @@ export class FindTab implements Component {
68
68
  private busy = false;
69
69
  private queryActive = true;
70
70
  private readonly maxVisible = 4;
71
+ private readonly idleWaiters = new Set<() => void>();
71
72
 
72
73
  constructor(
73
74
  private readonly natives: Natives,
@@ -99,6 +100,17 @@ export class FindTab implements Component {
99
100
 
100
101
  invalidate(): void {}
101
102
 
103
+ whenIdle(): Promise<void> {
104
+ if (!this.searching && !this.busy) return Promise.resolve();
105
+ return new Promise((resolve) => this.idleWaiters.add(resolve));
106
+ }
107
+
108
+ private signalIdle(): void {
109
+ if (this.searching || this.busy) return;
110
+ for (const resolve of this.idleWaiters) resolve();
111
+ this.idleWaiters.clear();
112
+ }
113
+
102
114
  render(width: number): string[] {
103
115
  const theme = this._theme;
104
116
  const hint = this.queryActive
@@ -177,6 +189,7 @@ export class FindTab implements Component {
177
189
  } finally {
178
190
  this.searching = false;
179
191
  this.host.requestRender();
192
+ this.signalIdle();
180
193
  }
181
194
  }
182
195
 
@@ -196,6 +209,7 @@ export class FindTab implements Component {
196
209
  } finally {
197
210
  this.busy = false;
198
211
  this.host.requestRender();
212
+ this.signalIdle();
199
213
  }
200
214
  }
201
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.21.3",
3
+ "version": "0.21.4",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "typecheck": "bunx tsc --noEmit"
28
28
  },
29
29
  "dependencies": {
30
- "@danypops/armada": "^0.2.0",
30
+ "@danypops/armada": "^0.3.1",
31
31
  "@danypops/packed": "^0.6.0",
32
32
  "@danypops/pi-extension-harness": "^0.2.0",
33
33
  "@danypops/vehicle-client": "^0.5.1",
@@ -100,17 +100,6 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
100
100
  ];
101
101
  const maintenanceTasks = configuredMaintenanceTasks.filter((task) => task.intervalMs > 0);
102
102
 
103
- if (options.maintenanceTasks === undefined) {
104
- for (const task of maintenanceTasks) {
105
- // Armada reconciles the whole desired fleet. Running this before Packed
106
- // publishes its readiness handle makes Armada replace Packed mid-startup.
107
- if (task.name === "vehicle-reconcile") continue;
108
- void Promise.resolve(task.run()).catch((error) =>
109
- logger.error(`maintenance task failed: ${task.name}`, { error: error instanceof Error ? error.message : String(error) }),
110
- );
111
- }
112
- }
113
-
114
103
  return {
115
104
  daemonLabel: "Packed",
116
105
  handlePath: paths.handle,
@@ -123,18 +112,18 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
123
112
  };
124
113
  }
125
114
 
126
- function runReadyVehicleReconcile(maintenanceTasks: MaintenanceTask[] | undefined): void {
127
- const task = maintenanceTasks?.find((candidate) => candidate.name === "vehicle-reconcile");
128
- if (!task) return;
129
- void Promise.resolve(task.run()).catch((error) =>
130
- logger.error(`maintenance task failed: ${task.name}`, { error: error instanceof Error ? error.message : String(error) }),
131
- );
115
+ function runInitialMaintenance(maintenanceTasks: MaintenanceTask[] | undefined): void {
116
+ for (const task of maintenanceTasks ?? []) {
117
+ void Promise.resolve(task.run()).catch((error) =>
118
+ logger.error(`maintenance task failed: ${task.name}`, { error: error instanceof Error ? error.message : String(error) }),
119
+ );
120
+ }
132
121
  }
133
122
 
134
123
  export async function startPackedDaemon(options: StartPackedDaemonOptions = {}): Promise<RunningDaemon> {
135
124
  const configured = daemonOptions(options);
136
125
  const running = await startDaemon(configured);
137
- runReadyVehicleReconcile(configured.maintenanceTasks);
126
+ runInitialMaintenance(configured.maintenanceTasks);
138
127
  return running;
139
128
  }
140
129
 
@@ -144,7 +133,7 @@ export function serveMain(): void {
144
133
  ...configured,
145
134
  onListen: ({ host, port }) => {
146
135
  logger.info("listening", { host, port });
147
- runReadyVehicleReconcile(configured.maintenanceTasks);
136
+ runInitialMaintenance(configured.maintenanceTasks);
148
137
  },
149
138
  });
150
139
  }
@@ -68,6 +68,7 @@ export function checkUpdates(latestOf: (name: string) => string | undefined, ins
68
68
  export interface WatcherOptions {
69
69
  intervalMs: number;
70
70
  onError?: (e: unknown) => void;
71
+ onTick?: (snapshot: UpdatesSnapshot) => void;
71
72
  }
72
73
 
73
74
  /** Producer loop: immediate check, then on a timer. Returns a stop function. */
@@ -80,7 +81,9 @@ export function startWatcher(
80
81
  async function check(): Promise<void> {
81
82
  try {
82
83
  const updates = checkUpdates(latestOf, readInstalled());
83
- await saveUpdates(stateDir, { checkedAt: new Date().toISOString(), updates });
84
+ const snapshot = { checkedAt: new Date().toISOString(), updates };
85
+ await saveUpdates(stateDir, snapshot);
86
+ opts.onTick?.(snapshot);
84
87
  log.info("updates check", { updates: updates.length });
85
88
  } catch (e) {
86
89
  opts.onError?.(e);
@@ -3,12 +3,15 @@ import { CACHE_TTL_MS } from "./constants.ts";
3
3
 
4
4
  export class TTLCache {
5
5
  private m = new Map<string, { body: string; expires: number }>();
6
- constructor(private ttlMs = CACHE_TTL_MS) {}
6
+ constructor(
7
+ private ttlMs = CACHE_TTL_MS,
8
+ private readonly now: () => number = Date.now,
9
+ ) {}
7
10
 
8
11
  get(key: string): string | undefined {
9
12
  const e = this.m.get(key);
10
13
  if (!e) return undefined;
11
- if (Date.now() > e.expires) {
14
+ if (this.now() > e.expires) {
12
15
  this.m.delete(key);
13
16
  return undefined;
14
17
  }
@@ -16,6 +19,6 @@ export class TTLCache {
16
19
  }
17
20
 
18
21
  set(key: string, body: string): void {
19
- this.m.set(key, { body, expires: Date.now() + this.ttlMs });
22
+ this.m.set(key, { body, expires: this.now() + this.ttlMs });
20
23
  }
21
24
  }
@@ -23,11 +23,12 @@ describe("clampLimit", () => {
23
23
  });
24
24
 
25
25
  describe("TTLCache", () => {
26
- it("stores and expires", async () => {
27
- const c = new TTLCache(20); // ms
26
+ it("stores and expires against an injected clock", () => {
27
+ let now = 1_000;
28
+ const c = new TTLCache(20, () => now);
28
29
  c.set("k", "v");
29
30
  expect(c.get("k")).toBe("v");
30
- await Bun.sleep(30);
31
+ now += 21;
31
32
  expect(c.get("k")).toBeUndefined();
32
33
  });
33
34
  });
@@ -2,8 +2,15 @@ import { afterEach, describe, expect, it } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { createArmadaTestHarness } from "@danypops/armada/testing";
6
+ import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
5
7
  import type { ServiceInstallResult, ServiceSpec } from "@danypops/vehicle-server/service";
6
8
  import { daemonOptions, startPackedDaemon } from "../src/daemon/daemon.ts";
9
+ import {
10
+ type DaemonServiceInstaller,
11
+ RealDaemonServiceInstaller,
12
+ reconcileAllDaemonServices,
13
+ } from "../src/daemon/daemon-service.ts";
7
14
  import type { Installer, Registry } from "../src/packages/package.ts";
8
15
  import { RECONCILE_INTERVAL_DEFAULT_MS } from "../src/shared/constants.ts";
9
16
  import { resolvePackedPaths } from "../src/shared/paths.ts";
@@ -26,6 +33,30 @@ function fakePaths() {
26
33
  return resolvePackedPaths({ env: { PI_PACKED_HOME: root } });
27
34
  }
28
35
 
36
+ function vehicleReconcileTask(piHome: string, daemonServiceInstaller: DaemonServiceInstaller): MaintenanceTask {
37
+ return {
38
+ name: "vehicle-reconcile",
39
+ intervalMs: RECONCILE_INTERVAL_DEFAULT_MS,
40
+ run: async () => {
41
+ await reconcileAllDaemonServices(piHome, undefined, daemonServiceInstaller);
42
+ },
43
+ };
44
+ }
45
+
46
+ function installMockDaemon(piHome: string, name: string): void {
47
+ const directory = join(piHome, "npm", "node_modules", name);
48
+ mkdirSync(directory, { recursive: true });
49
+ writeFileSync(
50
+ join(directory, "package.json"),
51
+ JSON.stringify({
52
+ name,
53
+ version: "1.0.0",
54
+ packed: { daemonService: { binPath: "cli.ts", args: ["serve"] } },
55
+ }),
56
+ );
57
+ writeFileSync(join(directory, "cli.ts"), "// Mock Vehicle entry point; Armada's test controller never executes it.\n");
58
+ }
59
+
29
60
  const registry: Registry = {
30
61
  async search() {
31
62
  return { total: 0, results: [] };
@@ -101,40 +132,89 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
101
132
  const task = options.maintenanceTasks?.find((t) => t.name === "vehicle-reconcile");
102
133
  expect(task).toBeDefined();
103
134
 
104
- // daemonOptions() also fires every default task once at startup (see the dedicated
105
- // startup-self-heal test below) -- let that finish, then isolate this test's own
106
- // explicit invocation from it rather than racing the two.
107
- await new Promise((resolveTick) => setTimeout(resolveTick, 20));
108
- installerSpy.gotSources = [];
109
135
  await task?.run();
110
136
 
111
137
  expect(installerSpy.gotSources).toEqual(["npm:@danypops/probe", "npm:@danypops/other"]);
112
138
  });
113
139
 
140
+ it("runs initial maintenance only after publishing the daemon handle", async () => {
141
+ const piHome = fakePiHome([]);
142
+ const paths = fakePaths();
143
+ let resolveRun: (() => void) | undefined;
144
+ const ran = new Promise<void>((resolve) => {
145
+ resolveRun = resolve;
146
+ });
147
+ const task: MaintenanceTask = {
148
+ name: "probe-initial-maintenance",
149
+ intervalMs: RECONCILE_INTERVAL_DEFAULT_MS,
150
+ run: () => {
151
+ expect(existsSync(paths.handle)).toBe(true);
152
+ resolveRun?.();
153
+ },
154
+ };
155
+
156
+ const running = await startPackedDaemon({ paths, reg: registry, inst: installer, piHome, maintenanceTasks: [task] });
157
+ try {
158
+ await Promise.race([
159
+ ran,
160
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("initial maintenance did not run")), 250)),
161
+ ]);
162
+ } finally {
163
+ await running.stop();
164
+ }
165
+ });
166
+
114
167
  it("does not reconcile Armada before the daemon has published its readiness handle", async () => {
115
168
  const piHome = fakePiHome(["npm:@danypops/pi-packed", "npm:@danypops/probe"]);
116
169
  const paths = fakePaths();
117
- const installerSpy = new RecordingDaemonServiceInstaller();
118
- mkdirSync(join(piHome, "npm", "node_modules"), { recursive: true });
119
- daemonOptions({ paths, reg: registry, inst: installer, piHome, daemonServiceInstaller: installerSpy });
120
-
121
- // Give pre-listen maintenance enough time to expose an accidental Armada call.
122
- await new Promise((resolveTick) => setTimeout(resolveTick, 10));
170
+ installMockDaemon(piHome, "@danypops/pi-packed");
171
+ installMockDaemon(piHome, "@danypops/probe");
172
+ const harness = await createArmadaTestHarness();
173
+ try {
174
+ const serviceInstaller = new RealDaemonServiceInstaller(harness.registrar);
175
+ daemonOptions({
176
+ paths,
177
+ reg: registry,
178
+ inst: installer,
179
+ piHome,
180
+ daemonServiceInstaller: serviceInstaller,
181
+ maintenanceTasks: [vehicleReconcileTask(piHome, serviceInstaller)],
182
+ });
123
183
 
124
- expect(installerSpy.gotSources).toEqual([]);
184
+ expect(existsSync(paths.handle)).toBe(false);
185
+ expect(harness.events()).toEqual([]);
186
+ } finally {
187
+ await harness.dispose();
188
+ }
125
189
  });
126
190
 
127
191
  it("reconciles Armada only after the real daemon handle is ready", async () => {
128
192
  const piHome = fakePiHome(["npm:@danypops/pi-packed", "npm:@danypops/probe"]);
129
193
  const paths = fakePaths();
130
- const installerSpy = new RecordingDaemonServiceInstaller();
131
- const running = await startPackedDaemon({ paths, reg: registry, inst: installer, piHome, daemonServiceInstaller: installerSpy });
194
+ installMockDaemon(piHome, "@danypops/pi-packed");
195
+ installMockDaemon(piHome, "@danypops/probe");
196
+ const harness = await createArmadaTestHarness();
197
+ const serviceInstaller = new RealDaemonServiceInstaller(harness.registrar);
198
+ const running = await startPackedDaemon({
199
+ paths,
200
+ reg: registry,
201
+ inst: installer,
202
+ piHome,
203
+ daemonServiceInstaller: serviceInstaller,
204
+ maintenanceTasks: [vehicleReconcileTask(piHome, serviceInstaller)],
205
+ });
132
206
  try {
133
- await new Promise((resolveTick) => setTimeout(resolveTick, 10));
207
+ await harness.waitForEvent("ready:probe");
134
208
  expect(existsSync(paths.handle)).toBe(true);
135
- expect(installerSpy.gotSources).toEqual(["npm:@danypops/probe"]);
209
+ expect(harness.events()).toEqual([
210
+ "replace:armada-probe.service",
211
+ "start:armada-probe.service",
212
+ "ready:probe",
213
+ ]);
214
+ expect(await harness.registrar.isRegistered("pi-packed")).toBe(false);
136
215
  } finally {
137
216
  await running.stop();
217
+ await harness.dispose();
138
218
  }
139
219
  });
140
220
  });
@@ -1,12 +1,20 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import type { VehicleRegistrar, VehicleRegistrationOutcome } from "@danypops/armada";
5
+ import type { VehicleRegistrar } from "@danypops/armada";
6
+ import { createArmadaTestHarness } from "@danypops/armada/testing";
6
7
  import { detectVehicleDaemonService, RealDaemonServiceInstaller, resolveDaemonServiceSpec } from "../src/daemon/daemon-service.ts";
7
8
 
9
+ const roots: string[] = [];
10
+ afterEach(() => {
11
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
12
+ });
13
+
8
14
  function fakePiHome(): string {
9
- return mkdtempSync(join(tmpdir(), "packed-daemon-service-"));
15
+ const root = mkdtempSync(join(tmpdir(), "packed-daemon-service-"));
16
+ roots.push(root);
17
+ return root;
10
18
  }
11
19
 
12
20
  function writePackage(piHome: string, name: string, manifest: unknown): void {
@@ -200,100 +208,128 @@ describe("resolveDaemonServiceSpec", () => {
200
208
  });
201
209
  });
202
210
 
203
- class FakeVehicleRegistrar implements VehicleRegistrar {
204
- registered = new Map<string, unknown>();
205
- outcome: VehicleRegistrationOutcome = { ok: true, manifestHash: "hash" as never, applied: [], diagnostics: [] };
206
- async register(vehicle: unknown): Promise<VehicleRegistrationOutcome> {
207
- if (this.outcome.ok) this.registered.set((vehicle as { name: string }).name, vehicle);
208
- return this.outcome;
209
- }
210
- async unregister(name: string): Promise<VehicleRegistrationOutcome> {
211
- if (this.outcome.ok) this.registered.delete(name);
212
- return this.outcome;
213
- }
214
- async isRegistered(name: string): Promise<boolean> {
215
- return this.registered.has(name);
216
- }
217
- }
218
-
219
211
  describe("RealDaemonServiceInstaller -- Armada registration through the in-process registrar", () => {
220
- it("install() resolves the package's Vehicle spec and registers it through Armada directly", async () => {
212
+ it("install() resolves the package and drives Armada's real registrar/reconciler through the isolated harness", async () => {
221
213
  const piHome = fakePiHome();
222
214
  writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
223
- const registrar = new FakeVehicleRegistrar();
224
- const installer = new RealDaemonServiceInstaller(registrar);
225
-
226
- const outcome = await installer.install(piHome, "npm:@danypops/web-spider-daemon");
227
-
228
- expect(outcome).toMatchObject({ ok: true, result: { installed: true }, spec: { name: "web-spider-daemon" } });
229
- expect(registrar.registered.has("web-spider-daemon")).toBe(true);
215
+ const harness = await createArmadaTestHarness();
216
+ try {
217
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
218
+ const outcome = await installer.install(piHome, "npm:@danypops/web-spider-daemon");
219
+
220
+ expect(outcome).toMatchObject({ ok: true, result: { installed: true }, spec: { name: "web-spider-daemon" } });
221
+ expect(harness.application("web-spider-daemon").state()).toBe("ready");
222
+ expect(harness.events()).toEqual([
223
+ "replace:armada-web-spider-daemon.service",
224
+ "start:armada-web-spider-daemon.service",
225
+ "ready:web-spider-daemon",
226
+ ]);
227
+ } finally {
228
+ await harness.dispose();
229
+ }
230
230
  });
231
231
 
232
232
  it("install() never asks Armada to replace Packed from inside Packed's own process", async () => {
233
233
  const piHome = fakePiHome();
234
234
  writePackage(piHome, "@danypops/pi-packed", { binPath: "service/src/cli/cli.ts", args: ["serve"] });
235
- const registrar = new FakeVehicleRegistrar();
236
- const installer = new RealDaemonServiceInstaller(registrar);
237
-
238
- const outcome = await installer.install(piHome, "npm:@danypops/pi-packed");
239
-
240
- expect(outcome).toMatchObject({ ok: true, result: { installed: false }, spec: { name: "pi-packed" } });
241
- expect(registrar.registered.size).toBe(0);
235
+ const harness = await createArmadaTestHarness();
236
+ try {
237
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
238
+ const outcome = await installer.install(piHome, "npm:@danypops/pi-packed");
239
+
240
+ expect(outcome).toMatchObject({ ok: true, result: { installed: false }, spec: { name: "pi-packed" } });
241
+ expect(harness.events()).toEqual([]);
242
+ expect(await harness.registrar.isRegistered("pi-packed")).toBe(false);
243
+ } finally {
244
+ await harness.dispose();
245
+ }
242
246
  });
243
247
 
244
- it("install() reports a non-daemon package without ever touching the registrar", async () => {
248
+ it("install() reports a non-daemon package without touching Armada", async () => {
245
249
  const piHome = fakePiHome();
246
250
  writePackage(piHome, "some-pkg", undefined);
247
- const registrar = new FakeVehicleRegistrar();
248
- const installer = new RealDaemonServiceInstaller(registrar);
249
-
250
- const outcome = await installer.install(piHome, "npm:some-pkg");
251
-
252
- expect(outcome).toMatchObject({ ok: false, notADaemon: true });
253
- expect(registrar.registered.size).toBe(0);
251
+ const harness = await createArmadaTestHarness();
252
+ try {
253
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
254
+ const outcome = await installer.install(piHome, "npm:some-pkg");
255
+
256
+ expect(outcome).toMatchObject({ ok: false, notADaemon: true });
257
+ expect(harness.events()).toEqual([]);
258
+ } finally {
259
+ await harness.dispose();
260
+ }
254
261
  });
255
262
 
256
- it("remove() unregisters the resolved Vehicle through Armada directly", async () => {
263
+ it("remove() unregisters the resolved Vehicle through Armada's real registrar", async () => {
257
264
  const piHome = fakePiHome();
258
265
  writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
259
- const registrar = new FakeVehicleRegistrar();
260
- const installer = new RealDaemonServiceInstaller(registrar);
261
- await installer.install(piHome, "npm:@danypops/web-spider-daemon");
262
-
263
- const outcome = await installer.remove(piHome, "npm:@danypops/web-spider-daemon");
264
-
265
- expect(outcome).toMatchObject({ ok: true, result: { installed: true } });
266
- expect(registrar.registered.has("web-spider-daemon")).toBe(false);
266
+ const harness = await createArmadaTestHarness();
267
+ try {
268
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
269
+ await installer.install(piHome, "npm:@danypops/web-spider-daemon");
270
+ const outcome = await installer.remove(piHome, "npm:@danypops/web-spider-daemon");
271
+
272
+ expect(outcome).toMatchObject({ ok: true, result: { installed: true } });
273
+ expect(await harness.registrar.isRegistered("web-spider-daemon")).toBe(false);
274
+ expect(harness.events().at(-1)).toBe("remove:armada-web-spider-daemon.service");
275
+ } finally {
276
+ await harness.dispose();
277
+ }
267
278
  });
268
279
 
269
- it("restart() is a no-op, in-band, when Armada has no registration for this Vehicle yet", async () => {
280
+ it("restart() is a no-op when Armada has no registration for this Vehicle", async () => {
270
281
  const piHome = fakePiHome();
271
282
  writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
272
- const registrar = new FakeVehicleRegistrar();
273
- const installer = new RealDaemonServiceInstaller(registrar);
274
-
275
- const outcome = await installer.restart(piHome, "npm:@danypops/web-spider-daemon");
276
-
277
- expect(outcome).toMatchObject({ ok: true, restarted: false });
283
+ const harness = await createArmadaTestHarness();
284
+ try {
285
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
286
+ const outcome = await installer.restart(piHome, "npm:@danypops/web-spider-daemon");
287
+
288
+ expect(outcome).toMatchObject({ ok: true, restarted: false });
289
+ expect(harness.events()).toEqual([]);
290
+ } finally {
291
+ await harness.dispose();
292
+ }
278
293
  });
279
294
 
280
- it("restart() re-registers (picking up a fresh on-disk version) once already registered", async () => {
295
+ it("restart() re-registers a changed on-disk version through Armada's update plan", async () => {
281
296
  const piHome = fakePiHome();
282
297
  writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
283
- const registrar = new FakeVehicleRegistrar();
284
- const installer = new RealDaemonServiceInstaller(registrar);
285
- await installer.install(piHome, "npm:@danypops/web-spider-daemon");
286
-
287
- const outcome = await installer.restart(piHome, "npm:@danypops/web-spider-daemon");
288
-
289
- expect(outcome).toMatchObject({ ok: true, restarted: true });
298
+ const harness = await createArmadaTestHarness();
299
+ try {
300
+ const installer = new RealDaemonServiceInstaller(harness.registrar);
301
+ await installer.install(piHome, "npm:@danypops/web-spider-daemon");
302
+ const packagePath = join(piHome, "npm", "node_modules", "@danypops/web-spider-daemon", "package.json");
303
+ writeFileSync(
304
+ packagePath,
305
+ JSON.stringify({
306
+ name: "@danypops/web-spider-daemon",
307
+ version: "1.1.0",
308
+ packed: { daemonService: { binPath: "dist/cli.js", args: ["serve"] } },
309
+ }),
310
+ );
311
+ const outcome = await installer.restart(piHome, "npm:@danypops/web-spider-daemon");
312
+
313
+ expect(outcome).toMatchObject({ ok: true, restarted: true, spec: { version: "1.1.0" } });
314
+ expect(harness.events().slice(-4)).toEqual([
315
+ "stop:armada-web-spider-daemon.service",
316
+ "replace:armada-web-spider-daemon.service",
317
+ "start:armada-web-spider-daemon.service",
318
+ "ready:web-spider-daemon",
319
+ ]);
320
+ } finally {
321
+ await harness.dispose();
322
+ }
290
323
  });
291
324
 
292
- it("surfaces a failed Armada registration's diagnostics in-band, never as a thrown error", async () => {
325
+ it("surfaces a failed Armada registration's diagnostics in-band", async () => {
293
326
  const piHome = fakePiHome();
294
327
  writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
295
- const registrar = new FakeVehicleRegistrar();
296
- registrar.outcome = { ok: false, diagnostics: [{ code: "X", severity: "error", path: "/", message: "native failure" }] };
328
+ const registrar: VehicleRegistrar = {
329
+ register: async () => ({ ok: false, diagnostics: [{ code: "X", severity: "error", path: "/", message: "native failure" }] }),
330
+ unregister: async () => ({ ok: false, diagnostics: [] }),
331
+ isRegistered: async () => false,
332
+ };
297
333
  const installer = new RealDaemonServiceInstaller(registrar);
298
334
 
299
335
  const outcome = await installer.install(piHome, "npm:@danypops/web-spider-daemon");
@@ -260,21 +260,23 @@ describe("updates store", () => {
260
260
  describe("watcher producer", () => {
261
261
  it("writes a snapshot on tick", async () => {
262
262
  const dir = mkdtempSync(join(tmpdir(), "packed-"));
263
+ let signalTick: ((snapshot: UpdatesSnapshot) => void) | undefined;
264
+ const tick = new Promise<UpdatesSnapshot>((resolve) => {
265
+ signalTick = resolve;
266
+ });
263
267
  const stop = startWatcher(
264
268
  () => "0.9.0",
265
269
  dir,
266
270
  () => [{ name: "pi-extension-manager", pinned: "0.8.2" }],
267
- { intervalMs: 60_000 },
271
+ { intervalMs: 60_000, onTick: (snapshot) => signalTick?.(snapshot) },
268
272
  );
269
- const deadline = Date.now() + 2000;
270
- let snap: UpdatesSnapshot | undefined;
271
- while (Date.now() < deadline) {
272
- snap = await loadUpdates(dir);
273
- if (snap?.updates.length) break;
274
- await Bun.sleep(25);
273
+ try {
274
+ const snap = await tick;
275
+ expect(snap.updates[0]?.latest).toBe("0.9.0");
276
+ expect(await loadUpdates(dir)).toEqual(snap);
277
+ } finally {
278
+ stop();
275
279
  }
276
- stop();
277
- expect(snap?.updates[0]?.latest).toBe("0.9.0");
278
280
  });
279
281
  });
280
282
 
@@ -165,13 +165,22 @@ describe("buildIndex bounds", () => {
165
165
  replaceAll(db, [{ name: "pi-one", version: "1.0.0" }], "test");
166
166
  db.close();
167
167
  let infoCalls = 0;
168
+ let releaseResponse: (() => void) | undefined;
169
+ let signalRequest: (() => void) | undefined;
170
+ const responseGate = new Promise<void>((resolve) => {
171
+ releaseResponse = resolve;
172
+ });
173
+ const requestReceived = new Promise<void>((resolve) => {
174
+ signalRequest = resolve;
175
+ });
168
176
  const server = Bun.serve({
169
177
  port: 0,
170
178
  async fetch(req) {
171
179
  const url = new URL(req.url);
172
180
  if (url.pathname.endsWith("/latest")) {
173
181
  infoCalls++;
174
- await Bun.sleep(30);
182
+ signalRequest?.();
183
+ await responseGate;
175
184
  return Response.json({ name: "pi-one", version: "1.0.0" });
176
185
  }
177
186
  return new Response("not found", { status: 404 });
@@ -179,7 +188,11 @@ describe("buildIndex bounds", () => {
179
188
  });
180
189
  try {
181
190
  const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
182
- const [a, b] = await Promise.all([buildIndex(reg, dir, { delayMs: 0 }), buildIndex(reg, dir, { delayMs: 0 })]);
191
+ const first = buildIndex(reg, dir, { delayMs: 0 });
192
+ await requestReceived;
193
+ const second = buildIndex(reg, dir, { delayMs: 0 });
194
+ releaseResponse?.();
195
+ const [a, b] = await Promise.all([first, second]);
183
196
  expect(a).toBe(b); // the very same result object -- one real run, not two
184
197
  expect(infoCalls).toBe(1); // never doubled
185
198
  } finally {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { createArmadaTestHarness } from "@danypops/armada/testing";
5
6
  import type { ServiceDoctorDeps } from "../src/adoption/service-doctor.ts";
6
7
  import { checkServiceUnitPaths } from "../src/adoption/service-doctor.ts";
7
8
 
@@ -64,27 +65,40 @@ describe("checkServiceUnitPaths", () => {
64
65
  expect(report).toEqual({ ok: true, diagnostics: [], checked: 1 });
65
66
  });
66
67
 
67
- it("reports an Armada-managed Vehicle that is declared but not running", () => {
68
+ it("reports a stopped Vehicle from Armada's real status projection", async () => {
68
69
  const home = piHome(["npm:fakedaemon"]);
69
70
  const dir = installDaemonPackage(home, "fakedaemon");
70
- const report = checkServiceUnitPaths(
71
- home,
72
- undefined,
73
- fakeDeps({ vehicles: [{ name: "fakedaemon", executable: join(dir, "cli.ts"), nativeStatus: "stopped", ready: false }], diagnostics: [] }),
74
- );
75
- expect(report).toEqual({
76
- ok: false,
77
- checked: 1,
78
- diagnostics: [
79
- {
80
- code: "SERVICE_NOT_RUNNING",
81
- severity: "error",
82
- package: "fakedaemon",
83
- unitName: "fakedaemon",
84
- message: expect.stringContaining("stopped"),
85
- },
86
- ],
87
- });
71
+ const harness = await createArmadaTestHarness();
72
+ try {
73
+ const registered = await harness.registrar.register({
74
+ name: "fakedaemon",
75
+ version: "1.0.0",
76
+ executable: join(dir, "cli.ts"),
77
+ arguments: ["serve"],
78
+ handlePath: join(harness.root, "fakedaemon", "handle.json"),
79
+ restart: { policy: "on-failure", delayMs: 100, maxAttempts: 2, windowMs: 1_000 },
80
+ readiness: { timeoutMs: 100, pollIntervalMs: 50 },
81
+ });
82
+ expect(registered.ok).toBe(true);
83
+ harness.application("fakedaemon").exitCleanly();
84
+
85
+ const report = checkServiceUnitPaths(home, undefined, fakeDeps(await harness.status()));
86
+ expect(report).toEqual({
87
+ ok: false,
88
+ checked: 1,
89
+ diagnostics: [
90
+ {
91
+ code: "SERVICE_NOT_RUNNING",
92
+ severity: "error",
93
+ package: "fakedaemon",
94
+ unitName: "fakedaemon",
95
+ message: expect.stringContaining("stopped"),
96
+ },
97
+ ],
98
+ });
99
+ } finally {
100
+ await harness.dispose();
101
+ }
88
102
  });
89
103
 
90
104
  it("reports a missing Armada-managed executable", () => {
@@ -1,11 +1,22 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { mkdtempSync } from "node:fs";
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { OPERATION_NAMES } from "../src/daemon/service.ts";
6
6
  import { createApp, type Deps } from "../src/daemon/service.ts";
7
7
  import type { Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
8
8
 
9
+ const roots: string[] = [];
10
+ afterEach(() => {
11
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
12
+ });
13
+
14
+ function temporaryRoot(prefix: string): string {
15
+ const root = mkdtempSync(join(tmpdir(), prefix));
16
+ roots.push(root);
17
+ return root;
18
+ }
19
+
9
20
  class FakeRegistry implements Registry {
10
21
  constructor(
11
22
  private results: Pkg[] = [],
@@ -42,7 +53,7 @@ function deps(over: Partial<Deps> = {}): Deps {
42
53
  reg: new FakeRegistry([{ name: "pi-lsp", version: "1.0.0", description: "An LSP package" }], 1),
43
54
  inst: new FakeInstaller(),
44
55
  token: "test-token",
45
- stateDir: mkdtempSync(join(tmpdir(), "packed-vehicle-")),
56
+ stateDir: temporaryRoot("packed-vehicle-"),
46
57
  ...over,
47
58
  };
48
59
  }
@@ -78,7 +89,7 @@ describe("packed's daemon operation surface, through the real Vehicle wire proto
78
89
  });
79
90
 
80
91
  it("package.installed and pi.status (daemon-only, never a Pi tool) are still reachable through /vehicle/invoke", async () => {
81
- const app = createApp(deps({ piHome: mkdtempSync(join(tmpdir(), "packed-vehicle-pihome-")) }));
92
+ const app = createApp(deps({ piHome: temporaryRoot("packed-vehicle-pihome-") }));
82
93
  const installed = await invoke(app, "package.installed", {});
83
94
  expect(installed.status).toBe(200);
84
95
  expect(installed.body.output).toEqual([]);