Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 93 additions & 22 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const fixture = Effect.fn("fixture")(function* (
readonly routesStreams?: boolean;
readonly onClose?: Effect.Effect<"ok" | "missing" | "reject" | "http_error" | "malformed">;
readonly onPlatformAvailability?: Effect.Effect<void>;
readonly onSummary?: Effect.Effect<void>;
} = {},
) {
const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
Expand All @@ -104,6 +105,7 @@ const fixture = Effect.fn("fixture")(function* (
const discoveryErrors = yield* Ref.make<Array<{ message: string; error?: string }>>([]);
const androidAvailable = yield* Ref.make(true);
const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" });
let running = false;
let booted = false;
let shutDown = false;
const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false };
Expand Down Expand Up @@ -232,12 +234,12 @@ const fixture = Effect.fn("fixture")(function* (
kind: connection.kind,
label: "Test server",
platforms: [
{ platform: "android", available: true },
{ platform: "ios", available: true },
{ platform: "android" as const, available: true },
{ platform: "ios" as const, available: true },
],
hubInstalled: true,
agentDeviceInstalled: false,
})),
})).pipe(Effect.tap(() => options.onSummary ?? Effect.void)),
platformAvailability: (platform) =>
(options.onPlatformAvailability ?? Effect.void).pipe(
Effect.as({ platform, available: true }),
Expand All @@ -246,6 +248,7 @@ const fixture = Effect.fn("fixture")(function* (
Effect.gen(function* () {
if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError")
return yield* runtimeFailure;
running = true;
starts.push("start");
yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…");
return ready;
Expand All @@ -261,7 +264,7 @@ const fixture = Effect.fn("fixture")(function* (
};
}),
current: Effect.sync(() =>
connection.online
connection.online && running
? {
...ready,
...(agentStarts.length > agentStops.length
Expand All @@ -279,6 +282,7 @@ const fixture = Effect.fn("fixture")(function* (
agentStops.push("stop");
}),
stop: Effect.sync(() => {
running = false;
starts.push("stop");
}),
};
Expand Down Expand Up @@ -452,6 +456,29 @@ describe("device setup consent", () => {
}).pipe(Effect.scoped),
);

it.effect(
"turning local simulators off stops the hub, hides its devices, and refuses opens",
() =>
Effect.gen(function* () {
const { service, starts, settings } = yield* fixture();
expect((yield* service.configure({ enabled: true })).devices).toHaveLength(1);
yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false }));
const off = yield* service.list;
expect(off.devices).toEqual([]);
expect(off.hostStatuses[LOCAL_DEVICE_HOST_ID]).toBeUndefined();
expect(starts).toEqual(["start", "stop"]);
yield* service.list;
expect(starts).toEqual(["start", "stop"]);
expect(yield* service.readinessIfSupported()).toBeNull();
const open = yield* service.readiness().pipe(Effect.flip);
expect(open).toMatchObject({ reason: expect.stringContaining("Settings → Devices") });

yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: true }));
expect((yield* service.list).devices.map((device) => device.id)).toEqual(["Pixel_API_35"]);
expect(starts).toEqual(["start", "stop", "start"]);
}).pipe(Effect.scoped),
);

