Open History to see the last commit
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { type EasDeviceHostConfig, SecretProviderId, ThreadId } from "@t3tools/contracts";
5import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as Exit from "effect/Exit";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Scope from "effect/Scope";
14import * as TestClock from "effect/testing/TestClock";
15import { HttpClient, HttpClientResponse } from "effect/http";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17
18import * as ServerConfig from "../config.ts";
19import * as ProcessRunner from "../processRunner.ts";
20import * as ServerSettings from "../serverSettings.ts";
21import * as DeviceService from "./DeviceService.ts";
22import * as EasDeviceHost from "./EasDeviceHost.ts";
23
24const APP_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454";
25
26const config = (overrides: Partial<EasDeviceHostConfig> = {}): EasDeviceHostConfig => ({
27 kind: "eas",
28 id: "eas",
29 label: "Expo Simulators",
30 appId: APP_ID,
31 accessToken: "expo-token",
32 maxSessionMinutes: 25,
33 idleMinutes: 5,
34 maxConcurrentSessions: 2,
35 ...overrides,
36});
37
38interface Gate {
39 readonly requested: Deferred.Deferred<void>;
40 readonly release: Deferred.Deferred<void>;
41}
42const makeGate = Effect.all({
43 requested: Deferred.make<void>(),
44 release: Deferred.make<void>(),
45});
46
47/** Expo's API and one session's preview host, recording what was asked of them. */
48const fakeExpo = (
49 options: {
50 readonly status?: () => string;
51 readonly failCreate?: boolean;
52 /** Expo answers the create with GraphQL errors: a refusal. */
53 readonly rejectCreate?: boolean;
54 readonly failStop?: () => boolean;
55 readonly failList?: () => boolean;
56 readonly active?: ReadonlyArray<{ id: string; status: string; name: string }>;
57 readonly previewGate?: Gate;
58 readonly createGate?: Gate;
59 /** Holds the first session read it answers with a gate. */
60 readonly sessionGate?: () => Gate | undefined;
61 readonly activeIncludesCreated?: boolean;
62 readonly failPreview?: boolean;
63 readonly omitCreatedFromActive?: boolean;
64 /** The session's agent-device daemon answers with a server error. */
65 readonly failAgent?: () => boolean;
66 } = {},
67) => {
68 const calls: Array<{ operation: string; variables: Record<string, unknown> }> = [];
69 const releases: Array<unknown> = [];
70 let created = 0;
71 let createdName = "";
72 const previews: string[] = [];
73 let previewFails = options.failPreview ?? false;
74 let createFails = options.failCreate ?? false;
75 let sessionGated = false;
76 const http = HttpClient.make((request) =>
77 Effect.gen(function* () {
78 const url = new URL(request.url);
79 if (url.origin === "https://preview.test") {
80 previews.push(`${url.pathname}${url.search}`);
81 expect(request.headers.authorization).toBe("Bearer preview-token");
82 if (options.previewGate) {
83 yield* Deferred.succeed(options.previewGate.requested, undefined);
84 yield* Deferred.await(options.previewGate.release);
85 }
86 if (previewFails) {
87 return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 }));
88 }
89 if (url.pathname.endsWith("/api/screenshot")) {
90 return HttpClientResponse.fromWeb(request, new Response(Uint8Array.of(137, 80, 78, 71)));
91 }
92 return HttpClientResponse.fromWeb(
93 request,
94 Response.json(
95 url.pathname === "/vendor/serve-emu/api"
96 ? { serial: "emulator-5554" }
97 : { device: "UDID-1" },
98 ),
99 );
100 }
101 if (url.origin === "https://agent.test") {
102 expect(request.headers.authorization).toBe("Bearer agent-token");
103 if (request.body._tag === "Uint8Array") {
104 const rpc = JSON.parse(new TextDecoder().decode(request.body.body)) as {
105 params: { command: string; session: string };
106 };
107 if (rpc.params.command === "close") releases.push(rpc.params.session);
108 }
109 if (options.failAgent?.())
110 return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 }));
111 return HttpClientResponse.fromWeb(request, Response.json({ result: { ok: true } }));
112 }
113 const body =
114 request.body._tag === "Uint8Array"
115 ? (JSON.parse(new TextDecoder().decode(request.body.body)) as {
116 query: string;
117 variables: Record<string, { name?: string } & Record<string, unknown>>;
118 })
119 : { query: "", variables: {} };
120 expect(request.headers.authorization).toBe("Bearer expo-token");
121 const operation = /(?:query|mutation) (\w+)/.exec(body.query)?.[1] ?? "unknown";
122 calls.push({ operation, variables: body.variables });
123 if (operation === "T3CreateEasSimulatorSession") {
124 createdName = body.variables.input?.name ?? "";
125 if (options.createGate) {
126 yield* Deferred.succeed(options.createGate.requested, undefined);
127 yield* Deferred.await(options.createGate.release);
128 }
129 if (createFails)
130 return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 }));
131 if (options.rejectCreate)
132 return HttpClientResponse.fromWeb(
133 request,
134 Response.json({
135 errors: [
136 {
137 message: "Device run sessions are not enabled for this account.",
138 extensions: { errorType: "USER" },
139 },
140 ],
141 data: null,
142 }),
143 );
144 }
145 if (operation === "T3StopEasSimulatorSession" && options.failStop?.()) {
146 return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 }));
147 }
148 if (operation === "T3ActiveEasSimulatorSessions" && options.failList?.()) {
149 return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 }));
150 }
151 // The account's feature gate is off, as for an Expo admin whom the create still admits.
152 if (body.query.includes("accountFeatureGates")) {
153 return HttpClientResponse.fromWeb(
154 request,
155 Response.json({
156 data: {
157 app: {
158 byId: {
159 ownerAccount: {
160 name: "pingdotgg",
161 accountFeatureGates: { "device-run-sessions": false },
162 },
163 },
164 },
165 },
166 }),
167 );
168 }
169 const sessionGate =
170 operation === "T3EasSimulatorSession" ? options.sessionGate?.() : undefined;
171 if (sessionGate && !sessionGated) {
172 sessionGated = true;
173 yield* Deferred.succeed(sessionGate.requested, undefined);
174 yield* Deferred.await(sessionGate.release);
175 }
176 const data =
177 operation === "T3CreateEasSimulatorSession"
178 ? { deviceRunSession: { createDeviceRunSession: { id: `session-${++created}` } } }
179 : operation === "T3ActiveEasSimulatorSessions"
180 ? {
181 app: {
182 byId: {
183 deviceRunSessionsPaginated: {
184 edges: [
185 ...(options.omitCreatedFromActive
186 ? []
187 : [{ node: { id: "orphan", status: "NEW", name: createdName } }]),
188 {
189 node: {
190 id: "someone-else",
191 status: "IN_PROGRESS",
192 name: "T3 Code · Expo Simulators · 000000",
193 },
194 },
195 ...(options.active ?? []).map((node) => ({ node })),
196 ...(options.activeIncludesCreated && created > 0
197 ? [
198 {
199 node: {
200 id: `session-${created}`,
201 status: "STARTING",
202 name: createdName,
203 },
204 },
205 ]
206 : []),
207 ],
208 },
209 },
210 },
211 }
212 : operation === "T3EasSimulatorSession"
213 ? {
214 deviceRunSessions: {
215 byId: {
216 id: body.variables.id,
217 status: options.status?.() ?? "IN_PROGRESS",
218 remoteConfig: {
219 __typename: "AgentDeviceRunSessionRemoteConfig",
220 agentDeviceRemoteSessionUrl: "https://agent.test",
221 agentDeviceRemoteSessionToken: "agent-token",
222 webPreviewToken: "preview-token",
223 previewApiUrl: "https://preview.test?token=preview-token",
224 },
225 },
226 },
227 }
228 : {
229 deviceRunSession: {
230 ensureDeviceRunSessionStopped: { id: body.variables.id, status: "STOPPED" },
231 },
232 };
233 return HttpClientResponse.fromWeb(request, Response.json({ data }));
234 }),
235 );
236 const named = (operation: string) => calls.filter((call) => call.operation === operation);
237 const stopped = () => named("T3StopEasSimulatorSession").map((call) => call.variables.id);
238 const allowPreview = () => {
239 previewFails = false;
240 };
241 const allowCreate = () => {
242 createFails = false;
243 };
244 return { http, named, stopped, allowPreview, allowCreate, releases, previews };
245};
246
247const makeSecretResolver = (values: Record<string, string> = {}) =>
248 Layer.succeed(SecretResolver.SecretResolver, {
249 label: (provider) => Effect.succeed(provider),
250 read: (references) =>
251 Effect.succeed(references.map((reference) => values[reference.reference])),
252 invalidate: () => Effect.void,
253 });
254
255const layerWith = (secrets: Layer.Layer<SecretResolver.SecretResolver>) =>
256 Layer.mergeAll(
257 ProcessRunner.layer,
258 ServerConfig.layerTest(process.cwd(), { prefix: "t3-eas-host-" }),
259 secrets,
260 ).pipe(Layer.provideMerge(NodeServices.layer));
261
262const layer = Layer.mergeAll(
263 ProcessRunner.layer,
264 ServerConfig.layerTest(process.cwd(), { prefix: "t3-eas-host-" }),
265 makeSecretResolver(),
266).pipe(Layer.provideMerge(NodeServices.layer));
267
268const makeHost = (
269 expo: ReturnType<typeof fakeExpo>,
270 hostConfig: EasDeviceHostConfig,
271 scope: Scope.Scope,
272) =>
273 EasDeviceHost.make(hostConfig).pipe(
274 Effect.provideService(HttpClient.HttpClient, expo.http),
275 Effect.provideService(Scope.Scope, scope),
276 );
277
278describe("Expo Simulators host", () => {
279 it.effect("starts a bounded session and routes its stream and agent to that session", () =>
280 Effect.gen(function* () {
281 const expo = fakeExpo();
282 const scope = yield* Scope.make();
283 const host = yield* makeHost(expo, config(), scope);
284 const ready = yield* host.ensureReady(() => Effect.void);
285 const catalog = (yield* ready.devices.list).devices;
286 expect(catalog.map((device) => [device.id, device.booted])).toEqual([
287 ["eas-ios", false],
288 ["eas-android", false],
289 ]);
290
291 expect(yield* ready.devices.boot(catalog[0]!)).toBe("session-1");
292 expect(expo.named("T3CreateEasSimulatorSession")[0]?.variables.input).toMatchObject({
293 appId: APP_ID,
294 platform: "IOS",
295 type: "AGENT_DEVICE",
296 maxRunTimeMinutes: 25,
297 maxIdleTimeMinutes: 5,
298 });
299 const listed = (yield* ready.devices.list).devices;
300 expect(listed.find((device) => device.id === "session-1")?.booted).toBe(true);
301 expect(yield* ready.devices.agentEndpoint!("session-1")).toEqual({
302 baseUrl: "https://agent.test",
303 token: "agent-token",
304 agentDeviceId: "UDID-1",
305 });
306
307 const routed = yield* ready.upstream!(
308 "/vendor/serve-sim/helper/session-1/webrtc/offer",
309 new URLSearchParams("hostId=eas"),
310 );
311 expect(routed).toEqual({
312 url: "https://preview.test/helper/UDID-1/webrtc/offer?hostId=eas",
313 headers: { authorization: "Bearer preview-token" },
314 protocols: ["serve-sim.token.preview-token"],
315 });
316 expect(
317 yield* ready.upstream!(
318 "/vendor/serve-sim/helper/ws",
319 new URLSearchParams("device=session-1"),
320 ),
321 ).toMatchObject({ url: "https://preview.test/helper/ws?device=UDID-1" });
322 expect(yield* ready.upstream!("/vendor/serve-emu/ws", new URLSearchParams())).toBeNull();
323 expect(
324 yield* ready.upstream!("/vendor/serve-sim/helper/%E0/config", new URLSearchParams()),
325 ).toBeNull();
326
327 yield* Scope.close(scope, Exit.void);
328 expect(expo.named("T3StopEasSimulatorSession").map((call) => call.variables.id)).toEqual([
329 "session-1",
330 ]);
331 }).pipe(Effect.scoped, Effect.provide(layer)),
332 );
333
334 it.effect("reads its token from the secret provider the setting names", () =>
335 Effect.gen(function* () {
336 const expo = fakeExpo();
337 const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" };
338 const host = yield* makeHost(expo, config({ accessToken: source }), yield* Scope.make());
339 const ready = yield* host.ensureReady(() => Effect.void);
340 const [catalogDevice] = (yield* ready.devices.list).devices;
341 expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1");
342 }).pipe(
343 Effect.scoped,
344 Effect.provide(layerWith(makeSecretResolver({ "expo#token": "expo-token" }))),
345 ),
346 );
347
348 it.effect("fails instead of using another Expo login when its secret cannot be read", () =>
349 Effect.gen(function* () {
350 const expo = fakeExpo();
351 const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" };
352 const host = yield* makeHost(expo, config({ accessToken: source }), yield* Scope.make()).pipe(
353 Effect.provideService(HostProcess.Environment, { EXPO_TOKEN: "expo-token" }),
354 );
355 const ready = yield* host.ensureReady(() => Effect.void);
356 const [catalogDevice] = (yield* ready.devices.list).devices;
357 const failure = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
358 expect(failure.message).toContain('secret provider "homelab"');
359 expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(0);
360 }).pipe(Effect.scoped, Effect.provide(layer)),
361 );
362
363 it.effect("still stops its sessions when the secret becomes unreadable after they start", () => {
364 const readable = { value: true };
365 return Effect.gen(function* () {
366 const expo = fakeExpo();
367 const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" };
368 const scope = yield* Scope.make();
369 const host = yield* makeHost(expo, config({ accessToken: source }), scope);
370 const ready = yield* host.ensureReady(() => Effect.void);
371 const [catalogDevice] = (yield* ready.devices.list).devices;
372 expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1");
373
374 readable.value = false;
375 yield* ready.devices.shutdown({ ...catalogDevice!, id: "session-1" });
376 expect(expo.stopped()).toEqual(["session-1"]);
377 yield* Scope.close(scope, Exit.void);
378 }).pipe(
379 Effect.scoped,
380 Effect.provide(
381 layerWith(
382 Layer.succeed(SecretResolver.SecretResolver, {
383 label: (provider) => Effect.succeed(provider),
384 read: (references) =>
385 Effect.succeed(references.map(() => (readable.value ? "expo-token" : undefined))),
386 invalidate: () => Effect.void,
387 }),
388 ),
389 ),
390 );
391 });
392
393 it.effect("runs an Android session through serve-emu beside an iOS one", () =>
394 Effect.gen(function* () {
395 const expo = fakeExpo();
396 const scope = yield* Scope.make();
397 const host = yield* makeHost(expo, config(), scope);
398 expect(yield* host.platformAvailability("android")).toEqual({
399 platform: "android",
400 available: true,
401 });
402 const ready = yield* host.ensureReady(() => Effect.void);
403 const [ios, android] = (yield* ready.devices.list).devices;
404 expect(yield* ready.devices.boot(ios!)).toBe("session-1");
405 expect(yield* ready.devices.boot(android!)).toBe("session-2");
406 expect(expo.named("T3CreateEasSimulatorSession")[1]?.variables.input).toMatchObject({
407 platform: "ANDROID",
408 });
409 const running = (yield* ready.devices.list).devices.find(
410 (device) => device.id === "session-2",
411 );
412 expect(running).toMatchObject({ platform: "android", booted: true });
413
414 expect(yield* ready.devices.agentEndpoint!("session-2")).toMatchObject({
415 agentDeviceId: "emulator-5554",
416 });
417 expect(Array.from(yield* ready.devices.screenshot(running!))).toEqual([137, 80, 78, 71]);
418 expect(expo.previews.slice(-2)).toEqual([
419 "/vendor/serve-emu/api",
420 "/vendor/serve-emu/api/screenshot?device=emulator-5554",
421 ]);
422 expect(
423 yield* ready.upstream!(
424 "/vendor/serve-emu/webrtc/offer",
425 new URLSearchParams("device=session-2&hostId=eas"),
426 ),
427 ).toEqual({
428 url: "https://preview.test/vendor/serve-emu/webrtc/offer?device=emulator-5554&hostId=eas",
429 headers: { authorization: "Bearer preview-token" },
430 protocols: ["serve-emu.token.preview-token"],
431 });
432 // Each session answers only on its own platform's stream.
433 expect(
434 yield* ready.upstream!("/vendor/serve-emu/api", new URLSearchParams("device=session-1")),
435 ).toBeNull();
436 expect(
437 yield* ready.upstream!("/vendor/serve-sim/api", new URLSearchParams("device=session-2")),
438 ).toBeNull();
439 }).pipe(Effect.scoped, Effect.provide(layer)),
440 );
441
442 it.effect("refuses to start past its session limit", () =>
443 Effect.gen(function* () {
444 const expo = fakeExpo();
445 const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make());
446 const ready = yield* host.ensureReady(() => Effect.void);
447 const [catalogDevice] = (yield* ready.devices.list).devices;
448 yield* ready.devices.boot(catalogDevice!);
449 const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
450 expect(refused.message).toBe(
451 "Expo Simulators is unavailable: Expo Simulators is limited to 1 running session. Close one in the Device panel first.",
452 );
453 expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(1);
454 }).pipe(Effect.scoped, Effect.provide(layer)),
455 );
456
457 it.effect("tells concurrent sessions apart although Expo gives each the same UDID", () =>
458 Effect.gen(function* () {
459 const expo = fakeExpo();
460 const scope = yield* Scope.make();
461 const host = yield* makeHost(expo, config({ maxConcurrentSessions: 2 }), scope);
462 const ready = yield* host.ensureReady(() => Effect.void);
463 const [catalogDevice] = (yield* ready.devices.list).devices;
464 expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1");
465 expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2");
466 expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
467 "eas-ios",
468 "eas-android",
469 "session-1",
470 "session-2",
471 ]);
472 expect(
473 yield* ready.upstream!(
474 "/vendor/serve-sim/api",
475 new URLSearchParams("device=session-2&hostId=eas"),
476 ),
477 ).toMatchObject({ url: "https://preview.test/api?device=UDID-1&hostId=eas" });
478
479 yield* ready.devices.shutdown({ id: "session-1", platform: "ios" });
480 expect(expo.stopped()).toEqual(["session-1"]);
481 expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
482 "eas-ios",
483 "eas-android",
484 "session-2",
485 ]);
486 yield* Scope.close(scope, Exit.void);
487 expect(expo.stopped()).toEqual(["session-1", "session-2"]);
488 }).pipe(Effect.scoped, Effect.provide(layer)),
489 );
490
491 it.effect(
492 "keeps a refused create's record for the reaper without counting it after a restart",
493 () =>
494 Effect.gen(function* () {
495 const fs = yield* FileSystem.FileSystem;
496 const path = yield* Path.Path;
497 const server = yield* ServerConfig.ServerConfig;
498 const ledger = path.join(server.stateDir, "device", "eas", "eas.json");
499 // Expo refuses the create and lists nothing.
500 const expo = fakeExpo({ rejectCreate: true, omitCreatedFromActive: true });
501 const first = yield* Scope.make();
502 const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), first);
503 const ready = yield* host.ensureReady(() => Effect.void);
504 const [catalogDevice] = (yield* ready.devices.list).devices;
505 yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
506 const entries = JSON.parse(yield* fs.readFileString(ledger)) as Array<{
507 refused?: boolean;
508 }>;
509 expect(entries.map((entry) => entry.refused)).toEqual([true]);
510 yield* Scope.close(first, Exit.void);
511 // A later server cannot even list the app: the record must not hold a slot.
512 const later = fakeExpo({ failList: () => true });
513 const restarted = yield* makeHost(
514 later,
515 config({ maxConcurrentSessions: 1 }),
516 yield* Scope.make(),
517 );
518 const readyAgain = yield* restarted.ensureReady(() => Effect.void);
519 const [device] = (yield* readyAgain.devices.list).devices;
520 expect(yield* readyAgain.devices.boot(device!)).toBe("session-1");
521 }).pipe(Effect.scoped, Effect.provide(layer)),
522 );
523
524 it.effect(
525 "does not sweep a replacement's session from a listing in flight when its scope closes",
526 () =>
527 Effect.gen(function* () {
528 const gate = yield* makeGate;
529 let armed = false;
530 const expo = fakeExpo({ sessionGate: () => (armed ? gate : undefined) });
531 const first = yield* Scope.make();
532 const old = yield* makeHost(expo, config(), first);
533 const oldReady = yield* old.ensureReady(() => Effect.void);
534 const [catalogDevice] = (yield* oldReady.devices.list).devices;
535 expect(yield* oldReady.devices.boot(catalogDevice!)).toBe("session-1");
536 // Long enough that the listing reads the session's status and a sweep is due.
537 yield* TestClock.adjust("2 minutes");
538 armed = true;
539 const listing = yield* oldReady.devices.list.pipe(Effect.forkChild);
540 yield* Deferred.await(gate.requested);
541 // Settings saved while the old listing waits on Expo.
542 yield* Scope.close(first, Exit.void);
543 const replacement = yield* makeHost(expo, config(), yield* Scope.make());
544 const ready = yield* replacement.ensureReady(() => Effect.void);
545 const [device] = (yield* ready.devices.list).devices;
546 expect(yield* ready.devices.boot(device!)).toBe("session-2");
547 yield* Deferred.succeed(gate.release, undefined);
548 yield* Fiber.join(listing);
549 expect(expo.stopped()).toEqual(["session-1"]);
550 }).pipe(Effect.scoped, Effect.provide(layer)),
551 );
552
553 it.effect("leaves admission to Expo's create although the account's gate is off", () =>
554 Effect.gen(function* () {
555 const expo = fakeExpo();
556 const host = yield* makeHost(expo, config(), yield* Scope.make());
557 const ready = yield* host.ensureReady(() => Effect.void);
558 const [catalogDevice] = (yield* ready.devices.list).devices;
559 expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1");
560 expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(1);
561 }).pipe(Effect.scoped, Effect.provide(layer)),
562 );
563
564 it.effect("stops the session a refused create made anyway", () =>
565 Effect.gen(function* () {
566 // Expo refuses the create but lists a session by its name.
567 const expo = fakeExpo({ rejectCreate: true });
568 const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make());
569 const ready = yield* host.ensureReady(() => Effect.void);
570 const [catalogDevice] = (yield* ready.devices.list).devices;
571 const failure = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
572 expect(failure.message).toBe(
573 "Expo Simulators is unavailable: Expo could not start a simulator session: Device run sessions are not enabled for this account.",
574 );
575 expect(expo.stopped()).toEqual(["orphan"]);
576 }).pipe(Effect.scoped, Effect.provide(layer)),
577 );
578
579 it.effect("records a session before creating it and refuses to start without the record", () =>
580 Effect.gen(function* () {
581 const fs = yield* FileSystem.FileSystem;
582 const path = yield* Path.Path;
583 const server = yield* ServerConfig.ServerConfig;
584 // A directory where the ledger file should be: it can be neither read nor written.
585 yield* fs.makeDirectory(path.join(server.stateDir, "device", "eas", "eas.json"), {
586 recursive: true,
587 });
588 const expo = fakeExpo();
589 const host = yield* makeHost(expo, config(), yield* Scope.make());
590 const ready = yield* host.ensureReady(() => Effect.void);
591 const [catalogDevice] = (yield* ready.devices.list).devices;
592 const failure = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
593 expect(failure.message).toContain("could not be read");
594 expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(0);
595 }).pipe(Effect.scoped, Effect.provide(layer)),
596 );
597
598 it.effect("keeps a session it could not stop counted, listed, and retried", () =>
599 Effect.gen(function* () {
600 let stopFails = true;
601 const expo = fakeExpo({ failStop: () => stopFails });
602 const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make());
603 const ready = yield* host.ensureReady(() => Effect.void);
604 const [catalogDevice] = (yield* ready.devices.list).devices;
605 yield* ready.devices.boot(catalogDevice!);
606 yield* ready.devices.shutdown({ id: "session-1", platform: "ios" }).pipe(Effect.flip);
607 expect((yield* ready.devices.list).devices.map((device) => device.id)).toContain("session-1");
608 const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
609 expect(refused.message).toContain("limited to 1 running session");
610 stopFails = false;
611 yield* ready.devices.list;
612 expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
613 "eas-ios",
614 "eas-android",
615 ]);
616 expect(expo.stopped().filter((id) => id === "session-1").length).toBeGreaterThanOrEqual(2);
617 }).pipe(Effect.scoped, Effect.provide(layer)),
618 );
619
620 it.effect("lets go of a stopping session Expo has ended although its stop keeps failing", () =>
621 Effect.gen(function* () {
622 let status = "IN_PROGRESS";
623 const expo = fakeExpo({ failStop: () => true, status: () => status });
624 const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make());
625 const ready = yield* host.ensureReady(() => Effect.void);
626 const [catalogDevice] = (yield* ready.devices.list).devices;
627 yield* ready.devices.boot(catalogDevice!);
628 yield* ready.devices.shutdown({ id: "session-1", platform: "ios" }).pipe(Effect.flip);
629 expect((yield* ready.devices.list).devices.map((device) => device.id)).toContain("session-1");
630 // Expo ended it by itself: the stop keeps failing, but the status says so.
631 status = "STOPPED";
632 expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
633 "eas-ios",
634 "eas-android",
635 ]);
636 status = "IN_PROGRESS";
637 expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2");
638 }).pipe(Effect.scoped, Effect.provide(layer)),
639 );
640
641 it.effect("asks for an Expo project before starting anything when none can be found", () =>
642 Effect.gen(function* () {
643 const expo = fakeExpo();
644 const host = yield* makeHost(expo, config({ appId: undefined }), yield* Scope.make());
645 const ready = yield* host.ensureReady(() => Effect.void);
646 const [catalogDevice] = (yield* ready.devices.list).devices;
647 const failure = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
648 expect(failure.message).toContain("Choose one for this host in Settings");
649 expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(0);
650 }).pipe(Effect.scoped, Effect.provide(layer)),
651 );
652
653 it.effect("reports iOS unavailable on a server with no Expo credential", () =>
654 Effect.gen(function* () {
655 const fs = yield* FileSystem.FileSystem;
656 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-expo-home-" });
657 const host = yield* makeHost(
658 fakeExpo(),
659 config({ accessToken: "" }),
660 yield* Scope.make(),
661 ).pipe(
662 Effect.provideService(HostProcess.HomeDirectory, home),
663 Effect.provideService(HostProcess.Environment, {}),
664 );
665 const [ios] = (yield* host.summary).platforms;
666 expect(ios).toMatchObject({ platform: "ios", available: false });
667 expect(ios?.reason).toContain("No Expo credential on the server");
668 }).pipe(Effect.scoped, Effect.provide(layer)),
669 );
670
671 it.effect("stops a session that is still starting when the host stops", () =>
672 Effect.gen(function* () {
673 const previewGate = yield* makeGate;
674 const expo = fakeExpo({ previewGate });
675 const host = yield* makeHost(expo, config(), yield* Scope.make());
676 const ready = yield* host.ensureReady(() => Effect.void);
677 const [catalogDevice] = (yield* ready.devices.list).devices;
678 const booting = yield* ready.devices.boot(catalogDevice!).pipe(Effect.forkChild);
679 yield* Deferred.await(previewGate.requested);
680 // Never released: stopping must not wait for the session to become ready.
681 yield* host.stop;
682 expect(expo.stopped()).toEqual(["session-1"]);
683 const failure = yield* Fiber.join(booting).pipe(Effect.flip);
684 expect(failure.message).toContain("was stopped while the session started");
685 expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
686 "eas-ios",
687 "eas-android",
688 ]);
689 }).pipe(Effect.scoped, Effect.provide(layer)),
690 );
691
692 it.effect("releases each thread's agent session on close and keeps the session running", () =>
693 Effect.gen(function* () {
694 let agentFails = false;
695 const expo = fakeExpo({ failAgent: () => agentFails });
696 const host = yield* makeHost(expo, config(), yield* Scope.make());
697 const service = yield* DeviceService.makeWithHosts(new Map([["eas", host]]), undefined, () =>
698 Effect.succeed("/eas-agent.json"),
699 );
700 const first = ThreadId.make("first");
701 const second = ThreadId.make("second");
702 const opened = yield* service.open({
703 threadId: first,
704 hostId: "eas",
705 deviceId: "eas-ios",
706 platform: "ios",
707 });
708 expect(opened.deviceId).toBe("session-1");
709 yield* service.open({
710 threadId: second,
711 hostId: "eas",
712 deviceId: opened.deviceId,
713 platform: "ios",
714 });
715 const sessionOf = (threadId: ThreadId) =>
716 service
717 .agentTarget({ threadId, hostId: "eas", deviceId: opened.deviceId })
718 .pipe(Effect.map((target) => target.args[3]));
719 const firstSession = yield* sessionOf(first);
720 const secondSession = yield* sessionOf(second);
721
722 yield* service.close({ threadId: first });
723 expect(expo.releases).toEqual([firstSession]);
724 expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([second]);
725
726 agentFails = true;
727 const failed = yield* Effect.exit(service.close({ threadId: second, shutdown: false }));
728 expect(Exit.isFailure(failed)).toBe(true);
729 expect((yield* service.state).sessions.map((entry) => entry.threadId)).toEqual([second]);
730
731 agentFails = false;
732 yield* service.close({ threadId: second, shutdown: false });
733 expect(expo.releases).toEqual([firstSession, secondSession, secondSession]);
734 expect((yield* service.state).sessions).toEqual([]);
735 expect(expo.stopped()).not.toContain("session-1");
736 expect(
737 (yield* service.state).devices.find((device) => device.id === "session-1")?.booted,
738 ).toBe(true);
739 }).pipe(
740 Effect.scoped,
741 Effect.provide(
742 Layer.mergeAll(
743 layer,
744 NodeCrypto.layer,
745 ServerSettings.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }),
746 ),
747 ),
748 ),
749 );
750});