1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { SecretProviderId, type SimlockDeviceHostConfig } from "@t3tools/contracts";
4import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Effect from "effect/Effect";
7import * as Clock from "effect/Clock";
8import * as Fiber from "effect/Fiber";
9import * as Deferred from "effect/Deferred";
10import * as Exit from "effect/Exit";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import * as TestClock from "effect/testing/TestClock";
16import { HttpClient, HttpClientResponse } from "effect/http";
17import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
18
19import * as ServerConfig from "../config.ts";
20import * as ProcessRunner from "../processRunner.ts";
21import * as SimlockDeviceHost from "./SimlockDeviceHost.ts";
22
23const GATEWAY = "https://simlock.test:4700";
24
25const config = (overrides: Partial<SimlockDeviceHostConfig> = {}): SimlockDeviceHostConfig => ({
26 kind: "simlock",
27 id: "pool",
28 label: "Device pool",
29 gatewayUrl: `${GATEWAY}/`,
30 agentToken: "agent-token",
31 maxLeases: 2,
32 maxLeaseMinutes: 60,
33 ...overrides,
34});
35
36const CATALOG = {
37 platforms: [
38 {
39 platform: "ios",
40 modelRuntimes: { "iPhone 17 Pro": ["27.0"], "Apple TV 4K": ["27.0"], "iPhone 12": [] },
41 modelClasses: { "iPhone 17 Pro": "phone", "Apple TV 4K": "tv", "iPhone 12": "phone" },
42 },
43 {
44 platform: "android",
45 modelRuntimes: { "Pixel 9": ["36"], "Pixel 8": ["36"] },
46 modelWorkers: { "Pixel 9": ["worker-a"], "Pixel 8": [] },
47 },
48 ],
49};
50
51/** The gateway's HTTP API and T3's agent-device CLI, recording what was asked of them. */
52const fakePool = (
53 options: {
54 /** What the agent-device CLI prints before exiting 1 from `open`. */
55 readonly failOpen?: unknown;
56 readonly holdOpen?: {
57 readonly entered: Deferred.Deferred<void>;
58 readonly proceed: Deferred.Deferred<void>;
59 };
60 /** The gateway's `lease.maxTtlMs`: leases expire one TTL after their last renewal. */
61 readonly maxTtlMs?: number;
62 } = {},
63) =>
64 Effect.gen(function* () {
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const requests: Array<string> = [];
68 const commands: Array<{ readonly args: ReadonlyArray<string>; readonly cwd?: string }> = [];
69 let leases = 0;
70 let renewStatus = 200;
71 let catalog: unknown = CATALOG;
72 let leaseDeadline = Infinity;
73 const http = HttpClient.make((request) =>
74 Effect.gen(function* () {
75 const now = yield* Clock.currentTimeMillis;
76 const url = new URL(request.url);
77 requests.push(`${request.method} ${url.pathname}`);
78 const json = (body: unknown, status = 200) =>
79 HttpClientResponse.fromWeb(request, Response.json(body, { status }));
80 if (request.headers.authorization !== "Bearer agent-token")
81 return json({ error: { code: "UNAUTHENTICATED", message: "Unknown bearer token" } }, 401);
82 if (url.pathname === "/v1/catalog") return json(catalog);
83 if (url.pathname.endsWith("/renew") && options.maxTtlMs !== undefined) {
84 if (now >= leaseDeadline) return json({}, 404);
85 const body =
86 request.body._tag === "Uint8Array"
87 ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { ttlMs?: number })
88 : {};
89 if ((body.ttlMs ?? 0) > options.maxTtlMs)
90 return json({ error: { code: "BAD_REQUEST", message: "ttlMs exceeds maxTtlMs" } }, 400);
91 leaseDeadline = now + (body.ttlMs ?? options.maxTtlMs);
92 return json({});
93 }
94 if (url.pathname.endsWith("/renew")) return json({}, renewStatus);
95 if (request.method === "DELETE") return json({}, 202);
96 return json({}, 404);
97 }),
98 );
99 const runner = ProcessRunner.ProcessRunner.of({
100 run: (input) =>
101 Effect.gen(function* () {
102 const args = input.args.slice(1);
103 commands.push({ args, ...(input.cwd ? { cwd: input.cwd } : {}) });
104 const command = args[0];
105 const session = args[args.indexOf("--session") + 1]!;
106 const configFile = args[args.indexOf("--config") + 1]!;
107 const stateDir = (
108 JSON.parse(yield* fs.readFileString(configFile)) as { stateDir: string }
109 ).stateDir;
110 if (command === "open") {
111 if (options.failOpen) {
112 return {
113 stdout: JSON.stringify(options.failOpen),
114 stderr: "",
115 code: ChildProcessSpawner.ExitCode(1),
116 timedOut: false,
117 stdoutTruncated: false,
118 stderrTruncated: false,
119 stdoutInvalidUtf8: false,
120 stderrInvalidUtf8: false,
121 };
122 }
123 if (options.holdOpen) {
124 yield* Deferred.succeed(options.holdOpen.entered, undefined);
125 yield* Deferred.await(options.holdOpen.proceed);
126 }
127 const dir = path.join(stateDir, "remote-connections");
128 yield* fs.makeDirectory(dir, { recursive: true });
129 yield* fs.writeFileString(
130 path.join(dir, `${session}.json`),
131 JSON.stringify({ leaseId: `lease-${++leases}` }),
132 );
133 if (options.maxTtlMs !== undefined)
134 leaseDeadline = (yield* Clock.currentTimeMillis) + options.maxTtlMs;
135 }
136 if (command === "screenshot") yield* fs.writeFile(args[1]!, new Uint8Array([1, 2, 3]));
137 return {
138 stdout: "{}",
139 stderr: "",
140 code: ChildProcessSpawner.ExitCode(0),
141 timedOut: false,
142 stdoutTruncated: false,
143 stderrTruncated: false,
144 stdoutInvalidUtf8: false,
145 stderrInvalidUtf8: false,
146 };
147 }).pipe(Effect.orDie),
148 });
149 const ran = (command: string) => commands.filter(({ args }) => args[0] === command);
150 return {
151 http,
152 runner,
153 requests,
154 ran,
155 endLeases: () => {
156 renewStatus = 404;
157 },
158 setCatalog: (next: unknown) => {
159 catalog = next;
160 },
161 };
162 });
163
164const makeSecretResolver = (values: Record<string, string> = {}) =>
165 Layer.succeed(SecretResolver.SecretResolver, {
166 read: (references) =>
167 Effect.succeed(references.map((reference) => values[reference.reference])),
168 invalidate: () => Effect.void,
169 label: (provider) => Effect.succeed(provider),
170 });
171
172const layerWith = (secrets: Layer.Layer<SecretResolver.SecretResolver>) =>
173 Layer.mergeAll(
174 ServerConfig.layerTest(process.cwd(), { prefix: "t3-simlock-host-" }),
175 secrets,
176 ).pipe(Layer.provideMerge(NodeServices.layer));
177
178const layer = layerWith(makeSecretResolver());
179
180const tokenSource = { provider: SecretProviderId.make("homelab"), reference: "simlock#token" };
181
182const makeHost = (
183 pool: Effect.Success<ReturnType<typeof fakePool>>,
184 hostConfig: SimlockDeviceHostConfig,
185 scope: Scope.Scope,
186) =>
187 SimlockDeviceHost.make(
188 hostConfig,
189 Effect.succeed({ nodePath: "node", entryPath: "/tools/agent-device.mjs" }),
190 ).pipe(
191 Effect.provideService(HttpClient.HttpClient, pool.http),
192 Effect.provideService(ProcessRunner.ProcessRunner, pool.runner),
193 Effect.provideService(Scope.Scope, scope),
194 );
195
196describe("Simlock device host", () => {
197 it.effect("leases a pool device for agents and serves its screen and input to the panel", () =>
198 Effect.gen(function* () {
199 const pool = yield* fakePool();
200 const fs = yield* FileSystem.FileSystem;
201 const workspace = yield* fs.makeTempDirectoryScoped();
202 const scope = yield* Scope.make();
203 const host = yield* makeHost(pool, config(), scope);
204 const ready = yield* host.ensureReady(() => Effect.void);
205
206 const catalog = (yield* ready.devices.list).devices;
207 // Only models a worker can make right now, and only phones and tablets.
208 expect(catalog.map((device) => [device.id, device.version])).toEqual([
209 ["simlock:ios:iPhone 17 Pro", "iOS 27.0"],
210 ["simlock:android:Pixel 9", "Android 36"],
211 ]);
212
213 const leaseId = yield* ready.devices.boot(catalog[1]!, { workspaceRoot: workspace });
214 expect(leaseId).toBe("lease-1");
215 const [open] = pool.ran("open");
216 expect(open?.args.slice(0, 5)).toEqual([
217 "open",
218 "--platform",
219 "android",
220 "--device",
221 "Pixel 9",
222 ]);
223 expect(open?.cwd).toBe(yield* fs.realPath(workspace));
224 expect(pool.ran("connect")[0]?.args).toContain(`${GATEWAY}/v1/agent-device`);
225
226 const binding = yield* ready.devices.agentEndpoint!(leaseId);
227 expect(binding).toMatchObject({
228 baseUrl: `${GATEWAY}/v1/agent-device`,
229 token: "agent-token",
230 agentDeviceId: null,
231 session: open?.args[open.args.indexOf("--session") + 1],
232 installDirectory: yield* fs.realPath(workspace),
233 });
234 expect((yield* ready.devices.list).devices.at(-1)).toMatchObject({
235 id: "lease-1",
236 name: "Pixel 9",
237 booted: true,
238 });
239
240 expect(
241 yield* ready.upstream!(
242 "/vendor/serve-sim/helper/lease-1/stream.avcc",
243 new URLSearchParams(),
244 ),
245 ).toEqual({
246 url: `${GATEWAY}/v1/leases/lease-1/stream`,
247 headers: { authorization: "Bearer agent-token" },
248 protocols: [],
249 });
250 expect(
251 yield* ready.upstream!(
252 "/vendor/serve-sim/helper/ws",
253 new URLSearchParams({ device: "lease-1" }),
254 ),
255 ).toEqual({
256 url: `${GATEWAY}/v1/leases/lease-1/input`,
257 headers: { authorization: "Bearer agent-token" },
258 protocols: [],
259 });
260 expect(
261 yield* ready.upstream!("/vendor/serve-sim/helper/lease-1/config", new URLSearchParams()),
262 ).toBeNull();
263 expect(
264 yield* ready.upstream!(
265 "/vendor/serve-sim/helper/ws",
266 new URLSearchParams({ device: "lease-2" }),
267 ),
268 ).toBeNull();
269 expect([...(yield* ready.devices.screenshot({ ...catalog[1]!, id: leaseId }))]).toEqual([
270 1, 2, 3,
271 ]);
272
273 yield* ready.devices.shutdown({ id: leaseId, platform: "android" });
274 expect(pool.ran("close")).toHaveLength(1);
275 expect((yield* ready.devices.list).devices.some((device) => device.booted)).toBe(false);
276 yield* Scope.close(scope, Exit.void);
277 expect(pool.ran("close")).toHaveLength(1);
278 }).pipe(Effect.scoped, Effect.provide(layer)),
279 );
280
281 it.effect("caps leases at the limit", () =>
282 Effect.gen(function* () {
283 const pool = yield* fakePool();
284 const scope = yield* Scope.make();
285 const host = yield* makeHost(pool, config({ maxLeases: 1 }), scope);
286 const ready = yield* host.ensureReady(() => Effect.void);
287 const [iphone] = (yield* ready.devices.list).devices;
288 yield* ready.devices.boot(iphone!);
289
290 const refused = yield* ready.devices.boot(iphone!).pipe(Effect.flip);
291 expect(refused.message).toContain("limited to 1 device");
292 expect(pool.ran("open")).toHaveLength(1);
293 yield* Scope.close(scope, Exit.void);
294 expect(pool.ran("close")).toHaveLength(1);
295 }).pipe(Effect.scoped, Effect.provide(layer)),
296 );
297
298 it.effect("reports its platforms unavailable when the gateway refuses the agent token", () =>
299 Effect.gen(function* () {
300 const pool = yield* fakePool();
301 const scope = yield* Scope.make();
302 const host = yield* makeHost(pool, config({ agentToken: "revoked" }), scope);
303 expect(yield* host.platformAvailability("ios")).toEqual({
304 platform: "ios",
305 available: false,
306 reason: "The Simlock gateway refused the agent token for Device pool.",
307 });
308 expect((yield* host.summary).platforms.every((platform) => !platform.available)).toBe(true);
309 yield* Scope.close(scope, Exit.void);
310 }).pipe(Effect.scoped, Effect.provide(layer)),
311 );
312
313 it.effect("sends saved and machine tokens only to the gateway each belongs to", () =>
314 Effect.gen(function* () {
315 const pool = yield* fakePool();
316 const availability = (baseUrl: string) =>
317 Effect.gen(function* () {
318 const scope = yield* Scope.make();
319 const host = yield* makeHost(pool, config({ agentToken: "" }), scope).pipe(
320 Effect.provideService(HostProcess.Environment, {
321 AGENT_DEVICE_DAEMON_BASE_URL: baseUrl,
322 AGENT_DEVICE_DAEMON_AUTH_TOKEN: "agent-token",
323 }),
324 );
325 const result = yield* host.platformAvailability("ios");
326 yield* Scope.close(scope, Exit.void);
327 return result;
328 });
329
330 expect(
331 yield* SimlockDeviceHost.resolveSimlockToken(
332 yield* SecretResolver.SecretResolver,
333 "https://elsewhere.example:4700",
334 config({ agentToken: "saved-token" }),
335 {},
336 ),
337 ).toBeNull();
338
339 expect(yield* availability(`${GATEWAY}/v1/agent-device`)).toEqual({
340 platform: "ios",
341 available: true,
342 });
343 const requests = pool.requests.length;
344 expect(yield* availability("https://elsewhere.example:4700/v1/agent-device")).toEqual({
345 platform: "ios",
346 available: false,
347 reason: "Add an agent token for Device pool in Settings.",
348 });
349 expect(pool.requests).toHaveLength(requests);
350 }).pipe(Effect.scoped, Effect.provide(layer)),
351 );
352
353 it.effect("reads its agent token from the secret provider the setting names", () =>
354 Effect.gen(function* () {
355 const pool = yield* fakePool();
356 const scope = yield* Scope.make();
357 const host = yield* makeHost(pool, config({ agentToken: tokenSource }), scope);
358 const ready = yield* host.ensureReady(() => Effect.void);
359 const [iphone] = (yield* ready.devices.list).devices;
360 const leaseId = yield* ready.devices.boot(iphone!);
361 expect(yield* ready.devices.agentEndpoint!(leaseId)).toMatchObject({ token: "agent-token" });
362 yield* Scope.close(scope, Exit.void);
363 expect(pool.ran("close")).toHaveLength(1);
364 }).pipe(
365 Effect.scoped,
366 Effect.provide(layerWith(makeSecretResolver({ "simlock#token": "agent-token" }))),
367 ),
368 );
369
370 it.effect("fails instead of using this machine's token when its secret cannot be read", () =>
371 Effect.gen(function* () {
372 const pool = yield* fakePool();
373 const scope = yield* Scope.make();
374 const host = yield* makeHost(pool, config({ agentToken: tokenSource }), scope).pipe(
375 Effect.provideService(HostProcess.Environment, {
376 AGENT_DEVICE_DAEMON_BASE_URL: `${GATEWAY}/v1/agent-device`,
377 AGENT_DEVICE_DAEMON_AUTH_TOKEN: "agent-token",
378 }),
379 );
380 const availability = yield* host.platformAvailability("ios");
381 expect(availability.available).toBe(false);
382 expect(availability.reason).toContain('secret provider "homelab"');
383 expect(pool.requests).toEqual([]);
384 yield* Scope.close(scope, Exit.void);
385 }).pipe(Effect.scoped, Effect.provide(layer)),
386 );
387
388 it.effect("lists a platform again as soon as its workers return", () =>
389 Effect.gen(function* () {
390 const pool = yield* fakePool();
391 const scope = yield* Scope.make();
392 const host = yield* makeHost(pool, config(), scope);
393 const ready = yield* host.ensureReady(() => Effect.void);
394 pool.setCatalog({ platforms: [] });
395 expect((yield* ready.devices.list).devices).toEqual([]);
396 // Mid-reconnect, the gateway knows the Android models but no worker that makes them.
397 pool.setCatalog({
398 platforms: [
399 CATALOG.platforms[0]!,
400 { ...CATALOG.platforms[1]!, modelWorkers: { "Pixel 9": [], "Pixel 8": [] } },
401 ],
402 });
403 const during = (yield* ready.devices.list).devices;
404 expect(during.some((device) => device.platform === "android")).toBe(false);
405 pool.setCatalog(CATALOG);
406 const after = (yield* ready.devices.list).devices;
407 expect(after.some((device) => device.platform === "android")).toBe(true);
408 yield* Scope.close(scope, Exit.void);
409 }).pipe(Effect.scoped, Effect.provide(layer)),
410 );
411
412 it.effect("closes the session of a failed open so no lease is left behind", () =>
413 Effect.gen(function* () {
414 const pool = yield* fakePool({
415 failOpen: { error: { message: "No device matches.", hint: "Retry." } },
416 });
417 const scope = yield* Scope.make();
418 const host = yield* makeHost(pool, config(), scope);
419 const ready = yield* host.ensureReady(() => Effect.void);
420 const [iphone] = (yield* ready.devices.list).devices;
421 const failure = yield* ready.devices.boot(iphone!).pipe(Effect.flip);
422 expect(String(failure.cause)).toContain("No device matches. Retry.");
423 expect(pool.ran("close")).toHaveLength(1);
424 // The failed open freed its slot.
425 expect(yield* ready.devices.boot(iphone!).pipe(Effect.flip)).toBeDefined();
426 yield* Scope.close(scope, Exit.void);
427 }).pipe(Effect.scoped, Effect.provide(layer)),
428 );
429
430 it.effect("tells the user what agent-device reported when a device fails to open", () =>
431 Effect.gen(function* () {
432 const pool = yield* fakePool({
433 failOpen: {
434 success: false,
435 error: {
436 code: "COMMAND_FAILED",
437 message: "Invalid daemon response",
438 cause: {
439 message: "Unexpected non-whitespace character after JSON at position 4",
440 },
441 hint: "Retry with --debug and inspect diagnostics log for details.",
442 details: { requestId: "22a6bf10a567b2a1", line: "404 Not Found" },
443 },
444 },
445 });
446 const scope = yield* Scope.make();
447 const host = yield* makeHost(pool, config(), scope);
448 const ready = yield* host.ensureReady(() => Effect.void);
449 const pixel = (yield* ready.devices.list).devices.find(({ name }) => name === "Pixel 9");
450 const failure = yield* ready.devices.boot(pixel!).pipe(Effect.flip);
451 expect(failure.message).not.toContain("The device host sent no details.");
452 expect(failure.message).toContain("Invalid daemon response");
453 expect(failure.message).toContain("404 Not Found");
454 yield* Scope.close(scope, Exit.void);
455
456 // A details bag without a usable response line still keeps agent-device's message.
457 const oddPool = yield* fakePool({
458 failOpen: { error: { message: "Invalid daemon response", details: { line: 17 } } },
459 });
460 const oddScope = yield* Scope.make();
461 const oddReady = yield* (yield* makeHost(oddPool, config(), oddScope)).ensureReady(
462 () => Effect.void,
463 );
464 const [oddDevice] = (yield* oddReady.devices.list).devices;
465 const oddFailure = yield* oddReady.devices.boot(oddDevice!).pipe(Effect.flip);
466 expect(oddFailure.message).toContain("Invalid daemon response");
467 yield* Scope.close(oddScope, Exit.void);
468 }).pipe(Effect.scoped, Effect.provide(layer)),
469 );
470
471 it.effect("keeps a watched lease renewed and stops listing it once Simlock ends it", () =>
472 Effect.gen(function* () {
473 const pool = yield* fakePool();
474 const scope = yield* Scope.make();
475 const host = yield* makeHost(pool, config(), scope);
476 const ready = yield* host.ensureReady(() => Effect.void);
477 const [iphone] = (yield* ready.devices.list).devices;
478 const leaseId = yield* ready.devices.boot(iphone!);
479
480 yield* TestClock.adjust("2 minutes");
481 expect(pool.requests).toContain(`POST /v1/leases/${leaseId}/renew`);
482 expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe(
483 true,
484 );
485
486 pool.endLeases();
487 yield* TestClock.adjust("2 minutes");
488 expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe(
489 false,
490 );
491 yield* Scope.close(scope, Exit.void);
492 expect(pool.ran("close")).toHaveLength(0);
493 }).pipe(Effect.scoped, Effect.provide(layer)),
494 );
495
496 it.effect("keeps a watched lease renewed when the gateway caps lease TTLs", () =>
497 Effect.gen(function* () {
498 const pool = yield* fakePool({ maxTtlMs: 5 * 60_000 });
499 const host = yield* makeHost(pool, config(), yield* Scope.make());
500 const ready = yield* host.ensureReady(() => Effect.void);
501 const [iphone] = (yield* ready.devices.list).devices;
502 const leaseId = yield* ready.devices.boot(iphone!);
503
504 yield* TestClock.adjust("6 minutes");
505 expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe(
506 true,
507 );
508 }).pipe(Effect.scoped, Effect.provide(layer)),
509 );
510
511 it.effect("returns a lease whose open finishes after the host stopped", () =>
512 Effect.gen(function* () {
513 const holdOpen = {
514 entered: yield* Deferred.make<void>(),
515 proceed: yield* Deferred.make<void>(),
516 };
517 const pool = yield* fakePool({ holdOpen });
518 const host = yield* makeHost(pool, config(), yield* Scope.make());
519 const ready = yield* host.ensureReady(() => Effect.void);
520 const [iphone] = (yield* ready.devices.list).devices;
521 const opening = yield* Effect.forkChild(ready.devices.boot(iphone!));
522 yield* Deferred.await(holdOpen.entered);
523 yield* host.stop;
524 yield* Deferred.succeed(holdOpen.proceed, undefined);
525 expect(Exit.isFailure(yield* Fiber.await(opening))).toBe(true);
526 expect(pool.ran("close")).toHaveLength(1);
527 expect((yield* ready.devices.list).devices.every((device) => !device.booted)).toBe(true);
528 }).pipe(Effect.scoped, Effect.provide(layer)),
529 );
530
531 it.effect("holds a lease for its full maximum age, then returns it", () =>
532 Effect.gen(function* () {
533 const pool = yield* fakePool();
534 const host = yield* makeHost(pool, config({ maxLeaseMinutes: 5 }), yield* Scope.make());
535 const ready = yield* host.ensureReady(() => Effect.void);
536 const [iphone] = (yield* ready.devices.list).devices;
537 yield* ready.devices.boot(iphone!);
538
539 yield* TestClock.adjust("299 seconds");
540 expect(pool.ran("close")).toHaveLength(0);
541 yield* TestClock.adjust("1 second");
542 expect(pool.ran("close")).toHaveLength(1);
543 }).pipe(Effect.scoped, Effect.provide(layer)),
544 );
545});