it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () =>
Effect.gen(function* () {
const { service, requests } = yield* fixture();
Expand Down Expand Up @@ -673,32 +700,76 @@ it.effect.each([1, 9009])(
),
);

it.effect("publishes boot progress and does not restore sessions after support is disabled", () =>
it.effect("does not publish a late local listing failure after the source is disabled", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const entered = yield* Deferred.make<void>();
const finish = yield* Deferred.make<void>();
const { service } = yield* fixture(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))),
let pause = false;
const { service, settings } = yield* fixture(
Effect.void,
undefined,
false,
undefined,
false,
undefined,
{
onSummary: Effect.suspend(() =>
pause
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(finish)))
: Effect.void,
),
},
);
yield* service.configure({ enabled: true });
const opening = yield* service
.open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" })
.pipe(Effect.result, Effect.forkChild);
yield* Deferred.await(started);
expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([
"Pixel_API_35",
]);
yield* service.configure({ enabled: false });
pause = true;
const listing = yield* service.list.pipe(Effect.forkChild);
yield* Deferred.await(entered);
yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false }));
yield* service.list;
yield* Deferred.succeed(finish, undefined);
expect((yield* Fiber.join(opening))._tag).toBe("Failure");
const state = yield* service.state;
expect(state.hostStatus).toBe("disabled");
expect(state.devices).toEqual([]);
expect(state.sessions).toEqual([]);
expect(state.bootingDevices).toEqual([]);
const after = yield* Fiber.join(listing);
expect(after.hostStatus).toBe("idle");
expect(after.hostStatuses[LOCAL_DEVICE_HOST_ID]).toBeUndefined();
expect(after.devices).toEqual([]);
}).pipe(Effect.scoped),
);

it.effect.each(["all", "local"] as const)(
"does not restore sessions after %s support is disabled",
(disabled) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const finish = yield* Deferred.make<void>();
const { service, settings } = yield* fixture(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))),
);
yield* service.configure({ enabled: true });
const opening = yield* service
.open({
threadId: ThreadId.make("thread-1"),
deviceId: "Pixel_API_35",
platform: "android",
})
.pipe(Effect.result, Effect.forkChild);
yield* Deferred.await(started);
expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([
"Pixel_API_35",
]);
if (disabled === "all") yield* service.configure({ enabled: false });
else {
yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false }));
yield* service.list;
}
yield* Deferred.succeed(finish, undefined);
expect((yield* Fiber.join(opening))._tag).toBe("Failure");
const state = yield* service.state;
expect(state.hostStatus).toBe(disabled === "all" ? "disabled" : "idle");
expect(state.devices).toEqual([]);
expect(state.sessions).toEqual([]);
expect(state.bootingDevices).toEqual([]);
}).pipe(Effect.scoped),
);

it.effect.each([
{ case: "drops it on a host that ends devices itself", endsDevices: true, kept: 0 },
{
Expand Down
100 changes: 85 additions & 15 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export class DeviceService extends Context.Service<
Option.Option<DeviceSession>,
DeviceHostAmbiguousError | DeviceOnOtherHostError
>;
readonly ensureEnabled: (hostId?: DeviceHostId) => Effect.Effect<void, DeviceError>;
readonly readiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness, DeviceError>;
readonly readinessIfSupported: (
hostId?: DeviceHostId,
Expand Down Expand Up @@ -284,6 +285,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
enabled: value.enableDeviceSupport,
agentAccessEnabled: value.enableAgentDeviceAccess,
onboardingCompleted: value.deviceOnboardingCompleted,
localEnabled: value.localDevicesEnabled,
})),
Effect.mapError(
(cause) =>
Expand Down Expand Up @@ -413,17 +415,32 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
})),
);

const ensureEnabled: DeviceService["Service"]["ensureEnabled"] = Effect.fn(
"DeviceService.ensureEnabled",
)(function* (hostId) {
const host = yield* resolveHost(hostId);
const deviceSettings = yield* readDeviceSettings;
if (!deviceSettings.enabled) {
return yield* new DeviceHostUnavailableError({
hostId: host.id,
hostLabel: host.label,
reason:
"Device support is off. Enable it in the Device panel before installing or starting device tools.",
});
}
if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) {
return yield* new DeviceHostUnavailableError({
hostId: host.id,
hostLabel: host.label,
reason: "Local simulators are turned off. Turn them on in Settings → Devices.",
});
}
});

