1import { describe, expect, it } from "@effect/vitest";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 DeviceId,
5 DeviceOperationError,
6 LOCAL_DEVICE_HOST_ID,
7 ThreadId,
8 type DeviceServiceState,
9} from "@t3tools/contracts";
10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Deferred from "effect/Deferred";
14import * as Fiber from "effect/Fiber";
15import * as PubSub from "effect/PubSub";
16import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import { HttpClient, HttpClientResponse } from "effect/http";
20import * as ServerSettings from "../serverSettings.ts";
21import * as DeviceHost from "./DeviceHost.ts";
22import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
23
24import * as DeviceService from "./DeviceService.ts";
25
26const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
27
28const baseState: DeviceServiceState = {
29 hosts: [],
30 hostStatus: "idle",
31 hostStatuses: {},
32 devices: [],
33 sessions: [],
34 onboardingCompleted: false,
35 agentAccessEnabled: false,
36 hubBasePath: "/api/device-hub",
37 revision: 0,
38};
39
40describe("DeviceService.stateStream", () => {
41 it.effect("emits the current snapshot and then every published change", () =>
42 Effect.gen(function* () {
43 const pubsub = yield* PubSub.unbounded<DeviceServiceState>();
44 const current = yield* Ref.make(baseState);
45 const service: Pick<DeviceService.DeviceService["Service"], "state" | "subscribe"> = {
46 state: Ref.get(current),
47 subscribe: PubSub.subscribe(pubsub),
48 };
49
50 const collected = yield* DeviceService.stateStream(
51 service as DeviceService.DeviceService["Service"],
52 ).pipe(Stream.take(3), Stream.runCollect, Effect.forkChild);
53 yield* Effect.yieldNow;
54 for (const revision of [1, 2]) {
55 const next = { ...baseState, revision, hostStatus: "ready" as const };
56 yield* Ref.set(current, next);
57 yield* PubSub.publish(pubsub, next);
58 }
59 const seen = yield* Fiber.join(collected);
60 expect(seen.map((state) => state.revision)).toEqual([0, 1, 2]);
61 }),
62 );
63});
64
65const fixture = Effect.fn("fixture")(function* (
66 onBoot: Effect.Effect<void> = Effect.void,
67 bootError?: string,
68 failListAfterShutdown = false,
69 runtimeFailure?:
70 | NodeRuntimeUnavailableError
71 | DeviceHost.DeviceHostError
72 | DeviceHost.AgentDeviceStartError,
73 inspectError = false,
74 installTool?: Parameters<typeof DeviceService.makeWithHosts>[3],
75) {
76 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
77 const starts: string[] = [];
78 const agentStarts: string[] = [];
79 const agentStops: string[] = [];
80 const requests: string[] = [];
81 let booted = false;
82 let shutDown = false;
83 const ready: DeviceHost.DeviceHostReady = {
84 nodePath: process.execPath,
85 hub: { origin: "http://device.test" },
86 helpers: { serveSimAxSettings: null, serveSimCli: null },
87 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
88 };
89 const host: DeviceHost.DeviceHost["Service"] = {
90 ...(inspectError
91 ? {
92 inspect: Effect.fail(
93 new DeviceHost.DeviceHostError({
94 hostId: LOCAL_DEVICE_HOST_ID,
95 step: "probe",
96 cause: new Error("offline"),
97 }),
98 ),
99 }
100 : {}),
101 id: LOCAL_DEVICE_HOST_ID,
102 summary: Effect.succeed({
103 id: LOCAL_DEVICE_HOST_ID,
104 kind: "local",
105 label: "Test server",
106 platforms: [{ platform: "android", available: true }],
107 hubInstalled: true,
108 agentDeviceInstalled: false,
109 }),
110 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
111 ensureReady: (onPhase) =>
112 Effect.gen(function* () {
113 if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError")
114 return yield* runtimeFailure;
115 starts.push("start");
116 yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…");
117 return ready;
118 }),
119 ensureAgentReady: (onPhase) =>
120 Effect.gen(function* () {
121 if (runtimeFailure) return yield* runtimeFailure;
122 agentStarts.push("start");
123 yield* onPhase("starting");
124 return {
125 ...ready,
126 agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" },
127 };
128 }),
129 current: Effect.succeed(null),
130 stopAgent: Effect.sync(() => {
131 agentStops.push("stop");
132 }),
133 stop: Effect.sync(() => {
134 starts.push("stop");
135 }),
136 };
137 const service = yield* DeviceService.makeWithHosts(
138 new Map([[host.id, host]]),
139 undefined,
140 undefined,
141 installTool,
142 ).pipe(
143 Effect.provide(NodeCrypto.layer),
144 Effect.provideService(DeviceHost.DeviceHost, host),
145 Effect.provideService(
146 ServerSettings.ServerSettingsService,
147 ServerSettings.ServerSettingsService.of({
148 updateProviderInstance: () => Effect.die("Unexpected provider mutation"),
149 withSettingsSnapshot: (use) => Effect.flatMap(Ref.get(settings), use),
150 start: Effect.void,
151 ready: Effect.void,
152 getSettings: Ref.get(settings),
153 updateSettings: (patch) =>
154 Ref.updateAndGet(settings, (current) => ({
155 ...current,
156 enableDeviceSupport: patch.enableDeviceSupport ?? current.enableDeviceSupport,
157 enableAgentDeviceAccess:
158 patch.enableAgentDeviceAccess ?? current.enableAgentDeviceAccess,
159 deviceOnboardingCompleted:
160 patch.deviceOnboardingCompleted ?? current.deviceOnboardingCompleted,
161 })),
162 streamChanges: Stream.empty,
163 subscribeChanges: Effect.succeed(Stream.empty),
164 }),
165 ),
166 Effect.provideService(
167 HttpClient.HttpClient,
168 HttpClient.make((request) =>
169 Effect.gen(function* () {
170 requests.push(request.url);
171 if (request.url.includes("/api/screenshot")) {
172 return HttpClientResponse.fromWeb(
173 request,
174 new Response(new Uint8Array([137, 80, 78, 71])),
175 );
176 }
177 if (request.url.endsWith("/shutdown")) {
178 shutDown = true;
179 booted = false;
180 return HttpClientResponse.fromWeb(request, Response.json({ ok: true }));
181 }
182 if (shutDown && failListAfterShutdown) {
183 return HttpClientResponse.fromWeb(
184 request,
185 new Response("Discovery busy", { status: 503 }),
186 );
187 }
188 if (request.url.endsWith("/boot")) {
189 yield* onBoot;
190 booted = true;
191 return HttpClientResponse.fromWeb(
192 request,
193 Response.json(
194 bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" },
195 ),
196 );
197 }
198 return HttpClientResponse.fromWeb(
199 request,
200 Response.json({
201 simulators: [],
202 emulators: booted
203 ? [
204 {
205 id: "emulator-5554",
206 name: "Pixel_API_35",
207 platform: "android",
208 version: "Android 15",
209 booted: true,
210 physical: false,
211 },
212 ]
213 : [],
214 }),
215 );
216 }),
217 ),
218 ),
219 );
220 return { service, starts, agentStarts, agentStops, requests, settings };
221});
222
223describe("device setup consent", () => {
224 it.effect("exposes daemon startup diagnostics through agent readiness and host status", () =>
225 Effect.gen(function* () {
226 const startupError = new DeviceHost.AgentDeviceStartError({
227 hostId: LOCAL_DEVICE_HOST_ID,
228 stateDir: "/test/device/agent-device",
229 exitCode: 1,
230 reason: "daemon_lock_not_directory",
231 cause: { stdout: "private startup diagnostics", code: 1 },
232 });
233 const { service, settings } = yield* fixture(Effect.void, undefined, false, startupError);
234 yield* Ref.update(settings, (current) => ({
235 ...current,
236 enableDeviceSupport: true,
237 enableAgentDeviceAccess: true,
238 }));
239 const error = yield* service.agentReadinessIfSupported().pipe(Effect.flip);
240 expect(error).toMatchObject({
241 _tag: "DeviceHostUnavailableError",
242 reason: startupError.message,
243 cause: startupError,
244 });
245 expect(error.message).toContain("daemon.lock is not a directory");
246 expect(error.message).not.toContain("private startup diagnostics");
247 expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({
248 status: "failed",
249 detail: startupError.message,
250 });
251 }).pipe(Effect.scoped),
252 );
253
254 it.effect(
255 "preserves missing-runtime guidance and causes through manual and agent readiness",
256 () =>
257 Effect.gen(function* () {
258 const underlying = new Error("private lookup diagnostics");
259 const runtimeFailure = new NodeRuntimeUnavailableError({
260 feature: "Local device support",
261 cause: underlying,
262 });
263 const { service, settings, requests } = yield* fixture(
264 Effect.void,
265 undefined,
266 false,
267 runtimeFailure,
268 );
269 yield* Ref.update(settings, (current) => ({
270 ...current,
271 enableDeviceSupport: true,
272 enableAgentDeviceAccess: true,
273 }));
274 for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) {
275 const error = yield* readiness.pipe(Effect.flip);
276 expect(error).toMatchObject({
277 _tag: "DeviceHostUnavailableError",
278 reason: expect.stringContaining("Install Node.js"),
279 cause: runtimeFailure,
280 });
281 expect(error.message).not.toContain(underlying.message);
282 }
283 expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({
284 status: "failed",
285 detail: expect.stringContaining("Install Node.js"),
286 });
287 expect(requests).toEqual([]);
288 }).pipe(Effect.scoped),
289 );
290
291 it.effect("listing and provider startup do not start helpers before consent", () =>
292 Effect.gen(function* () {
293 const { service, starts, requests } = yield* fixture();
294 expect((yield* service.list).hostStatus).toBe("disabled");
295 expect(yield* service.readinessIfSupported()).toBeNull();
296 const readiness = yield* service.readiness().pipe(Effect.result);
297 expect(readiness._tag).toBe("Failure");
298 expect(starts).toEqual([]);
299 expect(requests).toEqual([]);
300 }).pipe(Effect.scoped),
301 );
302
303 it.effect(
304 "explicit setup discovers never-booted AVDs; disabling stops helpers and blocks agents",
305 () =>
306 Effect.gen(function* () {
307 const { service, starts, settings } = yield* fixture();
308 const state = yield* service.configure({ enabled: true });
309 expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(true);
310 expect(state.devices.map((device) => [device.id, device.booted])).toEqual([
311 ["Pixel_API_35", false],
312 ]);
313 expect(starts).toEqual(["start"]);
314 const disabled = yield* service.configure({ enabled: false });
315 expect(disabled.hostStatus).toBe("disabled");
316 expect(disabled.devices).toEqual([]);
317 expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(false);
318 expect(yield* service.readinessIfSupported()).toBeNull();
319 expect(starts).toEqual(["start", "stop"]);
320 }).pipe(Effect.scoped),
321 );
322
323 it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () =>
324 Effect.gen(function* () {
325 const { service, requests } = yield* fixture();
326 yield* service.configure({ enabled: true });
327 const session = yield* service.open({
328 threadId: ThreadId.make("thread-1"),
329 deviceId: "Pixel_API_35",
330 platform: "android",
331 });
332 expect(session.deviceId).toBe("emulator-5554");
333 expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1);
334 const state = yield* service.state;
335 expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]);
336 expect(state.bootingDevices).toEqual([]);
337 }).pipe(Effect.scoped),
338 );
339
340 it.effect("installs agent support only after the separate agent permission", () =>
341 Effect.gen(function* () {
342 const { service, agentStarts, agentStops, settings } = yield* fixture();
343 yield* service.configure({ enabled: true });
344 expect(agentStarts).toEqual([]);
345 expect(yield* service.agentReadinessIfSupported()).toBeNull();
346
347 yield* service.configure({ agentAccessEnabled: true });
348 expect(agentStarts).toEqual(["start"]);
349 expect((yield* Ref.get(settings)).enableAgentDeviceAccess).toBe(true);
350 expect((yield* service.state).agentAccessEnabled).toBe(true);
351
352 yield* service.configure({ agentAccessEnabled: false, onboardingCompleted: true });
353 expect(agentStops).toEqual(["stop"]);
354 expect((yield* service.state).onboardingCompleted).toBe(true);
355 expect((yield* Ref.get(settings)).deviceOnboardingCompleted).toBe(true);
356 }).pipe(Effect.scoped),
357 );
358});
359
360it.effect("publishes boot progress and does not restore sessions after support is disabled", () =>
361 Effect.gen(function* () {
362 const started = yield* Deferred.make<void>();
363 const finish = yield* Deferred.make<void>();
364 const { service } = yield* fixture(
365 Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))),
366 );
367 yield* service.configure({ enabled: true });
368 const opening = yield* service
369 .open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" })
370 .pipe(Effect.result, Effect.forkChild);
371 yield* Deferred.await(started);
372 expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([
373 "Pixel_API_35",
374 ]);
375 yield* service.configure({ enabled: false });
376 yield* Deferred.succeed(finish, undefined);
377 expect((yield* Fiber.join(opening))._tag).toBe("Failure");
378 const state = yield* service.state;
379 expect(state.hostStatus).toBe("disabled");
380 expect(state.devices).toEqual([]);
381 expect(state.sessions).toEqual([]);
382 expect(state.bootingDevices).toEqual([]);
383 }).pipe(Effect.scoped),
384);
385
386describe("device discovery after server restart", () => {
387 it.effect("captures an explicit device before any client lists devices", () =>
388 Effect.gen(function* () {
389 const { service, settings, requests } = yield* fixture();
390 yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true }));
391 expect((yield* service.state).devices).toEqual([]);
392 const capture = yield* service.screenshot({ deviceId: DeviceId.make("Pixel_API_35") });
393 expect(capture.device.id).toBe("Pixel_API_35");
394 expect(Array.from(capture.png)).toEqual([137, 80, 78, 71]);
395 expect(requests.some((url) => url.endsWith("/api/devices"))).toBe(true);
396 }).pipe(Effect.scoped),
397 );
398});
399
400it.effect.each([
401 {
402 diagnostic: "Insufficient disk space at /private/user/path",
403 reason: "disk_space",
404 message: "not enough free disk space",
405 },
406 {
407 diagnostic: "Timed out spawning /private/user/command",
408 reason: "timeout",
409 message: "did not become ready in time",
410 },
411 {
412 diagnostic: "Unexpected failure: secret-token",
413 reason: "launch_failed",
414 message: "could not start",
415 },
416] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) =>
417 Effect.gen(function* () {
418 const { service } = yield* fixture(Effect.void, diagnostic);
419 yield* service.configure({ enabled: true });
420 const error = yield* service
421 .open({
422 threadId: ThreadId.make("boot-failure"),
423 deviceId: "Pixel_API_35",
424 platform: "android",
425 })
426 .pipe(Effect.flip);
427 expect(error._tag).toBe("DeviceBootError");
428 expect(error.message).toContain(message);
429 expect(error.message).not.toContain(diagnostic);
430 expect((yield* service.state).bootingDevices).toEqual([]);
431 }).pipe(Effect.scoped),
432);
433
434it.effect("keeps shutdown successful when subsequent discovery fails", () =>
435 Effect.gen(function* () {
436 const { service } = yield* fixture(Effect.void, undefined, true);
437 yield* service.configure({ enabled: true });
438 const threadId = ThreadId.make("shutdown-refresh");
439 const session = yield* service.open({
440 threadId,
441 deviceId: "Pixel_API_35",
442 platform: "android",
443 });
444 yield* service.close({ threadId, deviceId: session.deviceId, shutdown: true });
445 const state = yield* service.state;
446 expect(state.sessions).toEqual([]);
447 expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false);
448 }).pipe(Effect.scoped),
449);
450
451it.effect.each(["shutdown", "close"] as const)(
452 "%s releases iOS capture so reopening uses a fresh session",
453 (operation) =>
454 Effect.gen(function* () {
455 const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111");
456 const threadId = ThreadId.make("capture-recovery");
457 let booted = true;
458 let capture: number | null = null;
459 let generation = 0;
460 const ready: DeviceHost.DeviceHostReady = {
461 nodePath: process.execPath,
462 hub: { origin: "http://device.test" },
463 helpers: { serveSimAxSettings: null, serveSimCli: null },
464 run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }),
465 };
466 const host: DeviceHost.DeviceHost["Service"] = {
467 id: LOCAL_DEVICE_HOST_ID,
468 summary: Effect.succeed({
469 id: LOCAL_DEVICE_HOST_ID,
470 kind: "local",
471 label: "Simulator host",
472 platforms: [{ platform: "ios", available: true }],
473 hubInstalled: true,
474 agentDeviceInstalled: false,
475 }),
476 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
477 ensureReady: () => Effect.succeed(ready),
478 ensureAgentReady: () => Effect.die("Agent access is not used in this test"),
479 current: Effect.succeed(ready),
480 stopAgent: Effect.void,
481 stop: Effect.void,
482 };
483 const http = HttpClient.make((request) =>
484 Effect.sync(() => {
485 const path = new URL(request.url).pathname;
486 if (path === "/api/devices") {
487 return HttpClientResponse.fromWeb(
488 request,
489 Response.json({
490 emulators: [],
491 simulators: [
492 {
493 id: deviceId,
494 name: "iPhone",
495 platform: "ios",
496 version: "26",
497 physical: false,
498 booted,
499 },
500 ],
501 }),
502 );
503 }
504 if (path === "/vendor/serve-sim/grid/api/start") capture ??= ++generation;
505 else if (path === "/vendor/serve-sim/grid/api/shutdown") {
506 if (request.body._tag !== "Uint8Array") throw new Error("Missing shutdown body");
507 expect(decodeJson(new TextDecoder().decode(request.body.body))).toEqual({
508 udid: deviceId,
509 });
510 capture = null;
511 booted = false;
512 } else if (path === "/api/devices/shutdown") {
513 // This route powers off without releasing serve-sim's cached capture.
514 booted = false;
515 } else if (path === "/api/devices/boot") booted = true;
516 else throw new Error(`Unexpected hub path: ${path}`);
517 return HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId }));
518 }),
519 );
520 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(
521 Effect.provide(NodeCrypto.layer),
522 Effect.provideService(HttpClient.HttpClient, http),
523 );
524 const input = { threadId, deviceId, platform: "ios" as const };
525 yield* service.open(input);
526 expect(capture).toBe(1);
527 if (operation === "shutdown") yield* service.shutdown(input);
528 else yield* service.close({ threadId, deviceId, shutdown: true });
529 expect(capture).toBeNull();
530 expect((yield* service.state).sessions).toEqual([]);
531 yield* service.open(input);
532 expect(capture).toBe(2);
533 expect((yield* service.state).sessions).toHaveLength(1);
534 }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped),
535);
536
537it.effect.each([
538 { hubReports: "off", outcome: "succeeds" },
539 { hubReports: "booted", outcome: "fails" },
540 { hubReports: "missing", outcome: "fails" },
541] as const)(
542 "iOS shutdown $outcome when serve-sim rejects it and the hub reports the simulator $hubReports",
543 ({ hubReports, outcome }) =>
544 Effect.gen(function* () {
545 const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222");
546 const paths: string[] = [];
547 // The device list is stale until shutdown re-reads it from the hub.
548 let listed: "booted" | "off" | "missing" = "booted";
549 const ready: DeviceHost.DeviceHostReady = {
550 nodePath: process.execPath,
551 hub: { origin: "http://device.test" },
552 helpers: { serveSimAxSettings: null, serveSimCli: null },
553 run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }),
554 };
555 const host: DeviceHost.DeviceHost["Service"] = {
556 id: LOCAL_DEVICE_HOST_ID,
557 summary: Effect.succeed({
558 id: LOCAL_DEVICE_HOST_ID,
559 kind: "local",
560 label: "Simulator host",
561 platforms: [{ platform: "ios", available: true }],
562 hubInstalled: true,
563 agentDeviceInstalled: false,
564 }),
565 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
566 ensureReady: () => Effect.succeed(ready),
567 ensureAgentReady: () => Effect.die("Agent access is not used in this test"),
568 current: Effect.succeed(ready),
569 stopAgent: Effect.void,
570 stop: Effect.void,
571 };
572 const http = HttpClient.make((request) =>
573 Effect.sync(() => {
574 const path = new URL(request.url).pathname;
575 paths.push(path);
576 if (path === "/api/devices") {
577 return HttpClientResponse.fromWeb(
578 request,
579 Response.json({
580 emulators: [],
581 simulators:
582 listed === "missing"
583 ? []
584 : [
585 {
586 id: deviceId,
587 name: "iPhone",
588 platform: "ios",
589 version: "26",
590 physical: false,
591 booted: listed === "booted",
592 },
593 ],
594 // A partial listing still decodes; it must not read as "off".
595 errors: listed === "missing" ? [{ message: "simctl list failed" }] : [],
596 }),
597 );
598 }
599 if (path === "/vendor/serve-sim/grid/api/shutdown") {
600 // serve-sim runs `simctl shutdown` bare and returns its failure as-is.
601 listed = hubReports;
602 return HttpClientResponse.fromWeb(
603 request,
604 Response.json(
605 { ok: false, error: "Unable to shutdown device in current state: Shutdown" },
606 { status: 500 },
607 ),
608 );
609 }
610 throw new Error(`Unexpected hub path: ${path}`);
611 }),
612 );
613 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(
614 Effect.provide(NodeCrypto.layer),
615 Effect.provideService(HttpClient.HttpClient, http),
616 );
617 yield* service.list;
618 const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" }));
619 expect(paths.filter((path) => path.endsWith("shutdown"))).toEqual([
620 "/vendor/serve-sim/grid/api/shutdown",
621 ]);
622 if (outcome === "succeeds") {
623 expect(Exit.isSuccess(exit)).toBe(true);
624 expect(
625 (yield* service.state).devices.find((device) => device.id === deviceId)?.booted,
626 ).toBe(false);
627 } else {
628 expect(Exit.isFailure(exit)).toBe(true);
629 expect(
630 (yield* service.state).devices.find((device) => device.id === deviceId)?.booted,
631 ).toBe(true);
632 }
633 }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped),
634);
635
636it.effect("retry keeps device and agent consent unchanged", () =>
637 Effect.gen(function* () {
638 const { service, starts, agentStarts } = yield* fixture();
639 yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
640 expect(starts).toEqual([]);
641 expect(agentStarts).toEqual([]);
642 yield* service.configure({ enabled: true });
643 yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
644 expect(agentStarts).toEqual([]);
645 yield* service.configure({ agentAccessEnabled: true });
646 const before = agentStarts.length;
647 yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
648 expect(agentStarts.length).toBe(before + 1);
649 }).pipe(Effect.scoped),
650);
651
652it.effect("publishes update detail for the correct host", () =>
653 Effect.gen(function* () {
654 const { service } = yield* fixture();
655 const changes = yield* service.subscribe;
656 yield* service.configure({ enabled: true });
657 const states = yield* PubSub.takeAll(changes);
658 expect(
659 states.some(
660 (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…",
661 ),
662 ).toBe(true);
663 }).pipe(Effect.scoped),
664);
665
666it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () =>
667 Effect.gen(function* () {
668 const { service, settings } = yield* fixture(
669 Effect.void,
670 undefined,
671 false,
672 new DeviceHost.DeviceHostError({
673 hostId: LOCAL_DEVICE_HOST_ID,
674 step: "probe",
675 cause: "private diagnostics",
676 }),
677 );
678 yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true }));
679 const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID);
680 expect(state.supportsHostRetry).toBe(true);
681 expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({
682 status: "failed",
683 detail: "Could not connect to this host over SSH.",
684 });
685 }).pipe(Effect.scoped),
686);
687
688it.effect("version discovery does not grant consent or start device tools", () =>
689 Effect.gen(function* () {
690 const { service, starts, agentStarts, requests } = yield* fixture();
691 const state = yield* service.inspect;
692 expect(state.supportsToolInspection).toBe(true);
693 expect(state.hostStatus).toBe("disabled");
694 expect(state.hosts).toHaveLength(1);
695 expect(starts).toEqual([]);
696 expect(agentStarts).toEqual([]);
697 expect(requests).toEqual([]);
698 }).pipe(Effect.scoped),
699);
700
701it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () =>
702 Effect.gen(function* () {
703 const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true);
704 const state = yield* service.inspect;
705 expect(state.supportsToolInspection).toBe(true);
706 expect(state.hostStatus).toBe("disabled");
707 expect(state.hosts[0]?.hubInstalled).toBe(true);
708 expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host");
709 expect(starts).toEqual([]);
710 }).pipe(Effect.scoped),
711);
712
713it.effect(
714 "manual updates install only the selected tool without enabling access or starting helpers",
715 () =>
716 Effect.gen(function* () {
717 const installed: string[] = [];
718 const { service, starts, agentStarts, requests } = yield* fixture(
719 Effect.void,
720 undefined,
721 false,
722 undefined,
723 false,
724 (tool) =>
725 Effect.sync(() => {
726 installed.push(tool);
727 }),
728 );
729 const before = yield* service.state;
730 const state = yield* service.updateTool("agent");
731 expect(installed).toEqual(["agent"]);
732 expect(state.supportsToolUpdate).toBe(true);
733 expect(state.hostStatus).toBe(before.hostStatus);
734 expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled);
735 expect(state.revision).toBeGreaterThan(before.revision);
736 expect(starts).toEqual([]);
737 expect(agentStarts).toEqual([]);
738 expect(requests).toEqual([]);
739 yield* service.updateTool("hub");
740 expect(installed).toEqual(["agent", "hub"]);
741 }).pipe(Effect.scoped),
742);
743
744it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () =>
745 Effect.gen(function* () {
746 let attempts = 0;
747 const { service, starts, agentStarts } = yield* fixture(
748 Effect.void,
749 undefined,
750 false,
751 undefined,
752 false,
753 () =>
754 Effect.suspend(() =>
755 ++attempts === 1
756 ? Effect.fail(
757 new DeviceOperationError({
758 operation: "update device tool",
759 reason: "command_failed",
760 cause: new Error("offline"),
761 }),
762 )
763 : Effect.void,
764 ),
765 );
766 const before = yield* service.state;
767 const result = yield* service.updateTool("agent").pipe(Effect.result);
768 expect(result._tag).toBe("Failure");
769 expect(yield* service.state).toEqual(before);
770 yield* service.updateTool("agent");
771 expect(attempts).toBe(2);
772 expect(starts).toEqual([]);
773 expect(agentStarts).toEqual([]);
774 }).pipe(Effect.scoped),
775);