@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.
- package/extension/src/tabs/discover.ts +14 -0
- package/package.json +2 -2
- package/service/src/daemon/daemon.ts +8 -19
- package/service/src/daemon/watcher.ts +4 -1
- package/service/src/shared/cache.ts +6 -3
- package/service/test/core.test.ts +4 -3
- package/service/test/daemon-maintenance.test.ts +96 -16
- package/service/test/daemon-service.test.ts +106 -70
- package/service/test/domain.test.ts +11 -9
- package/service/test/index.test.ts +15 -2
- package/service/test/service-doctor.test.ts +33 -19
- package/service/test/vehicle-registration.test.ts +15 -4
|
@@ -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
|
+
"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.
|
|
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
|
|
127
|
-
const task
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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:
|
|
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",
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
207
|
+
await harness.waitForEvent("ready:probe");
|
|
134
208
|
expect(existsSync(paths.handle)).toBe(true);
|
|
135
|
-
expect(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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 =
|
|
296
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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:
|
|
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:
|
|
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([]);
|