const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
function* (hostId) {
const host = yield* resolveHost(hostId);
if (!(yield* readDeviceSettings).enabled) {
return yield* new DeviceHostUnavailableError({
hostId: host.id,
hostLabel: host.label,
reason:
"Device support is off. Enable it in the Device panel before installing or starting device tools.",
});
}
yield* ensureEnabled(host.id);
const ready = yield* host
.ensureReady((status, detail) =>
setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid),
Expand Down Expand Up @@ -471,8 +488,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn(
"DeviceService.readinessIfSupported",
)(function* (hostId) {
if (!(yield* readDeviceSettings).enabled) return null;
const deviceSettings = yield* readDeviceSettings;
if (!deviceSettings.enabled) return null;
const host = yield* resolveHost(hostId);
if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) return null;
const summary = yield* host.summary;
if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
return null;
Expand All @@ -484,6 +503,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const deviceSettings = yield* readDeviceSettings;
if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
const host = yield* resolveHost(hostId);
if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) return null;
const summary = yield* host.summary;
if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available))
return null;
Expand Down Expand Up @@ -554,7 +574,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
return yield* lifecycleLock.withPermit(
Effect.gen(function* () {
if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host)
const deviceSettings = yield* readDeviceSettings;
if (
!deviceSettings.enabled ||
(ready.hostId === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) ||
!host ||
hosts.get(ready.hostId) !== host
)
return (yield* SynchronizedRef.get(stateRef)).state;
return yield* publish((state) => ({
...state,
Expand Down Expand Up @@ -588,8 +614,41 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
);
});

/** Stops the local hub and forgets what it listed, once, after local simulators are turned off. */
const retireLocalHost = lifecycleLock.withPermit(
Effect.gen(function* () {
const { state } = yield* SynchronizedRef.get(stateRef);
const isLocal = (entry: { readonly hostId: DeviceHostId }) =>
entry.hostId === LOCAL_DEVICE_HOST_ID;
const local = hosts.get(LOCAL_DEVICE_HOST_ID);
if (
!(local && (yield* local.current)) &&
!state.bootingDevices?.some(isLocal) &&
!state.devices.some(isLocal) &&
!state.sessions.some(isLocal) &&
state.hostStatuses[LOCAL_DEVICE_HOST_ID] === undefined
)
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (local) yield* local.stop;
yield* publish((current) => {
const { [LOCAL_DEVICE_HOST_ID]: _retired, ...hostStatuses } = current.hostStatuses;
return {
...current,
hostStatus: "idle",
hostStatusDetail: undefined,
hostStatuses,
devices: current.devices.filter((device) => !isLocal(device)),
sessions: current.sessions.filter((session) => !isLocal(session)),
bootingDevices: current.bootingDevices?.filter((device) => !isLocal(device)),
};
});
}),
);

const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
const deviceSettings = yield* readDeviceSettings;
if (!deviceSettings.enabled) return (yield* SynchronizedRef.get(stateRef)).state;
if (!deviceSettings.localEnabled) yield* retireLocalHost;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
yield* Effect.forEach(
hosts.values(),
(host) =>
Expand All @@ -598,10 +657,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
if (ready) yield* refresh(ready);
}).pipe(
Effect.catch((error) =>
setHostStatus(host.id, {
status: "failed",
detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message,
}),
lifecycleLock.withPermit(
Effect.gen(function* () {
const current = yield* readDeviceSettings;
if (!current.enabled || (host.id === LOCAL_DEVICE_HOST_ID && !current.localEnabled))
return;
yield* setHostStatus(host.id, {
status: "failed",
detail:
error._tag === "DeviceHostUnavailableError" ? error.reason : error.message,
});
}),
),
),
),
{ concurrency: 4 },
Expand Down Expand Up @@ -737,6 +804,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")(
function* (input, context) {
const host = yield* resolveHost(input.hostId);
yield* ensureEnabled(host.id);
yield* ensurePlatform(host, input.platform);
const ready = yield* readiness(host.id);
let state = yield* refresh(ready);
Expand Down Expand Up @@ -823,6 +891,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
hostLabel: host.label,
reason: "Device support was turned off while the device was opening.",
});
yield* ensureEnabled(host.id);
yield* publish((current) => ({
...current,
sessions: [
Expand Down Expand Up @@ -1191,6 +1260,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
screenshot,
locateDevice,
locateSession,
ensureEnabled,
readiness,
readinessIfSupported,
agentReadinessIfSupported,
Expand Down
Loading
Loading