Skip to content

Commit 812c0a7

Browse files
SchroederNathanjuliusmarmingeclaude
authored
feat(devices): support Android EAS Simulator sessions (#26797)
Co-authored-by: Julius Marminge <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent dd14848 commit 812c0a7

12 files changed

Lines changed: 405 additions & 100 deletions

File tree

‎apps/server/src/device/DeviceHubProxy.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,32 @@ describe("device hub proxy", () => {
169169
expect(headers[0]?.["content-type"]).toBe("application/json");
170170
});
171171

172+
it("lets a viewer read Android stream capabilities and signal WebRTC with read scope", async () => {
173+
const { handler, requests } = fixture([AuthOrchestrationReadScope]);
174+
const hub = "http://t3.test/api/device-hub/vendor/serve-emu";
175+
const api = await handler(new Request(`${hub}/api?device=session-1`));
176+
expect(api.status).toBe(200);
177+
await api.text();
178+
for (const action of ["offer", "close"]) {
179+
const signal = await handler(
180+
new Request(`${hub}/webrtc/${action}?device=session-1`, {
181+
method: "POST",
182+
headers: { "content-type": "application/json" },
183+
body: '{"sessionId":"viewer"}',
184+
}),
185+
);
186+
expect(signal.status).toBe(200);
187+
await signal.text();
188+
}
189+
expect(requests).toEqual([
190+
"http://hub.test/vendor/serve-emu/api?device=session-1",
191+
"http://hub.test/vendor/serve-emu/webrtc/offer?device=session-1",
192+
"http://hub.test/vendor/serve-emu/webrtc/close?device=session-1",
193+
]);
194+
expect((await handler(new Request(`${hub}/api`, { method: "POST" }))).status).toBe(405);
195+
expect((await handler(new Request(`${hub}/webrtc/offer`))).status).toBe(405);
196+
});
197+
172198
it("sends requests a host routes itself to its upstream with its credentials", async () => {
173199
const { handler, requests, headers } = fixture(
174200
[AuthOrchestrationReadScope],

‎apps/server/src/device/DeviceHubProxy.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@ const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
2727
/^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/,
2828
/^\/vendor\/serve-sim\/helper\/[^/]+\/webrtc\/(offer|close)$/,
2929
/^\/vendor\/serve-sim\/appstate$/,
30+
/^\/vendor\/serve-emu\/api$/,
3031
/^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility|fold)$/,
3132
/^\/vendor\/serve-emu\/health$/,
3233
];
3334

35+
// Paths that take methods besides GET; one missing from ALLOWED_PATHS takes no GET.
3436
const MUTABLE_PATHS: ReadonlyArray<RegExp> = [
3537
/^\/vendor\/serve-sim\/api\/screenshot$/,
3638
// WebRTC signalling: opening and closing a view of the stream, so read scope.
3739
/^\/vendor\/serve-sim\/helper\/[^/]+\/webrtc\/(offer|close)$/,
40+
/^\/vendor\/serve-emu\/webrtc\/(offer|close)$/,
3841
/^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/,
3942
/^\/vendor\/serve-emu\/api\/fold$/,
4043
];
@@ -178,14 +181,15 @@ const handler = Effect.gen(function* () {
178181
}
179182
const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/";
180183
const upgrade = isWebSocketUpgrade(request);
181-
const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) =>
182-
pattern.test(hubPath),
183-
);
184+
const matches = (paths: ReadonlyArray<RegExp>) => paths.some((pattern) => pattern.test(hubPath));
185+
const allowed = upgrade
186+
? matches(ALLOWED_WS_PATHS)
187+
: matches(ALLOWED_PATHS) || matches(MUTABLE_PATHS);
184188
if (!allowed) {
185189
return HttpServerResponse.text("Not Found", { status: 404 });
186190
}
187191
const readOnly = request.method === "GET" || request.method === "HEAD";
188-
if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) {
192+
if (!upgrade && !matches(readOnly ? ALLOWED_PATHS : MUTABLE_PATHS)) {
189193
return HttpServerResponse.text("Method Not Allowed", { status: 405 });
190194
}
191195
const controlsDevice =

‎apps/server/src/device/EasDeviceHost.test.ts‎

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ const fakeExpo = (
6262
const calls: Array<{ operation: string; variables: Record<string, unknown> }> = [];
6363
let created = 0;
6464
let createdName = "";
65+
const previews: string[] = [];
6566
let previewFails = options.failPreview ?? false;
6667
let createFails = options.failCreate ?? false;
6768
let sessionGated = false;
6869
const http = HttpClient.make((request) =>
6970
Effect.gen(function* () {
7071
const url = new URL(request.url);
7172
if (url.origin === "https://preview.test") {
73+
previews.push(`${url.pathname}${url.search}`);
7274
expect(request.headers.authorization).toBe("Bearer preview-token");
7375
if (options.previewGate) {
7476
yield* Deferred.succeed(options.previewGate.requested, undefined);
@@ -77,7 +79,17 @@ const fakeExpo = (
7779
if (previewFails) {
7880
return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 }));
7981
}
80-
return HttpClientResponse.fromWeb(request, Response.json({ device: "UDID-1" }));
82+
if (url.pathname.endsWith("/api/screenshot")) {
83+
return HttpClientResponse.fromWeb(request, new Response(Uint8Array.of(137, 80, 78, 71)));
84+
}
85+
return HttpClientResponse.fromWeb(
86+
request,
87+
Response.json(
88+
url.pathname === "/vendor/serve-emu/api"
89+
? { serial: "emulator-5554" }
90+
: { device: "UDID-1" },
91+
),
92+
);
8193
}
8294
const body =
8395
request.body._tag === "Uint8Array"
@@ -195,7 +207,7 @@ const fakeExpo = (
195207
const allowCreate = () => {
196208
createFails = false;
197209
};
198-
return { http, named, stopped, allowPreview, allowCreate };
210+
return { http, named, stopped, allowPreview, allowCreate, previews };
199211
};
200212

201213
const layer = Layer.mergeAll(
@@ -221,7 +233,10 @@ describe("EAS Simulator host", () => {
221233
const host = yield* makeHost(expo, config(), scope);
222234
const ready = yield* host.ensureReady(() => Effect.void);
223235
const catalog = (yield* ready.devices.list).devices;
224-
expect(catalog.map((device) => [device.id, device.booted])).toEqual([["eas-ios", false]]);
236+
expect(catalog.map((device) => [device.id, device.booted])).toEqual([
237+
["eas-ios", false],
238+
["eas-android", false],
239+
]);
225240

226241
expect(yield* ready.devices.boot(catalog[0]!)).toBe("session-1");
227242
expect(expo.named("T3CreateEasSimulatorSession")[0]?.variables.input).toMatchObject({
@@ -266,6 +281,55 @@ describe("EAS Simulator host", () => {
266281
}).pipe(Effect.scoped, Effect.provide(layer)),
267282
);
268283

284+
it.effect("runs an Android session through serve-emu beside an iOS one", () =>
285+
Effect.gen(function* () {
286+
const expo = fakeExpo();
287+
const scope = yield* Scope.make();
288+
const host = yield* makeHost(expo, config(), scope);
289+
expect(yield* host.platformAvailability("android")).toEqual({
290+
platform: "android",
291+
available: true,
292+
});
293+
const ready = yield* host.ensureReady(() => Effect.void);
294+
const [ios, android] = (yield* ready.devices.list).devices;
295+
expect(yield* ready.devices.boot(ios!)).toBe("session-1");
296+
expect(yield* ready.devices.boot(android!)).toBe("session-2");
297+
expect(expo.named("T3CreateEasSimulatorSession")[1]?.variables.input).toMatchObject({
298+
platform: "ANDROID",
299+
});
300+
const running = (yield* ready.devices.list).devices.find(
301+
(device) => device.id === "session-2",
302+
);
303+
expect(running).toMatchObject({ platform: "android", booted: true });
304+
305+
expect(yield* ready.devices.agentEndpoint!("session-2")).toMatchObject({
306+
agentDeviceId: "emulator-5554",
307+
});
308+
expect(Array.from(yield* ready.devices.screenshot(running!))).toEqual([137, 80, 78, 71]);
309+
expect(expo.previews.slice(-2)).toEqual([
310+
"/vendor/serve-emu/api",
311+
"/vendor/serve-emu/api/screenshot?device=emulator-5554",
312+
]);
313+
expect(
314+
yield* ready.upstream!(
315+
"/vendor/serve-emu/webrtc/offer",
316+
new URLSearchParams("device=session-2&hostId=eas"),
317+
),
318+
).toEqual({
319+
url: "https://preview.test/vendor/serve-emu/webrtc/offer?device=emulator-5554&hostId=eas",
320+
headers: { authorization: "Bearer preview-token" },
321+
protocols: ["serve-emu.token.preview-token"],
322+
});
323+
// Each session answers only on its own platform's stream.
324+
expect(
325+
yield* ready.upstream!("/vendor/serve-emu/api", new URLSearchParams("device=session-1")),
326+
).toBeNull();
327+
expect(
328+
yield* ready.upstream!("/vendor/serve-sim/api", new URLSearchParams("device=session-2")),
329+
).toBeNull();
330+
}).pipe(Effect.scoped, Effect.provide(layer)),
331+
);
332+
269333
it.effect("refuses to start past its session limit", () =>
270334
Effect.gen(function* () {
271335
const expo = fakeExpo();
@@ -292,6 +356,7 @@ describe("EAS Simulator host", () => {
292356
expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2");
293357
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
294358
"eas-ios",
359+
"eas-android",
295360
"session-1",
296361
"session-2",
297362
]);
@@ -306,6 +371,7 @@ describe("EAS Simulator host", () => {
306371
expect(expo.stopped()).toEqual(["session-1"]);
307372
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
308373
"eas-ios",
374+
"eas-android",
309375
"session-2",
310376
]);
311377
yield* Scope.close(scope, Exit.void);
@@ -421,7 +487,10 @@ describe("EAS Simulator host", () => {
421487
expect(refused.message).toContain("limited to 1 running session");
422488
stopFails = false;
423489
yield* ready.devices.list;
424-
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]);
490+
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
491+
"eas-ios",
492+
"eas-android",
493+
]);
425494
expect(expo.stopped().filter((id) => id === "session-1").length).toBeGreaterThanOrEqual(2);
426495
}).pipe(Effect.scoped, Effect.provide(layer)),
427496
);
@@ -438,7 +507,10 @@ describe("EAS Simulator host", () => {
438507
expect((yield* ready.devices.list).devices.map((device) => device.id)).toContain("session-1");
439508
// Expo ended it by itself: the stop keeps failing, but the status says so.
440509
status = "STOPPED";
441-
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]);
510+
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
511+
"eas-ios",
512+
"eas-android",
513+
]);
442514
status = "IN_PROGRESS";
443515
expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2");
444516
}).pipe(Effect.scoped, Effect.provide(layer)),
@@ -488,7 +560,10 @@ describe("EAS Simulator host", () => {
488560
expect(expo.stopped()).toEqual(["session-1"]);
489561
const failure = yield* Fiber.join(booting).pipe(Effect.flip);
490562
expect(failure.message).toContain("was stopped while the session started");
491-
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]);
563+
expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([
564+
"eas-ios",
565+
"eas-android",
566+
]);
492567
}).pipe(Effect.scoped, Effect.provide(layer)),
493568
);
494569
});

0 commit comments

Comments
 (0)